@stratusagent/channel-slack 0.12.4 → 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
@@ -2283,6 +2283,16 @@ export const createSlackChannelAdapter = (options) => {
2283
2283
  * corrects it.
2284
2284
  */
2285
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();
2286
2296
  /**
2287
2297
  * One cold verdict per message — see `followUpWinner`. Bounded, because
2288
2298
  * an entry outlives its own resolution on purpose; an entry that has not
@@ -2996,6 +3006,73 @@ export const createSlackChannelAdapter = (options) => {
2996
3006
  }
2997
3007
  }));
2998
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
+ };
2999
3076
  /**
3000
3077
  * The addressable outbound seam — the channel contract's first real
3001
3078
  * `OutboundConnection` implementation. Validate-then-hand-over: every
@@ -3041,16 +3118,31 @@ export const createSlackChannelAdapter = (options) => {
3041
3118
  // A reply under a message rather than a post beside it. Every chunk of
3042
3119
  // a long one goes to the same thread, so a split reply stays together.
3043
3120
  const thread = address.thread?.trim() || undefined;
3121
+ const speaker = address.speaker;
3044
3122
  return {
3045
3123
  async post(text) {
3046
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
+ }
3047
3132
  let first;
3048
- for (const chunk of chunks) {
3049
- const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
3050
- if (!first) {
3051
- 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
+ }
3052
3141
  }
3053
3142
  }
3143
+ finally {
3144
+ accepted(sent === chunks.length ? text : sent > 0 ? chunks.slice(0, sent).join('\n') : undefined);
3145
+ }
3054
3146
  return first ?? { channel: channelId, ts: '' };
3055
3147
  },
3056
3148
  async edit(ref, text) {
@@ -3977,6 +4069,34 @@ export const createSlackChannelAdapter = (options) => {
3977
4069
  };
3978
4070
  /** One thread, whichever agent is talking in it. */
3979
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
+ };
3980
4100
  /**
3981
4101
  * Records that `agentId` holds the thread from the message at `ts`
3982
4102
  * onwards, wherever in the thread's history that message falls.
@@ -3989,7 +4109,8 @@ export const createSlackChannelAdapter = (options) => {
3989
4109
  * later insertion would take the thread from a reply that sits below it.
3990
4110
  * Only the same claim twice — the same message reaching a second
3991
4111
  * connection — is dropped.
3992
- * 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
3993
4114
  * least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
3994
4115
  * losses cost a lookup, never an answer, since the sessions still hold
3995
4116
  * the durable version.
@@ -4003,7 +4124,7 @@ export const createSlackChannelAdapter = (options) => {
4003
4124
  * Slack timestamps compare as strings: `seconds.microseconds`, both
4004
4125
  * parts fixed width, so lexicographic order is chronological order.
4005
4126
  */
4006
- const rememberAddressee = (key, agentId, ts) => {
4127
+ const rememberAddressee = (key, agentId, ts, takes = false) => {
4007
4128
  const handovers = threadAddressee.get(key) ?? [];
4008
4129
  let at = handovers.length;
4009
4130
  while (at > 0 && handovers[at - 1].ts > ts) {
@@ -4011,17 +4132,24 @@ export const createSlackChannelAdapter = (options) => {
4011
4132
  }
4012
4133
  const before = handovers[at - 1];
4013
4134
  if (before && before.agentId === agentId && before.ts === ts) {
4135
+ if (takes) {
4136
+ before.takes = true;
4137
+ }
4014
4138
  return;
4015
4139
  }
4016
- handovers.splice(at, 0, { agentId, ts });
4140
+ handovers.splice(at, 0, takes ? { agentId, ts, takes } : { agentId, ts });
4017
4141
  // Re-inserted rather than mutated in place: a Map iterates in insertion
4018
4142
  // order, which is what makes the first key the one to evict.
4019
4143
  threadAddressee.delete(key);
4020
- threadAddressee.set(key, handovers.slice(-THREAD_HANDOVER_DEPTH));
4144
+ threadAddressee.set(key, trimHandovers(handovers));
4021
4145
  if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
4022
4146
  const oldest = threadAddressee.keys().next();
4023
4147
  if (!oldest.done) {
4024
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);
4025
4153
  }
4026
4154
  }
4027
4155
  };
@@ -4043,6 +4171,95 @@ export const createSlackChannelAdapter = (options) => {
4043
4171
  }
