@stratusagent/channel-slack 0.12.3 → 0.12.5

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();
@@ -2210,6 +2283,16 @@ export const createSlackChannelAdapter = (options) => {
2210
2283
  * corrects it.
2211
2284
  */
2212
2285
  const threadAddressee = new Map();
2286
+ /**
2287
+ * Threads whose owner this process can name (card 508): `true` for one
2288
+ * whose first message it saw, so the first claim in `threadAddressee` is
2289
+ * the owner, or the owner itself when the thread read named it after a
2290
+ * restart. Absent for a thread that started before this process did:
2291
+ * its first claim here is only the first one SEEN, and an untagged reply
2292
+ * in it asks the sessions and the thread (see `ownerAt`). Bounded like
2293
+ * the addressee map; a lost entry costs a lookup.
2294
+ */
2295
+ const threadOrigins = new Map();
2213
2296
  /**
2214
2297
  * One cold verdict per message — see `followUpWinner`. Bounded, because
2215
2298
  * an entry outlives its own resolution on purpose; an entry that has not
@@ -2382,8 +2465,12 @@ export const createSlackChannelAdapter = (options) => {
2382
2465
  // as an unlisted person. This fleet's own agents are not: they are
2383
2466
  // the conversation the mention joins — an authenticated peer whose
2384
2467
  // socket failed to start included, since what it said is still its.
2468
+ // Nor is a remote team agent this one answers (card 513): its
2469
+ // mention is admitted, so what it said before the mention is too.
2385
2470
  return admitsSender(connection.config, message.user ?? '')
2386
- || (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user));
2471
+ || (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user))
2472
+ || (message.user !== undefined && connection.teamId !== undefined
2473
+ && answersAgentMention(connection.config.agentId, connection.teamId, message.user));
2387
2474
  }
2388
2475
  return message.user !== undefined
2389
2476
  && isPersonSpeaking({ type: 'message', ts: message.ts, channel, ...(message.subtype ? { subtype: message.subtype } : {}) })
@@ -2919,6 +3006,73 @@ export const createSlackChannelAdapter = (options) => {
2919
3006
  }
2920
3007
  }));
2921
3008
  };
3009
+ /**
3010
+ * An agent's own post into a thread, heard in its own session for that
3011
+ * thread (card 364). A scheduled report or a `message.send` reply is a
3012
+ * bot message, which `admit` drops on purpose, and backfill reads a
3013
+ * thread only when a session for it is first made, so without this an
3014
+ * agent already in the thread never knew it had posted there: a reply
3015
+ * under its report reached a session with the question and not the
3016
+ * report. Through `observe`, so it starts no turn and lands only in a
3017
+ * session that already exists; a thread the agent was never in is read
3018
+ * by backfill when somebody first replies there.
3019
+ *
3020
+ * Labelled as the agent's own post, at the label the SENDING session's
3021
+ * words carry (`speaker.trust`, `sessionWriteTrust` of that session):
3022
+ * a report written by a firing that read a web page carries what that
3023
+ * page said, so it is heard at `external`, and one written from `user`
3024
+ * and `agent` context is heard at `agent`. Never higher, so hearing its
3025
+ * own report cannot launder anything into the thread. A post nobody
3026
+ * vouches for (no speaker) is not heard at all, rather than guessed at.
3027
+ *
3028
+ * Not when the sender IS that thread's session: that turn already holds
3029
+ * the call and what it said. Claimed on the intake chain BEFORE the
3030
+ * first chunk goes to Slack, so a reply typed under the report, even
3031
+ * while a long one is still being sent, is heard after it. `posted`
3032
+ * settles with what Slack accepted: all of it, the chunks before one it
3033
+ * refused (they are in the thread, so they are heard), or nothing. Not
3034
+ * awaited by `post`, only by the shutdown drain, so a long turn running
3035
+ * in the thread never holds up the sender, and two threads posting into
3036
+ * each other cannot wait on each other.
3037
+ */
3038
+ const hearOwnPost = (connection, channel, thread, posted, speaker) => {
3039
+ const gateway = gatewayRef;
3040
+ if (!gateway?.observe) {
3041
+ return;
3042
+ }
3043
+ const agentId = connection.config.agentId;
3044
+ const team = connection.teamId;
3045
+ const sessionId = channelSessionKey({ channel: 'slack', agentId, team, conversation: channel, thread });
3046
+ if (sessionId === speaker.sessionId) {
3047
+ return;
3048
+ }
3049
+ const metadata = {
3050
+ channel: 'slack',
3051
+ team,
3052
+ slackChannel: channel,
3053
+ slackUser: connection.botUserId,
3054
+ slackThread: thread,
3055
+ [SENDER_TRUST_METADATA_KEY]: leastTrusted('agent', speaker.trust),
3056
+ };
3057
+ const heard = onIntakeChain(sessionId, async () => {
3058
+ const text = await posted;
3059
+ if (text === undefined) {
3060
+ return { observed: undefined };
3061
+ }
3062
+ const author = await displayNameFor(connection, connection.botUserId);
3063
+ const observed = gateway.observe?.({ sessionId, agentId, message: `${author} (your own post, sent from another session): ${text}`, metadata });
3064
+ return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
3065
+ });
3066
+ // Tracked, so stop() drains it: a restart right after a report must not
3067
+ // close the stores while the thread's session is still to hear it.
3068
+ track(heard
3069
+ .then(async ({ observed }) => {
3070
+ await observed;
3071
+ })
3072
+ .catch((error) => {
3073
+ warn(`slack: ${agentId} could not hear its own post in ${channel} thread ${thread}: ${error instanceof Error ? error.message : String(error)}`);
3074
+ }));
3075
+ };
2922
3076
  /**
2923
3077
  * The addressable outbound seam — the channel contract's first real
2924
3078
  * `OutboundConnection` implementation. Validate-then-hand-over: every
@@ -2964,16 +3118,31 @@ export const createSlackChannelAdapter = (options) => {
2964
3118
  // A reply under a message rather than a post beside it. Every chunk of
2965
3119
  // a long one goes to the same thread, so a split reply stays together.
2966
3120
  const thread = address.thread?.trim() || undefined;
3121
+ const speaker = address.speaker;
2967
3122
  return {
2968
3123
  async post(text) {
2969
3124
  const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
3125
+ // Heard in the thread's session (card 364): its place is claimed
3126
+ // now, before Slack shows anything, and filled once we know what
3127
+ // Slack accepted.
3128
+ let accepted = () => { };
3129
+ if (thread && speaker && text.trim().length > 0) {
3130
+ hearOwnPost(connection, channelId, thread, new Promise((resolve) => { accepted = resolve; }), speaker);
3131
+ }
2970
3132
  let first;
2971
- for (const chunk of chunks) {
2972
- const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
2973
- if (!first) {
2974
- first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
3133
+ let sent = 0;
3134
+ try {
3135
+ for (const chunk of chunks) {
3136
+ const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
3137
+ sent += 1;
3138
+ if (!first) {
3139
+ first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
3140
+ }
2975
3141
  }
2976
3142
  }
3143
+ finally {
3144
+ accepted(sent === chunks.length ? text : sent > 0 ? chunks.slice(0, sent).join('\n') : undefined);
3145
+ }
2977
3146
  return first ?? { channel: channelId, ts: '' };
2978
3147
  },
2979
3148
  async edit(ref, text) {
@@ -3900,6 +4069,34 @@ export const createSlackChannelAdapter = (options) => {
3900
4069
  };
3901
4070
  /** One thread, whichever agent is talking in it. */
3902
4071
  const threadKeyFor = (parts) => `${parts.team}:${parts.conversation}:${parts.thread}`;
4072
+ /**
4073
+ * A thread's claims cut to `THREAD_HANDOVER_DEPTH`, oldest first out, but
4074
+ * never the two `ownerAt` reads past the rest, so at most one over: the
4075
+ * first claim, which is who the thread belongs to, and the latest claim
4076
+ * that took the thread.
4077
+ * Dropping that one would hand the thread back to its owner after enough
4078
+ * mentions of a third agent, while the judge that took it still answers.
4079
+ */
4080
+ const trimHandovers = (handovers) => {
4081
+ if (handovers.length <= THREAD_HANDOVER_DEPTH) {
4082
+ return handovers;
4083
+ }
4084
+ const first = handovers[0];
4085
+ let latestTake = -1;
4086
+ for (let index = handovers.length - 1; index > 0; index -= 1) {
4087
+ if (handovers[index]?.takes === true) {
4088
+ latestTake = index;
4089
+ break;
4090
+ }
4091
+ }
4092
+ const tailFrom = handovers.length - (THREAD_HANDOVER_DEPTH - 1);
4093
+ // The kept take sits on top of the full recent tail, one past the depth,
4094
+ // so `holderAt` still sees every recent claim it saw before.
4095
+ const tail = handovers.slice(tailFrom);
4096
+ return latestTake > 0 && latestTake < tailFrom
4097
+ ? [first, handovers[latestTake], ...tail]
4098
+ : [first, ...tail];
4099
+ };
3903
4100
  /**
3904
4101
  * Records that `agentId` holds the thread from the message at `ts`
3905
4102
  * onwards, wherever in the thread's history that message falls.
@@ -3912,7 +4109,8 @@ export const createSlackChannelAdapter = (options) => {
3912
4109
  * later insertion would take the thread from a reply that sits below it.
3913
4110
  * Only the same claim twice — the same message reaching a second
3914
4111
  * connection — is dropped.
3915
- * The oldest claims are dropped past `THREAD_HANDOVER_DEPTH`, and the
4112
+ * The oldest claims are dropped past `THREAD_HANDOVER_DEPTH` (but not
4113
+ * the first or the latest that took the thread: `trimHandovers`), and the
3916
4114
  * least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
3917
4115
  * losses cost a lookup, never an answer, since the sessions still hold
3918
4116
  * the durable version.
@@ -3926,7 +4124,7 @@ export const createSlackChannelAdapter = (options) => {
3926
4124
  * Slack timestamps compare as strings: `seconds.microseconds`, both
3927
4125
  * parts fixed width, so lexicographic order is chronological order.
3928
4126
  */
3929
- const rememberAddressee = (key, agentId, ts) => {
4127
+ const rememberAddressee = (key, agentId, ts, takes = false) => {
3930
4128
  const handovers = threadAddressee.get(key) ?? [];
3931
4129
  let at = handovers.length;
3932
4130
  while (at > 0 && handovers[at - 1].ts > ts) {
@@ -3934,17 +4132,24 @@ export const createSlackChannelAdapter = (options) => {
3934
4132
  }
3935
4133
  const before = handovers[at - 1];
3936
4134
  if (before && before.agentId === agentId && before.ts === ts) {
4135
+ if (takes) {
4136
+ before.takes = true;
4137
+ }
3937
4138
  return;
3938
4139
  }
3939
- handovers.splice(at, 0, { agentId, ts });
4140
+ handovers.splice(at, 0, takes ? { agentId, ts, takes } : { agentId, ts });
3940
4141
  // Re-inserted rather than mutated in place: a Map iterates in insertion
3941
4142
  // order, which is what makes the first key the one to evict.
3942
4143
  threadAddressee.delete(key);
3943
- threadAddressee.set(key, handovers.slice(-THREAD_HANDOVER_DEPTH));
4144
+ threadAddressee.set(key, trimHandovers(handovers));
3944
4145
  if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
3945
4146
  const oldest = threadAddressee.keys().next();
3946
4147
  if (!oldest.done) {
3947
4148
  threadAddressee.delete(oldest.value);
4149
+ // The owner goes with the record of who took the thread since: an
4150
+ // owner kept alone would stand in for a judge's takeover it no
4151
+ // longer knows of, where the thread read would see it.
4152
+ threadOrigins.delete(oldest.value);
3948
4153
  }
3949
4154
  }
3950
4155
  };
@@ -3966,12 +4171,126 @@ export const createSlackChannelAdapter = (options) => {
3966
4171
  }
3967
4172
  return undefined;
3968
4173
  };
4174
+ /**
4175
+ * Who an untagged reply at `ts` belongs to (card 508): the agent the
4176
+ * thread belongs to, not whoever spoke last. That is the first to hold
4177
+ * it — the agent its first message named, a home channel's owner, or the
4178
+ * bot (this daemon's or another's) that posted it — unless a judging
4179
+ * agent has since chosen to speak, which takes it (`takes`). A mention
4180
+ * of another agent gets that agent's answer to that message and nothing
4181
+ * more: the thread stays where it was, and so does every reply after.
4182
+ * In a thread that began before this process did, and that the thread
4183
+ * read has not placed yet, the first claim this process SAW stands in
4184
+ * for the owner: the best it has, and no worse than the last-speaker
4185
+ * rule it replaces. Undefined when this process has seen no claim at
4186
+ * all, which sends the question to the sessions and the thread.
4187
+ */
4188
+ const ownerAt = (key, ts) => {
4189
+ const origin = threadOrigins.get(key);
4190
+ const handovers = threadAddressee.get(key) ?? [];
4191
+ for (let index = handovers.length - 1; index >= 0; index -= 1) {
4192
+ const handover = handovers[index];
4193
+ if (handover && handover.ts <= ts && (handover.takes === true || (index === 0 && typeof origin !== 'string'))) {
4194
+ return handover.agentId;
4195
+ }
4196
+ }
4197
+ return typeof origin === 'string' ? origin : undefined;
4198
+ };
4199
+ /**
4200
+ * The agent the thread `key` belongs to, whoever holds it now: the owner
4201
+ * the thread read settled, or else the first claim this process saw.
4202
+ * Answering in its own thread takes the thread back from a judge that
4203
+ * spoke since (card 508), so the owner is asked for here, not the holder.
4204
+ */
4205
+ const threadOwner = (key) => {
4206
+ const origin = threadOrigins.get(key);
4207
+ return typeof origin === 'string' ? origin : threadAddressee.get(key)?.[0]?.agentId;
4208
+ };
4209
+ /** Records that this process saw the thread `key` begin, or who owns it. */
4210
+ const noteOrigin = (key, owner = true) => {
4211
+ if (owner === true && threadOrigins.has(key)) {
4212
+ return;
4213
+ }
4214
+ threadOrigins.delete(key);
4215
+ threadOrigins.set(key, owner);
4216
+ if (threadOrigins.size > THREAD_ADDRESSEE_CAPACITY) {
4217
+ const oldest = threadOrigins.keys().next();
4218
+ if (!oldest.done) {
4219
+ threadOrigins.delete(oldest.value);
4220
+ }
4221
+ }
4222
+ };
4223
+ /**
4224
+ * A message that starts a thread, seen by any connection: from here on
4225
+ * this process sees the thread's first claim, so it knows the owner. One
4226
+ * this daemon's own agent posted (a schedule's delivery, a message.send)
4227
+ * is that agent's thread.
4228
+ */
4229
+ const noteThreadStart = (connection, args) => {
4230
+ const event = args.event;
4231
+ if (!event || (event.type !== 'message' && event.type !== 'app_mention') || event.channel_type === 'im') {
4232
+ return;
4233
+ }
4234
+ if (event.thread_ts !== undefined && event.thread_ts !== event.ts) {
4235
+ return;
4236
+ }
4237
+ if (event.subtype !== undefined && event.subtype !== 'bot_message' && !HUMAN_MESSAGE_SUBTYPES.has(event.subtype)) {
4238
+ return;
4239
+ }
4240
+ const team = args.body?.team_id ?? connection.teamId;
4241
+ const key = threadKeyFor({ team, conversation: event.channel, thread: event.ts });
4242
+ if (threadOrigins.has(key)) {
4243
+ return;
4244
+ }
4245
+ noteOrigin(key);
4246
+ if (event.user !== undefined) {
4247
+ for (const [agentId, identity] of botIdentities) {
4248
+ if (identity.teamId === team && identity.botUserId === event.user) {
4249
+ rememberAddressee(key, agentId, event.ts);
4250
+ }
4251
+ }
4252
+ }
4253
+ // A team agent on another gateway that starts a thread owns it, even
4254
+ // when its first message asks an agent here something: that agent
4255
+ // answers the question, and the thread stays the asker's. Recorded
4256
+ // here, ahead of the answer, since `noteForeignSpeaker` leaves out a
4257
+ // bot's question to an agent here.
4258
+ const bot = event.bot_id !== undefined || event.subtype === 'bot_message';
4259
+ if (bot && event.user !== undefined && !isFleetBot(team, event.user) && !startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
4260
+ rememberAddressee(key, `${FOREIGN_SPEAKER_PREFIX}${event.user}`, event.ts);
4261
+ }
4262
+ };
3969
4263
  /**
3970
4264
  * Whether `user` is one of this adapter's own agents in `team`. Their
3971
4265
  * replies reach the holder record through `ReplyOutcome.spokeAt`, and
3972
4266
  * their sessions answer for them after a restart.
3973
4267
  */
3974
4268
  const isFleetBot = (team, user) => user !== undefined && [...botIdentities.values()].some((identity) => identity.teamId === team && identity.botUserId === user);
4269
+ /**
4270
+ * Whether a bot message is a team agent asking one of this daemon's
4271
+ * agents something (card 513): a question, not another voice taking the
4272
+ * thread. The agent asked answers it, and the thread stays with whoever
4273
+ * held it. Used live and when a thread is read back after a restart.
4274
+ */
4275
+ const asksAgentHere = (team, asker, text) => asker !== undefined && [...botIdentities].some(([agentId, identity]) => identity.teamId === team && mentions(text ?? '', identity.botUserId) && answersAgentMention(agentId, team, asker));
4276
+ /**
4277
+ * Whether a bot message is another app starting a thread — an alert, a
4278
+ * deploy note — rather than an agent (card 508). That starts the thread
4279
+ * without owning it, so an agent named under it keeps the conversation it
4280
+ * was given. A team agent (`agentMentions.team`) starting one owns it the
4281
+ * way an agent here does, and any app replying inside a thread counts as
4282
+ * a speaker there, since an agent on a gateway nobody listed looks like
4283
+ * any other app.
4284
+ */
4285
+ const startsThreadAsIntegration = (team, user, threadTs, ts) => {
4286
+ if (threadTs !== undefined && threadTs !== ts) {
4287
+ return false;
4288
+ }
4289
+ return user === undefined || ![...botIdentities].some(([agentId, identity]) => {
4290
+ const config = agentConfigFor(agentId);
4291
+ return config !== undefined && teamAgentFor(config, identity.teamId, team, user) !== undefined;
4292
+ });
4293
+ };
3975
4294
  /**
3976
4295
  * Records a bot this adapter does not serve as the thread's holder from
3977
4296
  * the message it posted. An agent on another gateway answers in the same
@@ -4000,6 +4319,12 @@ export const createSlackChannelAdapter = (options) => {
4000
4319
  if (speaker === undefined || isFleetBot(team, event.user)) {
4001
4320
  return;
4002
4321
  }
4322
+ if (asksAgentHere(team, event.user, event.text)) {
4323
+ return;
4324
+ }
4325
+ if (startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
4326
+ return;
4327
+ }
4003
4328
  const thread = event.thread_ts ?? event.ts;
4004
4329
  rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
4005
4330
  };
@@ -4032,16 +4357,30 @@ export const createSlackChannelAdapter = (options) => {
4032
4357
  const botUserId = botIdentities.get(candidate.agentId)?.botUserId;
4033
4358
  if (botUserId !== undefined) {
4034
4359
  byBotUser.set(botUserId, candidate.agentId);
4035
- held.set(candidate.agentId, 0);
4360
+ held.set(candidate.agentId, Number.POSITIVE_INFINITY);
4036
4361
  continue;
4037
4362
  }
4038
4363
  const spoke = candidate.lastSpokeAt === undefined ? Number.NaN : Date.parse(candidate.lastSpokeAt) / 1000;
4039
- held.set(candidate.agentId, Number.isFinite(spoke) ? spoke : 0);
4364
+ held.set(candidate.agentId, Number.isFinite(spoke) ? spoke : Number.POSITIVE_INFINITY);
4365
+ }
4366
+ // Where each last held it, for a stop, which is for whoever was asked
4367
+ // last (card 337), not for the thread's owner.
4368
+ const last = new Map();
4369
+ for (const [agentId, at] of held) {
4370
+ last.set(agentId, Number.isFinite(at) ? at : 0);
4040
4371
  }
4041
4372
  const place = (agentId, at) => {
4042
- held.set(agentId, Math.max(held.get(agentId) ?? 0, at));
4373
+ held.set(agentId, Math.min(held.get(agentId) ?? Number.POSITIVE_INFINITY, at));
4374
+ last.set(agentId, Math.max(last.get(agentId) ?? 0, at));
4043
4375
  };
4044
- let foreign = 0;
4376
+ let foreign = Number.POSITIVE_INFINITY;
4377
+ let lastForeign = 0;
4378
+ // Each of these agents' own messages, and whether the message before it
4379
+ // had asked it anything: one nobody asked for is a judge taking the
4380
+ // thread, or its owner answering in it (card 508).
4381
+ const posts = [];
4382
+ let asked = new Set();
4383
+ const askedIn = (text) => new Set([...byBotUser].filter(([botUserId]) => mentions(text ?? '', botUserId)).map(([, agentId]) => agentId));
4045
4384
  try {
4046
4385
  let cursor;
4047
4386
  let pages = 0;
@@ -4063,14 +4402,35 @@ export const createSlackChannelAdapter = (options) => {
4063
4402
  const own = message.user === undefined ? undefined : byBotUser.get(message.user);
4064
4403
  if (own !== undefined) {
4065
4404
  place(own, at);
4405
+ posts.push({ agentId: own, at, asked: asked.has(own) });
4406
+ // An agent here naming a colleague that answers its mentions
4407
+ // asks it, as `admitAgentMention` has it live, so the answer is
4408
+ // not a judge speaking unasked.
4409
+ const sender = message.user;
4410
+ const named = sender === undefined
4411
+ ? []
4412
+ : [...askedIn(message.text)].filter((agentId) => agentId !== own && answersAgentMention(agentId, parts.team, sender));
4413
+ if (named.length > 0) {
4414
+ asked = new Set(named);
4415
+ }
4066
4416
  continue;
4067
4417
  }
4068
4418
  const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
4069
4419
  if (bot) {
4070
4420
  // A bot's mention hands nothing over live (admit ignores bots),
4071
4421
  // so here it is only another app speaking, or a peer's words.
4072
- if (!isFleetBot(parts.team, message.user)) {
4073
- foreign = Math.max(foreign, at);
4422
+ // A team agent's question to an agent here is not it taking the
4423
+ // thread, unless it started the thread with it: then the thread
4424
+ // is its own, as it is live.
4425
+ const asks = asksAgentHere(parts.team, message.user, message.text);
4426
+ if (asks) {
4427
+ asked = askedIn(message.text);
4428
+ }
4429
+ if (!isFleetBot(parts.team, message.user) &&
4430
+ (!asks || message.ts === parts.thread) &&
4431
+ !startsThreadAsIntegration(parts.team, message.user, parts.thread, message.ts ?? '')) {
4432
+ foreign = Math.min(foreign, at);
4433
+ lastForeign = Math.max(lastForeign, at);
4074
4434
  }
4075
4435
  continue;
4076
4436
  }
@@ -4080,10 +4440,19 @@ export const createSlackChannelAdapter = (options) => {
4080
4440
  if (sender === undefined) {
4081
4441
  continue;
4082
4442
  }
4443
+ asked = askedIn(message.text);
4083
4444
  const named = agentNamedIn(message.text ?? '', parts.team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
4084
4445
  if (named !== undefined && held.has(named) && botIdentities.get(named)?.botUserId !== undefined) {
4085
4446
  place(named, at);
4086
4447
  }
4448
+ // A root that names nobody in a home channel is its owner's, as
4449
+ // the live home-channel rule has it.
4450
+ const home = message.ts === parts.thread && named === undefined
4451
+ ? homeOwners.get(`${parts.team}:${parts.conversation}`)
4452
+ : undefined;
4453
+ if (home !== undefined && held.has(home) && admitsSender(agentConfigFor(home), sender)) {
4454
+ place(home, at);
4455
+ }
4087
4456
  }
4088
4457
  cursor = page.response_metadata?.next_cursor || undefined;
4089
4458
  } while (cursor && pages < EARLIER_THREAD_PAGES);
@@ -4095,12 +4464,7 @@ export const createSlackChannelAdapter = (options) => {
4095
4464
  warn(`slack: could not read thread ${parts.thread} in ${parts.conversation} to see who spoke last, so it is routed as before: ${error instanceof Error ? error.message : String(error)}`);
4096
4465
  return undefined;
4097
4466
  }
4098
- return { held, foreign };
4099
- };
4100
- /** Whether another app spoke in the thread after `agentId` last held it. */
4101
- const foreignSpokeSince = async (agentId, parts, ts, lastSpokeAt) => {
4102
- const read = await threadPositions([{ agentId, ...(lastSpokeAt !== undefined ? { lastSpokeAt } : {}) }], parts, ts);
4103
- return read !== undefined && read.foreign > (read.held.get(agentId) ?? 0);
4467
+ return { held, last, foreign, lastForeign, posts };
4104
4468
  };
4105
4469
  /**
4106
4470
  * Which of this process's agents a message names, or undefined if it
@@ -4166,25 +4530,19 @@ export const createSlackChannelAdapter = (options) => {
4166
4530
  * restart forgets nothing.
4167
4531
  *
4168
4532
  * Whose turn it is, is the second. In a thread with several agents an
4169
- * unaddressed reply belongs to **whoever spoke last**, the way it does
4170
- * between people: answer the voice that just answered you, and tag
4171
- * someone by name to change that. `lastSpokeAt` orders them — when each
4172
- * agent last *replied*, not when its session last changed, because a turn
4173
- * saves on tool results and approval checkpoints without having said
4174
- * anything, and a recovery resuming after a restart lands in exactly this
4175
- * window.
4533
+ * unaddressed reply belongs to the agent the thread belongs to (card
4534
+ * 508): the first to hold it, read off the thread itself. Only when the
4535
+ * thread can't be read do the sessions decide, and then by `lastSpokeAt`
4536
+ * — when each agent last *replied*, not when its session last changed,
4537
+ * because a turn saves on tool results and approval checkpoints without
4538
+ * having said anything — since that is the only order they keep.
4176
4539
  *
4177
4540
  * Nobody is the answer only when the agents cannot be *ordered* — nothing
4178
- * in the thread has spoken (a host whose routing carries no `lastSpokeAt`
4179
- * at all reads this way, and so does a thread where every agent is still
4180
- * on its first turn), or the newest two tie — because guessing there
4181
- * would put two answers under one message. An agent that simply has not
4182
- * spoken is a different thing and is skipped rather than fatal: it cannot
4183
- * have spoken last, and letting it silence the agent that did would leave
4184
- * a reply nobody answers. The agent alone in its thread, which is nearly
4185
- * every thread, is answered without the question arising.
4541
+ * in the thread has spoken, or two tie — because guessing there would put
4542
+ * two answers under one message. The agent alone in its thread, which is
4543
+ * nearly every thread, is answered without the question arising.
4186
4544
  */
4187
- const resolveFollowUpWinner = async (parts, ts) => {
4545
+ const resolveFollowUpWinner = async (parts, ts, mode = 'reply') => {
4188
4546
  const gateway = gatewayRef;
4189
4547
  if (!gateway?.sessionRouting || !parts.thread) {
4190
4548
  return undefined;
@@ -4262,22 +4620,107 @@ export const createSlackChannelAdapter = (options) => {
4262
4620
  }
4263
4621
  for (const [agentId, identity] of botIdentities) {
4264
4622
  if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
4265
- return await foreignSpokeSince(agentId, parts, ts, undefined) ? undefined : agentId;
4623
+ noteOrigin(threadKeyFor(parts), agentId);
4624
+ return agentId;
4266
4625
  }
4267
4626
  }
4268
4627
  return undefined;
4269
4628
  }
4270
- if (engaged.length === 1) {
4271
- return await foreignSpokeSince(first.agentId, parts, ts, first.lastSpokeAt) ? undefined : first.agentId;
4629
+ // The thread says who it belongs to: whichever of these agents held it
4630
+ // first — its first message named it, or it posted there first — or
4631
+ // another app that spoke before any of them did (card 508). When it
4632
+ // can't be read, the sessions decide: the only agent in it, or, as a
4633
+ // last resort, the one that spoke last, which is all they record.
4634
+ // The agent that posted the root takes part even with no session here:
4635
+ // a post made through message.send or a schedule leaves none, and a
4636
+ // colleague named under it since must not look like the owner.
4637
+ const candidates = [...engaged];
4638
+ if (parts.parentUser !== undefined) {
4639
+ for (const [agentId, identity] of botIdentities) {
4640
+ if (identity.teamId === parts.team && identity.botUserId === parts.parentUser && !candidates.some((entry) => entry.agentId === agentId)) {
4641
+ candidates.push({ agentId });
4642
+ }
4643
+ }
4644
+ }
4645
+ const read = await threadPositions(candidates, parts, ts);
4646
+ if (!read) {
4647
+ return engaged.length === 1 ? first.agentId : lastSpeaker(engaged);
4648
+ }
4649
+ if (mode === 'stop') {
4650
+ // A stop is for whoever was asked last (card 337), as `holderAt` has
4651
+ // it live: the turn it means is that agent's, not the owner's.
4652
+ let holder;
4653
+ let tiedLast = false;
4654
+ for (const entry of candidates) {
4655
+ const at = read.last.get(entry.agentId) ?? 0;
4656
+ if (at <= 0) {
4657
+ continue;
4658
+ }
4659
+ if (!holder || at > holder.at) {
4660
+ holder = { agentId: entry.agentId, at };
4661
+ tiedLast = false;
4662
+ }
4663
+ else if (at === holder.at) {
4664
+ tiedLast = true;
4665
+ }
4666
+ }
4667
+ if (!holder) {
4668
+ return read.lastForeign > 0 ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
4669
+ }
4670
+ return tiedLast || read.lastForeign > holder.at ? undefined : holder.agentId;
4671
+ }
4672
+ let owner;
4673
+ let tiedAt = false;
4674
+ for (const entry of candidates) {
4675
+ const at = read.held.get(entry.agentId) ?? Number.POSITIVE_INFINITY;
4676
+ if (!Number.isFinite(at) || at <= 0) {
4677
+ continue;
4678
+ }
4679
+ if (!owner || at < owner.at) {
4680
+ owner = { agentId: entry.agentId, at };
4681
+ tiedAt = false;
4682
+ }
4683
+ else if (at === owner.at) {
4684
+ tiedAt = true;
4685
+ }
4686
+ }
4687
+ // A judging agent that spoke unasked took the thread, and the owner
4688
+ // answering in it took it back, as live (card 508): the latest of those
4689
+ // holds it. The live record of who took it is lost on a restart; the
4690
+ // thread keeps it. Only a judge takes by speaking unasked, as only its
4691
+ // turns are judged live; another agent's post there (a message.send)
4692
+ // takes nothing.
4693
+ const ownerId = owner && !tiedAt && !(read.foreign < owner.at) ? owner.agentId : undefined;
4694
+ let taken;
4695
+ for (const post of read.posts) {
4696
+ const takes = post.agentId === ownerId || (!post.asked && listensOf(post.agentId) === 'judge');
4697
+ if (takes && (!taken || post.at > taken.at)) {
4698
+ taken = post;
4699
+ }
4700
+ }
4701
+ if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
4702
+ return taken.agentId;
4703
+ }
4704
+ if (!owner) {
4705
+ return Number.isFinite(read.foreign) ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
4706
+ }
4707
+ if (ownerId === undefined) {
4708
+ return undefined;
4272
4709
  }
4710
+ noteOrigin(threadKeyFor(parts), ownerId);
4711
+ return ownerId;
4712
+ };
4713
+ /**
4714
+ * Of several agents in a thread, the one whose session says it spoke
4715
+ * last — the sessions' only ordering, used when the thread itself can't
4716
+ * be read. Undefined when nobody has spoken or the newest two tie, since
4717
+ * guessing there would put two answers under one message.
4718
+ */
4719
+ const lastSpeaker = (engaged) => {
4273
4720
  let latest;
4274
4721
  let tied = false;
4275
4722
  for (const entry of engaged) {
4276
4723
  if (entry.lastSpokeAt === undefined) {
4277
- // In the thread, but has never said anything in it — mentioned and
4278
- // still on its first turn, or a turn that produced no text. It
4279
- // cannot be the one who spoke last, and it must not stop the agent
4280
- // that did from answering.
4281
4724
  continue;
4282
4725
  }
4283
4726
  if (!latest || entry.lastSpokeAt > latest.lastSpokeAt) {
@@ -4288,34 +4731,7 @@ export const createSlackChannelAdapter = (options) => {
4288
4731
  tied = true;
4289
4732
  }
4290
4733
  }
4291
- const bySession = tied ? undefined : latest?.agentId;
4292
- // Sessions date a reply by when it finished, the thread by where its
4293
- // placeholder went up, and an agent on another gateway may have spoken
4294
- // since: only the thread can say who holds it. When it can't be read,
4295
- // the sessions decide, as before.
4296
- const read = await threadPositions(engaged, parts, ts);
4297
- if (!read) {
4298
- return bySession;
4299
- }
4300
- let holder;
4301
- let tiedAt = false;
4302
- for (const entry of engaged) {
4303
- const at = read.held.get(entry.agentId) ?? 0;
4304
- if (at <= 0) {
4305
- continue;
4306
- }
4307
- if (!holder || at > holder.at) {
4308
- holder = { agentId: entry.agentId, at };
4309
- tiedAt = false;
4310
- }
4311
- else if (at === holder.at) {
4312
- tiedAt = true;
4313
- }
4314
- }
4315
- if (!holder) {
4316
- return read.foreign > 0 ? undefined : bySession;
4317
- }
4318
- return tiedAt || read.foreign > holder.at ? undefined : holder.agentId;
4734
+ return tied ? undefined : latest?.agentId;
4319
4735
  };
4320
4736
  /**
4321
4737
  * `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
@@ -4335,13 +4751,15 @@ export const createSlackChannelAdapter = (options) => {
4335
4751
  * this after the winner has finished, and recomputing for that agent is
4336
4752
  * precisely the disagreement the memo exists to prevent.
4337
4753
  */
4338
- const followUpWinner = (parts, ts) => {
4339
- const key = `${threadKeyFor(parts)}:${ts}`;
4754
+ const followUpWinner = (parts, ts, mode = 'reply') => {
4755
+ // A stop asks a different question (whoever was asked last), so it
4756
+ // gets its own verdict, the same for every agent that asks it.
4757
+ const key = `${threadKeyFor(parts)}:${ts}${mode === 'stop' ? ':stop' : ''}`;
4340
4758
  const existing = coldVerdicts.get(key);
4341
4759
  if (existing) {
4342
4760
  return existing.verdict;
4343
4761
  }
4344
- const entry = { verdict: resolveFollowUpWinner(parts, ts), settled: false };
4762
+ const entry = { verdict: resolveFollowUpWinner(parts, ts, mode), settled: false };
4345
4763
  coldVerdicts.set(key, entry);
4346
4764
  void entry.verdict.then(() => {
4347
4765
  entry.settled = true;
@@ -4388,11 +4806,179 @@ export const createSlackChannelAdapter = (options) => {
4388
4806
  * case, where by definition no message of this thread has been seen in
4389
4807
  * this process.
4390
4808
  */
4809
+ /**
4810
+ * Stop (card 337): cancels every turn this agent is running or has queued
4811
+ * in this conversation, including messages still in intake, and says
4812
+ * nothing was stopped when there was nothing to stop. Synchronous, and
4813
+ * called off the intake chain before any dispatch, so a stop is never
4814
+ * queued behind the turn it stops nor merged into the next one. The
4815
+ * caller has checked that the sender is an approver: a stop throws away
4816
+ * work someone else asked for.
4817
+ *
4818
+ * `fromIntake` is a stop that could only be placed on the intake chain,
4819
+ * once the sessions named this agent (the cold case). Everything ahead of
4820
+ * it on the chain has already been dispatched, and everything still in
4821
+ * intake arrived after it, so it cancels the dispatched turns only.
4822
+ */
4823
+ const stopTurns = (connection, sessionId, userId, channel, fromIntake = false) => {
4824
+ const running = renderers.get(sessionId) ?? [];
4825
+ const inIntake = fromIntake ? 0 : intakeInFlight.get(sessionId) ?? 0;
4826
+ if (running.length === 0 && inIntake === 0) {
4827
+ return false;
4828
+ }
4829
+ for (const renderer of running) {
4830
+ if (renderer.stoppedBy === undefined) {
4831
+ renderer.stoppedBy = userId;
4832
+ renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${userId}`));
4833
+ }
4834
+ }
4835
+ if (inIntake > 0) {
4836
+ stopsBySession.set(sessionId, { count: (stopsBySession.get(sessionId)?.count ?? 0) + 1, by: userId });
4837
+ }
4838
+ log(`slack: ${userId} stopped ${running.length + inIntake} turn(s) of ${connection.config.agentId} in ${channel}`);
4839
+ return true;
4840
+ };
4841
+ /**
4842
+ * Agent mentions admitted per thread since a person last spoke there, by
4843
+ * message ts (card 513). A copy of one message reaching a second socket,
4844
+ * or redelivered, is the same hop. Bounded like the addressee map.
4845
+ */
4846
+ const agentHops = new Map();
4847
+ /**
4848
+ * Which team agent a bot user in `team` is, for the agent configured by
4849
+ * `config` in workspace `agentTeam`, if any. A configured id counts only
4850
+ * in that agent's own workspace: user ids are unique per workspace, so the
4851
+ * same id elsewhere is some other bot.
4852
+ */
4853
+ const teamAgentFor = (config, agentTeam, team, botUser) => {
4854
+ for (const [agentId, identity] of botIdentities) {
4855
+ if (identity.teamId === team && identity.botUserId === botUser) {
4856
+ return agentId;
4857
+ }
4858
+ }
4859
+ if (agentTeam === undefined || agentTeam !== team) {
4860
+ return undefined;
4861
+ }
4862
+ for (const [name, id] of Object.entries(config.agentMentions?.team ?? {})) {
4863
+ if (id === botUser) {
4864
+ return name;
4865
+ }
4866
+ }
4867
+ return undefined;
4868
+ };
4869
+ /**
4870
+ * Whether `agentId` answers a mention from `botUser` in `team`: it has
4871
+ * agent mentions on, the bot is a team agent to it, and on its list if it
4872
+ * keeps one. The hop limit is not asked here.
4873
+ */
4874
+ const answersAgentMention = (agentId, team, botUser) => {
4875
+ const config = agentConfigFor(agentId);
4876
+ const settings = config?.agentMentions;
4877
+ if (!config || !settings) {
4878
+ return false;
4879
+ }
4880
+ const name = teamAgentFor(config, botIdentities.get(agentId)?.teamId, team, botUser);
4881
+ return name !== undefined && (settings.only === undefined || settings.only.includes(name));
4882
+ };
4883
+ /**
4884
+ * A message another agent's bot posted, for an agent that answers team
4885
+ * agents (`agentMentions`, card 513). Admitted only when it names this
4886
+ * agent, comes from a team agent this one listens to, and the thread is
4887
+ * under its hop limit; anything else from a bot is dropped, as every
4888
+ * bot message was before. Never part of the holder rule: a bot naming an
4889
+ * agent asks it one thing, and hands it nothing.
4890
+ */
4891
+ const admitAgentMention = (connection, args) => {
4892
+ const event = args.event;
4893
+ const settings = connection.config.agentMentions;
4894
+ if (!settings || !event || event.user === undefined || event.user === connection.botUserId) {
4895
+ return undefined;
4896
+ }
4897
+ if ((event.type !== 'app_mention' && event.type !== 'message') || event.channel_type === 'im') {
4898
+ return undefined;
4899
+ }
4900
+ if (event.subtype !== undefined && !AGENT_SPEECH_SUBTYPES.has(event.subtype)) {
4901
+ return undefined;
4902
+ }
4903
+ const text = event.text ?? '';
4904
+ if (!mentions(text, connection.botUserId)) {
4905
+ return undefined;
4906
+ }
4907
+ const team = args.body?.team_id ?? connection.teamId;
4908
+ const name = teamAgentFor(connection.config, connection.teamId, team, event.user);
4909
+ if (name === undefined) {
4910
+ return undefined;
4911
+ }
4912
+ const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
4913
+ if (settings.only !== undefined && !settings.only.includes(name)) {
4914
+ if (!alreadySeen(eventKey)) {
4915
+ log(`slack: ${connection.config.agentId} ignored a mention from agent ${name}: not on its agent mention list`);
4916
+ }
4917
+ return undefined;
4918
+ }
4919
+ const thread = event.thread_ts ?? event.ts;
4920
+ const threadKey = threadKeyFor({ team, conversation: event.channel, thread });
4921
+ const hops = agentHops.get(threadKey) ?? [];
4922
+ if (!hops.includes(event.ts)) {
4923
+ if (hops.length >= AGENT_MENTION_HOP_LIMIT) {
4924
+ if (!alreadySeen(eventKey)) {
4925
+ 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`);
4926
+ }
4927
+ return undefined;
4928
+ }
4929
+ agentHops.delete(threadKey);
4930
+ agentHops.set(threadKey, [...hops, event.ts]);
4931
+ if (agentHops.size > THREAD_ADDRESSEE_CAPACITY) {
4932
+ const oldest = agentHops.keys().next();
4933
+ if (!oldest.done) {
4934
+ agentHops.delete(oldest.value);
4935
+ }
4936
+ }
4937
+ }
4938
+ if (alreadySeen(eventKey)) {
4939
+ return undefined;
4940
+ }
4941
+ return {
4942
+ event,
4943
+ isDm: false,
4944
+ team,
4945
+ eventKey,
4946
+ userId: event.user,
4947
+ sessionId: channelSessionKey({ channel: 'slack', agentId: connection.config.agentId, team, conversation: event.channel, thread }),
4948
+ thread,
4949
+ threadKey,
4950
+ settled: true,
4951
+ overhear: false,
4952
+ judge: false,
4953
+ fromAgent: name,
4954
+ };
4955
+ };
4391
4956
  const admit = (connection, args) => {
4392
4957
  const event = args.event;
4393
4958
  if (gatewayRef) {
4959
+ noteThreadStart(connection, args);
4394
4960
  noteForeignSpeaker(connection, args);
4395
4961
  }
4962
+ if (gatewayRef && event && (event.bot_id !== undefined || event.subtype === 'bot_message')) {
4963
+ return admitAgentMention(connection, args);
4964
+ }
4965
+ if (event && event.user !== undefined && event.channel_type !== 'im' && isPersonSpeaking(event) && admitsSender(connection.config, event.user)) {
4966
+ // A person this agent admits, speaking in a thread, ends a run of
4967
+ // agent mentions there. Someone it refuses can't restart one. Only
4968
+ // hops before the person's message reset: another agent's socket can
4969
+ // deliver an older human message late, after newer hops.
4970
+ const hopKey = threadKeyFor({ team: args.body?.team_id ?? connection.teamId, conversation: event.channel, thread: event.thread_ts ?? event.ts });
4971
+ const hops = agentHops.get(hopKey);
4972
+ if (hops) {
4973
+ const newer = hops.filter((hop) => compareSlackTs(hop, event.ts) > 0);
4974
+ if (newer.length > 0) {
4975
+ agentHops.set(hopKey, newer);
4976
+ }
4977
+ else {
4978
+ agentHops.delete(hopKey);
4979
+ }
4980
+ }
4981
+ }
4396
4982
  if (!gatewayRef || !event || !event.user || event.bot_id || !isPersonSpeaking(event)) {
4397
4983
  return undefined;
4398
4984
  }
@@ -4496,8 +5082,34 @@ export const createSlackChannelAdapter = (options) => {
4496
5082
  // Consuming the key here is safe because a refused message records
4497
5083
  // nothing a later copy could be needed for.
4498
5084
  if (!admitsSender(connection.config, sender)) {
5085
+ // An approver the operator did not list as a principal can still stop
5086
+ // the agent (card 337): approving is its own grant. Only when the
5087
+ // message names this agent (or is a DM to it), and nothing else they
5088
+ // say gets through.
5089
+ if (addressed &&
5090
+ (connection.config.approvers ?? []).includes(sender) &&
5091
+ isStopRequest(text, connection.botUserId) &&
5092
+ !alreadySeen(eventKey)) {
5093
+ const stopSession = channelSessionKey({
5094
+ channel: 'slack',
5095
+ agentId: connection.config.agentId,
5096
+ team,
5097
+ conversation: event.channel,
5098
+ ...(thread ? { thread } : {}),
5099
+ });
5100
+ if (stopTurns(connection, stopSession, sender, event.channel)) {
5101
+ return undefined;
5102
+ }
5103
+ log(`slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`);
5104
+ return undefined;
5105
+ }
4499
5106
  const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
4500
- const held = threadKey !== undefined ? holderAt(threadKey, event.ts) : undefined;
5107
+ // Whose message this is, as for an admitted one: the thread's owner's
5108
+ // (card 508), or for a stop, whoever was asked last (card 337).
5109
+ const refusedStop = (connection.config.approvers ?? []).includes(sender) && isStopRequest(text, connection.botUserId);
5110
+ const held = threadKey === undefined
5111
+ ? undefined
5112
+ : (refusedStop ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
4501
5113
  if (addressed || held === connection.config.agentId) {
4502
5114
  if (!alreadySeen(eventKey)) {
4503
5115
  log(refusal);
@@ -4516,7 +5128,7 @@ export const createSlackChannelAdapter = (options) => {
4516
5128
  conversation: event.channel,
4517
5129
  thread,
4518
5130
  ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
4519
- }, event.ts).then((winner) => {
5131
+ }, event.ts, refusedStop ? 'stop' : 'reply').then((winner) => {
4520
5132
  if (winner === connection.config.agentId) {
4521
5133
  log(refusal);
4522
5134
  }
@@ -4592,16 +5204,37 @@ export const createSlackChannelAdapter = (options) => {
4592
5204
  // "whose is an untagged reply", and for these two agents the soul has
4593
5205
  // already answered.
4594
5206
  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 };
5207
+ if (listens === 'mentions' || listens === 'judge') {
5208
+ // Except a stop (card 337): the holder rule decides whose it is, as
5209
+ // for any agent, and it only acts if there is something to stop. A
5210
+ // thread the record has forgotten is left to the sessions, as below,
5211
+ // so a stop for a turn that outlived its record still reaches it.
5212
+ const approverStop = threadKey !== undefined &&
5213
+ (connection.config.approvers ?? []).includes(sender) &&
5214
+ isStopRequest(text, connection.botUserId);
5215
+ const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
5216
+ // A stop held by another agent is only heard: judging it would let an
5217
+ // attentive agent take a turn on a stop that was never its own.
5218
+ const stopForAnother = stopHolder !== undefined && stopHolder !== connection.config.agentId;
5219
+ return {
5220
+ ...admission,
5221
+ overhear: true,
5222
+ ...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
5223
+ ...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
5224
+ ...(approverStop && stopHolder === undefined ? { settled: false } : {}),
5225
+ };
4600
5226
  }
4601
5227
  // The record is what this process has actually seen, in the order it
4602
5228
  // saw it — right even inside the window before a handover reaches the
4603
- // store.
4604
- const holder = threadKey === undefined ? undefined : holderAt(threadKey, event.ts);
5229
+ // store. Whose an untagged reply is, is the thread's owner's (card
5230
+ // 508), not the last speaker's — except a stop, which is for whoever
5231
+ // was asked last, since that is the turn it means (card 337).
5232
+ const stopRequest = threadKey !== undefined &&
5233
+ (connection.config.approvers ?? []).includes(sender) &&
5234
+ isStopRequest(text, connection.botUserId);
5235
+ const holder = threadKey === undefined
5236
+ ? undefined
5237
+ : (stopRequest ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
4605
5238
  if (holder !== undefined && threadKey !== undefined) {
4606
5239
  if (holder !== connection.config.agentId) {
4607
5240
  // Standing down for the holder, whether or not the holder can still
@@ -4652,6 +5285,19 @@ export const createSlackChannelAdapter = (options) => {
4652
5285
  return;
4653
5286
  }
4654
5287
  const { event, isDm, team, userId, thread, sessionId, threadKey, eventKey } = admitted;
5288
+ // Stop (card 337), see `stopTurns`. Only a message addressed to this
5289
+ // agent: an untagged reply this agent merely overhears is the holder's
5290
+ // (unless this agent is the holder and only `listens` made it an
5291
+ // overhear, see `holderStop`), and one only the sessions can place is
5292
+ // not this agent's to act on yet.
5293
+ if ((!admitted.overhear || admitted.holderStop === true) &&
5294
+ admitted.settled &&
5295
+ admitted.fromAgent === undefined &&
5296
+ (connection.config.approvers ?? []).includes(userId) &&
5297
+ isStopRequest(event.text ?? '', connection.botUserId) &&
5298
+ stopTurns(connection, sessionId, userId, event.channel)) {
5299
+ return;
5300
+ }
4655
5301
  // What the gateway knows this message by (see `dispatchKeyFor`).
4656
5302
  const dispatchKey = dispatchKeyFor(eventKey);
4657
5303
  // Everything up to (and including) the dispatch call is serialized per
@@ -4661,6 +5307,30 @@ export const createSlackChannelAdapter = (options) => {
4661
5307
  // reverse order in durable history. The chain is claimed here,
4662
5308
  // synchronously, so a message's place in it is fixed by when Slack
4663
5309
  // delivered it and not by how long anything it does takes.
5310
+ const stopsAtClaim = stopsBySession.get(sessionId)?.count ?? 0;
5311
+ // Only intake that can still become a turn is something a stop can
5312
+ // cancel. A settled overhear that is not judged only ever reaches
5313
+ // `gateway.observe`, so counting it would let a stop with nothing to
5314
+ // stop be consumed instead of passing on as an ordinary message.
5315
+ const mayRun = !admitted.overhear || admitted.judge || !admitted.settled;
5316
+ if (mayRun) {
5317
+ intakeInFlight.set(sessionId, (intakeInFlight.get(sessionId) ?? 0) + 1);
5318
+ }
5319
+ let leftIntake = !mayRun;
5320
+ const leaveIntake = () => {
5321
+ if (leftIntake) {
5322
+ return;
5323
+ }
5324
+ leftIntake = true;
5325
+ const left = (intakeInFlight.get(sessionId) ?? 1) - 1;
5326
+ if (left > 0) {
5327
+ intakeInFlight.set(sessionId, left);
5328
+ }
5329
+ else {
5330
+ intakeInFlight.delete(sessionId);
5331
+ stopsBySession.delete(sessionId);
5332
+ }
5333
+ };
4664
5334
  const intake = onIntakeChain(sessionId, async () => {
4665
5335
  // The durable half of addressing runs INSIDE the chain, for the same
4666
5336
  // reason: it awaits, and a message that had to ask the sessions must
@@ -4695,21 +5365,36 @@ export const createSlackChannelAdapter = (options) => {
4695
5365
  if (!admitted.settled) {
4696
5366
  // One verdict for this message, shared with whichever other agents
4697
5367
  // are asking about it — see `followUpWinner`.
5368
+ const stop = (connection.config.approvers ?? []).includes(userId) &&
5369
+ isStopRequest(event.text ?? '', connection.botUserId);
4698
5370
  const winner = await followUpWinner({
4699
5371
  team,
4700
5372
  conversation: event.channel,
4701
5373
  thread: event.thread_ts ?? '',
4702
5374
  ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
4703
- }, event.ts);
5375
+ }, event.ts, stop ? 'stop' : 'reply');
4704
5376
  if (winner !== connection.config.agentId) {
4705
5377
  // 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.
5378
+ // this agent's to hear, same as the two warm stand-downs. Only a
5379
+ // stop sends a judging agent here, and a stop the sessions place
5380
+ // with another agent is heard, never judged, as it is warm.
5381
+ judged = false;
4707
5382
  overhear = true;
4708
5383
  }
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);
5384
+ else {
5385
+ if (threadKey) {
5386
+ // Answered from the store rather than from memory: hold it, so
5387
+ // the rest of the thread is ordered by receipt like any other.
5388
+ rememberAddressee(threadKey, connection.config.agentId, event.ts);
5389
+ }
5390
+ // Stop (card 337) for a message only the sessions could place:
5391
+ // now that they say it is this agent's, it is a stop like the
5392
+ // warm one above, not a message to queue behind the turn it stops.
5393
+ if ((connection.config.approvers ?? []).includes(userId) &&
5394
+ isStopRequest(event.text ?? '', connection.botUserId) &&
5395
+ stopTurns(connection, sessionId, userId, event.channel, true)) {
5396
+ return undefined;
5397
+ }
4713
5398
  }
4714
5399
  }
4715
5400
  const cleaned = await humanizeMentions(connection, event.text ?? '');
@@ -4737,7 +5422,7 @@ export const createSlackChannelAdapter = (options) => {
4737
5422
  const { texts, saved, unread } = overhear || judged
4738
5423
  ? { texts: [], saved: [], unread: notImages }
4739
5424
  : 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)
5425
+ ? createAttachmentSaver(options.attachmentStore, connection.config.agentId, admitted.fromAgent !== undefined ? 'unknown' : senderTrustFor(connection.config, userId), options.now ?? Date.now)
4741
5426
  : undefined);
4742
5427
  // An image or a document with nothing said is still a question
4743
5428
  // ("what's this?"); a file the turn could not open with nothing said
@@ -4745,7 +5430,14 @@ export const createSlackChannelAdapter = (options) => {
4745
5430
  if (cleaned.length === 0 && images.length === 0 && texts.length === 0 && saved.length === 0) {
4746
5431
  return undefined;
4747
5432
  }
4748
- const author = await authorFor(connection, userId);
5433
+ // A team agent goes by the name the operator gave it, not a profile
5434
+ // its own app chose.
5435
+ // A team agent's daemon hears only what tags its bot (card 513), so
5436
+ // the model gets that tag and decides whether its answer needs it
5437
+ // (Dylan, 2026-10-15: the model tags back, the adapter never does).
5438
+ const author = admitted.fromAgent !== undefined
5439
+ ? `${admitted.fromAgent} (an agent; it sees your answer only if you tag <@${userId}>)`
5440
+ : await authorFor(connection, userId);
4749
5441
  // In shared channels the model should know who is speaking; a DM is
4750
5442
  // unambiguous. A bare image in a channel still says who sent it.
4751
5443
  const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
@@ -4756,7 +5448,10 @@ export const createSlackChannelAdapter = (options) => {
4756
5448
  // `unknown` exactly as a channel mention would be. The same label
4757
5449
  // whether the agent answers or only hears: the text is in its
4758
5450
  // transcript either way.
4759
- const senderTrust = senderTrustFor(connection.config, userId);
5451
+ // A team agent is below a principal whatever the list says: its
5452
+ // words carry whatever its own session was exposed to, which nothing
5453
+ // here can see (card 513).
5454
+ const senderTrust = admitted.fromAgent !== undefined ? 'unknown' : senderTrustFor(connection.config, userId);
4760
5455
  // Who can read this, so the agent writes for them, and a thread says
4761
5456
  // it is one. A DM names the other person only when they are one of
4762
5457
  // the operator's principals: this lands in the system prompt, and
@@ -4889,6 +5584,14 @@ export const createSlackChannelAdapter = (options) => {
4889
5584
  const queue = renderers.get(sessionId) ?? [];
4890
5585
  queue.push(renderer);
4891
5586
  renderers.set(sessionId, queue);
5587
+ // Stopped while its lookups ran: dispatched under an aborted signal,
5588
+ // so it ends the way a queued turn a stop reached does.
5589
+ const stopped = stopsBySession.get(sessionId);
5590
+ leaveIntake();
5591
+ if (stopped !== undefined && stopped.count !== stopsAtClaim && renderer.stoppedBy === undefined) {
5592
+ renderer.stoppedBy = stopped.by;
5593
+ renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${stopped.by}`));
5594
+ }
4892
5595
  // In a DM each message is its own status key, and Slack stops showing
4893
5596
  // the running turn's once a newer message sits below it — so a
4894
5597
  // message queued there that stayed dark read as the agent having
@@ -4906,6 +5609,7 @@ export const createSlackChannelAdapter = (options) => {
4906
5609
  ...(earlier.length > 0 ? { earlier } : {}),
4907
5610
  turnId: renderer.turnId,
4908
5611
  metadata,
5612
+ signal: renderer.controller.signal,
4909
5613
  // A redelivery this process no longer remembers (it restarted, or
4910
5614
  // the dedupe evicted it) is still the message it was: the gateway
4911
5615
  // runs no second turn for it, and says so through `onRepeat`.
@@ -4922,6 +5626,8 @@ export const createSlackChannelAdapter = (options) => {
4922
5626
  });
4923
5627
  return { renderer, turn, repeat: () => repeat, absorbed: () => absorbed };
4924
5628
  });
5629
+ // Whatever way the intake ended, it is no longer in flight.
5630
+ void intake.then(leaveIntake, leaveIntake);
4925
5631
  const started = await intake;
4926
5632
  if (!started) {
4927
5633
  return;
@@ -5058,7 +5764,12 @@ export const createSlackChannelAdapter = (options) => {
5058
5764
  // the reader's next untagged message is for the lower of the two.
5059
5765
  // Late, either way, for a message typed while the turn was still
5060
5766
  // running, which the other may therefore still take.
5061
- rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
5767
+ // Under the owner rule (card 508) only two replies move the thread:
5768
+ // a judging agent that chose to speak, and the owner answering in
5769
+ // its own thread, which takes it back from a judge that spoke above.
5770
+ // An answer to a mention is that message's answer and no more.
5771
+ const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
5772
+ rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
5062
5773
  }
5063
5774
  // The reply just posted took the thread's status down with it, and a
5064
5775
  // turn queued behind this one is still working.
@@ -5071,12 +5782,16 @@ export const createSlackChannelAdapter = (options) => {
5071
5782
  // not a failure of the agent. Matched by name, as this package does
5072
5783
  // not import the gateway. A turn nobody asked for stays silent.
5073
5784
  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));
5785
+ const { spoke, spokeAt } = renderer.stoppedBy !== undefined
5786
+ ? await renderer.stopped(renderer.stoppedBy)
5787
+ : pausedReply
5788
+ ? await renderer.finalize(renderer.lazy ? '' : failure.message)
5789
+ : await renderer.fail(failure instanceof Error ? failure.message : String(failure));
5077
5790
  if (spoke && threadKey !== undefined) {
5078
- // A file it posted before breaking is still the last thing said.
5079
- rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
5791
+ // A file it posted before breaking has still spoken, by the same
5792
+ // rule as a reply that landed.
5793
+ const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
5794
+ rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
5080
5795
  }
5081
5796
  renderers.get(sessionId)?.[0]?.refreshLoading();
5082
5797
  }
@@ -5085,6 +5800,9 @@ export const createSlackChannelAdapter = (options) => {
5085
5800
  name: 'slack',
5086
5801
  resolveOutbound,
5087
5802
  readConversation,
5803
+ // A channel id is unique only within its workspace, so a standing
5804
+ // grant on one is bound to the team the agent's app authenticated in.
5805
+ accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
5088
5806
  requestCredential,
5089
5807
  async start(gateway) {
5090
5808
  gatewayRef = gateway;