4044
4172
  return undefined;
4045
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
+ };
4046
4263
  /**
4047
4264
  * Whether `user` is one of this adapter's own agents in `team`. Their
4048
4265
  * replies reach the holder record through `ReplyOutcome.spokeAt`, and
@@ -4056,6 +4273,24 @@ export const createSlackChannelAdapter = (options) => {
4056
4273
  * held it. Used live and when a thread is read back after a restart.
4057
4274
  */
4058
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
+ };
4059
4294
  /**
4060
4295
  * Records a bot this adapter does not serve as the thread's holder from
4061
4296
  * the message it posted. An agent on another gateway answers in the same
@@ -4087,6 +4322,9 @@ export const createSlackChannelAdapter = (options) => {
4087
4322
  if (asksAgentHere(team, event.user, event.text)) {
4088
4323
  return;
4089
4324
  }
4325
+ if (startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
4326
+ return;
4327
+ }
4090
4328
  const thread = event.thread_ts ?? event.ts;
4091
4329
  rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
4092
4330
  };
@@ -4119,16 +4357,30 @@ export const createSlackChannelAdapter = (options) => {
4119
4357
  const botUserId = botIdentities.get(candidate.agentId)?.botUserId;
4120
4358
  if (botUserId !== undefined) {
4121
4359
  byBotUser.set(botUserId, candidate.agentId);
4122
- held.set(candidate.agentId, 0);
4360
+ held.set(candidate.agentId, Number.POSITIVE_INFINITY);
4123
4361
  continue;
4124
4362
  }
4125
4363
  const spoke = candidate.lastSpokeAt === undefined ? Number.NaN : Date.parse(candidate.lastSpokeAt) / 1000;
4126
- 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);
4127
4371
  }
4128
4372
  const place = (agentId, at) => {
4129
- 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));
4130
4375
  };
4131
- 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));
4132
4384
  try {
4133
4385
  let cursor;
4134
4386
  let pages = 0;
@@ -4150,14 +4402,35 @@ export const createSlackChannelAdapter = (options) => {
4150
4402
  const own = message.user === undefined ? undefined : byBotUser.get(message.user);
4151
4403
  if (own !== undefined) {
4152
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
+ }
4153
4416
  continue;
4154
4417
  }
4155
4418
  const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
4156
4419
  if (bot) {
4157
4420
  // A bot's mention hands nothing over live (admit ignores bots),
4158
4421
  // so here it is only another app speaking, or a peer's words.
4159
- if (!isFleetBot(parts.team, message.user) && !asksAgentHere(parts.team, message.user, message.text)) {
4160
- 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);
4161
4434
  }
4162
4435
  continue;
4163
4436
  }
@@ -4167,10 +4440,19 @@ export const createSlackChannelAdapter = (options) => {
4167
4440
  if (sender === undefined) {
4168
4441
  continue;
4169
4442
  }
4443
+ asked = askedIn(message.text);
4170
4444
  const named = agentNamedIn(message.text ?? '', parts.team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
4171
4445
  if (named !== undefined && held.has(named) && botIdentities.get(named)?.botUserId !== undefined) {
4172
4446
  place(named, at);
4173
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
+ }
4174
4456
  }
4175
4457
  cursor = page.response_metadata?.next_cursor || undefined;
4176
4458
  } while (cursor && pages < EARLIER_THREAD_PAGES);
@@ -4182,12 +4464,7 @@ export const createSlackChannelAdapter = (options) => {
4182
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)}`);
4183
4465
  return undefined;
4184
4466
  }
4185
- return { held, foreign };
4186
- };
4187
- /** Whether another app spoke in the thread after `agentId` last held it. */
4188
- const foreignSpokeSince = async (agentId, parts, ts, lastSpokeAt) => {
4189
- const read = await threadPositions([{ agentId, ...(lastSpokeAt !== undefined ? { lastSpokeAt } : {}) }], parts, ts);
4190
- return read !== undefined && read.foreign > (read.held.get(agentId) ?? 0);
4467
+ return { held, last, foreign, lastForeign, posts };
4191
4468
  };
4192
4469
  /**
4193
4470
  * Which of this process's agents a message names, or undefined if it
@@ -4253,25 +4530,19 @@ export const createSlackChannelAdapter = (options) => {
4253
4530
  * restart forgets nothing.
4254
4531
  *
4255
4532
  * Whose turn it is, is the second. In a thread with several agents an
4256
- * unaddressed reply belongs to **whoever spoke last**, the way it does
4257
- * between people: answer the voice that just answered you, and tag
4258
- * someone by name to change that. `lastSpokeAt` orders them — when each
4259
- * agent last *replied*, not when its session last changed, because a turn
4260
- * saves on tool results and approval checkpoints without having said
4261
- * anything, and a recovery resuming after a restart lands in exactly this
4262
- * 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.
4263
4539
  *
4264
4540
  * Nobody is the answer only when the agents cannot be *ordered* — nothing
4265
- * in the thread has spoken (a host whose routing carries no `lastSpokeAt`
4266
- * at all reads this way, and so does a thread where every agent is still
4267
- * on its first turn), or the newest two tie — because guessing there
4268
- * would put two answers under one message. An agent that simply has not
4269
- * spoken is a different thing and is skipped rather than fatal: it cannot
4270
- * have spoken last, and letting it silence the agent that did would leave
4271
- * a reply nobody answers. The agent alone in its thread, which is nearly
4272
- * 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.
4273
4544
  */
4274
- const resolveFollowUpWinner = async (parts, ts) => {
4545
+ const resolveFollowUpWinner = async (parts, ts, mode = 'reply') => {
4275
4546
  const gateway = gatewayRef;
4276
4547
  if (!gateway?.sessionRouting || !parts.thread) {
4277
4548
  return undefined;
@@ -4349,22 +4620,107 @@ export const createSlackChannelAdapter = (options) => {
4349
4620
  }
4350
4621
  for (const [agentId, identity] of botIdentities) {
4351
4622
  if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
4352
- return await foreignSpokeSince(agentId, parts, ts, undefined) ? undefined : agentId;
4623
+ noteOrigin(threadKeyFor(parts), agentId);
4624
+ return agentId;
4353
4625
  }
4354
4626
  }
4355
4627
  return undefined;
4356
4628
  }
4357
- if (engaged.length === 1) {
4358
- 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;
4359
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;
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) => {
4360
4720
  let latest;
4361
4721
  let tied = false;
4362
4722
  for (const entry of engaged) {
4363
4723
  if (entry.lastSpokeAt === undefined) {
4364
- // In the thread, but has never said anything in it — mentioned and
4365
- // still on its first turn, or a turn that produced no text. It
4366
- // cannot be the one who spoke last, and it must not stop the agent
4367
- // that did from answering.
4368
4724
  continue;
4369
4725
  }
4370
4726
  if (!latest || entry.lastSpokeAt > latest.lastSpokeAt) {
@@ -4375,34 +4731,7 @@ export const createSlackChannelAdapter = (options) => {
4375
4731
  tied = true;
4376
4732
  }
4377
4733
  }
4378
- const bySession = tied ? undefined : latest?.agentId;
4379
- // Sessions date a reply by when it finished, the thread by where its
4380
- // placeholder went up, and an agent on another gateway may have spoken
4381
- // since: only the thread can say who holds it. When it can't be read,
4382
- // the sessions decide, as before.
4383
- const read = await threadPositions(engaged, parts, ts);
4384
- if (!read) {
4385
- return bySession;
4386
- }
4387
- let holder;
4388
- let tiedAt = false;
4389
- for (const entry of engaged) {
4390
- const at = read.held.get(entry.agentId) ?? 0;
4391
- if (at <= 0) {
4392
- continue;
4393
- }
4394
- if (!holder || at > holder.at) {
4395
- holder = { agentId: entry.agentId, at };
4396
- tiedAt = false;
4397
- }
4398
- else if (at === holder.at) {
4399
- tiedAt = true;
4400
- }
4401
- }
4402
- if (!holder) {
4403
- return read.foreign > 0 ? undefined : bySession;
4404
- }
4405
- return tiedAt || read.foreign > holder.at ? undefined : holder.agentId;
4734
+ return tied ? undefined : latest?.agentId;
4406
4735
  };
4407
4736
  /**
4408
4737
  * `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
@@ -4422,13 +4751,15 @@ export const createSlackChannelAdapter = (options) => {
4422
4751
  * this after the winner has finished, and recomputing for that agent is
4423
4752
  * precisely the disagreement the memo exists to prevent.
4424
4753
  */
4425
- const followUpWinner = (parts, ts) => {
4426
- 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' : ''}`;
4427
4758
  const existing = coldVerdicts.get(key);
4428
4759
  if (existing) {
4429
4760
  return existing.verdict;
4430
4761
  }
4431
- const entry = { verdict: resolveFollowUpWinner(parts, ts), settled: false };
4762
+ const entry = { verdict: resolveFollowUpWinner(parts, ts, mode), settled: false };
4432
4763
  coldVerdicts.set(key, entry);
4433
4764
  void entry.verdict.then(() => {
4434
4765
  entry.settled = true;
@@ -4625,6 +4956,7 @@ export const createSlackChannelAdapter = (options) => {
4625
4956
  const admit = (connection, args) => {
4626
4957
  const event = args.event;
4627
4958
  if (gatewayRef) {
4959
+ noteThreadStart(connection, args);
4628
4960
  noteForeignSpeaker(connection, args);
4629
4961
  }
4630
4962
  if (gatewayRef && event && (event.bot_id !== undefined || event.subtype === 'bot_message')) {
@@ -4772,7 +5104,12 @@ export const createSlackChannelAdapter = (options) => {
4772
5104
  return undefined;
4773
5105
  }
4774
5106
  const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
4775
- 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);
4776
5113
  if (addressed || held === connection.config.agentId) {
4777
5114
  if (!alreadySeen(eventKey)) {
4778
5115
  log(refusal);
@@ -4791,7 +5128,7 @@ export const createSlackChannelAdapter = (options) => {
4791
5128
  conversation: event.channel,
4792
5129
  thread,
4793
5130
  ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
4794
- }, event.ts).then((winner) => {
5131
+ }, event.ts, refusedStop ? 'stop' : 'reply').then((winner) => {
4795
5132
  if (winner === connection.config.agentId) {
4796
5133
  log(refusal);
4797
5134
  }
@@ -4889,8 +5226,15 @@ export const createSlackChannelAdapter = (options) => {
4889
5226
  }
4890
5227
  // The record is what this process has actually seen, in the order it
4891
5228
  // saw it — right even inside the window before a handover reaches the
4892
- // store.
4893
- 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);
4894
5238
  if (holder !== undefined && threadKey !== undefined) {
4895
5239
  if (holder !== connection.config.agentId) {
4896
5240
  // Standing down for the holder, whether or not the holder can still
@@ -5021,12 +5365,14 @@ export const createSlackChannelAdapter = (options) => {
5021
5365
  if (!admitted.settled) {
5022
5366
  // One verdict for this message, shared with whichever other agents
5023
5367
  // are asking about it — see `followUpWinner`.
5368
+ const stop = (connection.config.approvers ?? []).includes(userId) &&
5369
+ isStopRequest(event.text ?? '', connection.botUserId);
5024
5370
  const winner = await followUpWinner({
5025
5371
  team,
5026
5372
  conversation: event.channel,
5027
5373
  thread: event.thread_ts ?? '',
5028
5374
  ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
5029
- }, event.ts);
5375
+ }, event.ts, stop ? 'stop' : 'reply');
5030
5376
  if (winner !== connection.config.agentId) {
5031
5377
  // Not this agent's to answer — and, if it is in the thread,
5032
5378
  // this agent's to hear, same as the two warm stand-downs. Only a
@@ -5418,7 +5764,12 @@ export const createSlackChannelAdapter = (options) => {
5418
5764
  // the reader's next untagged message is for the lower of the two.
5419
5765
  // Late, either way, for a message typed while the turn was still
5420
5766
  // running, which the other may therefore still take.
5421
- 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);
5422
5773
  }
5423
5774
  // The reply just posted took the thread's status down with it, and a
5424
5775
  // turn queued behind this one is still working.
@@ -5437,8 +5788,10 @@ export const createSlackChannelAdapter = (options) => {
5437
5788
  ? await renderer.finalize(renderer.lazy ? '' : failure.message)
5438
5789
  : await renderer.fail(failure instanceof Error ? failure.message : String(failure));
5439
5790
  if (spoke && threadKey !== undefined) {
5440
- // A file it posted before breaking is still the last thing said.
5441
- 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);
5442
5795
  }
5443
5796
  renderers.get(sessionId)?.[0]?.refreshLoading();
5444
5797
  }