@stratusagent/channel-slack 0.12.4 → 0.12.6

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
@@ -27,6 +27,17 @@ const THREAD_ADDRESSEE_CAPACITY = 2000;
27
27
  * same message, and the addressee record answers for every message after.
28
28
  */
29
29
  const COLD_VERDICT_CAPACITY = 256;
30
+ /**
31
+ * How many of one person's mentions one thread remembers (card 541). Only
32
+ * has to span the lag between two sockets, like `THREAD_HANDOVER_DEPTH`.
33
+ */
34
+ const SENDER_ADDRESSEE_DEPTH = 8;
35
+ /**
36
+ * How many people one thread remembers an addressee for. Past this the
37
+ * least recently heard is forgotten, and their next untagged reply goes to
38
+ * the thread's owner, as after a restart.
39
+ */
40
+ const THREAD_SENDER_CAPACITY = 64;
30
41
  /**
31
42
  * How many claims on one thread it remembers.
32
43
  *
@@ -69,17 +80,6 @@ const toolStatus = (toolName) => `is running ${toolName}…`;
69
80
  * approval wait — is exactly the one whose status must not vanish.
70
81
  */
71
82
  const STATUS_REFRESH_MS = 60_000;
72
- /**
73
- * How long an agent that judges stays attentive after it last answered a
74
- * message that addressed it: this many messages, or this many minutes,
75
- * whichever ends first. Judging costs
76
- * a model call per message, and two people talking for an hour is a
77
- * hundred of them for one "no" — so attention decays the way a person's
78
- * does. Outside the window a message is overheard for free, and a mention
79
- * re-arms it, which is what a mention has always meant.
80
- */
81
- const ATTENTION_MESSAGES = 8;
82
- const ATTENTION_MS = 15 * 60 * 1000;
83
83
  /**
84
84
  * A 5 MB image over a slow link is seconds, not minutes. Past this the
85
85
  * downloads are abandoned and the turn runs with the attachments named as
@@ -2283,6 +2283,77 @@ 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();
2296
+ /**
2297
+ * Per thread, the agent each person last addressed by name, and when
2298
+ * (card 541). An untagged reply from that person is that agent's —
2299
+ * `@Bea your turn`, then `and?`, is Bea's — ahead of the thread's owner,
2300
+ * who keeps every reply from someone who has not addressed anyone else.
2301
+ * Recorded by every socket from the message itself, before any refusal,
2302
+ * as a handover is, so the sockets agree. Bounded like the addressee map;
2303
+ * a lost entry, or a restart, falls back to the owner.
2304
+ */
2305
+ const senderAddressees = new Map();
2306
+ const rememberSenderAddressee = (key, sender, agentId, ts) => {
2307
+ const senders = senderAddressees.get(key) ?? new Map();
2308
+ // Kept in timestamp order, so a socket that is behind can still find the
2309
+ // mention that preceded the reply it is only now reaching. A late
2310
+ // delivery of an older mention slots in where it belongs.
2311
+ const history = (senders.get(sender) ?? []).filter((entry) => entry.ts !== ts);
2312
+ let at = history.length;
2313
+ while (at > 0 && history[at - 1].ts > ts) {
2314
+ at -= 1;
2315
+ }
2316
+ history.splice(at, 0, { agentId, ts });
2317
+ if (history.length > SENDER_ADDRESSEE_DEPTH) {
2318
+ history.splice(0, history.length - SENDER_ADDRESSEE_DEPTH);
2319
+ }
2320
+ // Most recently heard sender last, so the first key is the one to drop.
2321
+ senders.delete(sender);
2322
+ senders.set(sender, history);
2323
+ if (senders.size > THREAD_SENDER_CAPACITY) {
2324
+ const oldest = senders.keys().next();
2325
+ if (!oldest.done) {
2326
+ senders.delete(oldest.value);
2327
+ }
2328
+ }
2329
+ senderAddressees.delete(key);
2330
+ senderAddressees.set(key, senders);
2331
+ if (senderAddressees.size > THREAD_ADDRESSEE_CAPACITY) {
2332
+ const oldest = senderAddressees.keys().next();
2333
+ if (!oldest.done) {
2334
+ senderAddressees.delete(oldest.value);
2335
+ }
2336
+ }
2337
+ };
2338
+ /**
2339
+ * The agent `sender` last addressed in thread `key` before `ts`, if this
2340
+ * process saw it. A reply older than every mention still remembered for
2341
+ * that sender has nothing to go on, and falls back to the owner.
2342
+ */
2343
+ const senderAddresseeAt = (key, sender, ts) => {
2344
+ const history = senderAddressees.get(key)?.get(sender);
2345
+ if (!history) {
2346
+ return undefined;
2347
+ }
2348
+ for (let i = history.length - 1; i >= 0; i -= 1) {
2349
+ if (history[i].ts < ts) {
2350
+ return history[i].agentId;
2351
+ }
2352
+ }
2353
+ return undefined;
2354
+ };
2355
+ /** Whose an untagged reply from `sender` at `ts` is: who they last addressed, else the owner. */
2356
+ const replyTargetAt = (key, sender, ts) => senderAddresseeAt(key, sender, ts) ?? ownerAt(key, ts);
2286
2357
  /**
2287
2358
  * One cold verdict per message — see `followUpWinner`. Bounded, because
2288
2359
  * an entry outlives its own resolution on purpose; an entry that has not
@@ -2803,69 +2874,6 @@ export const createSlackChannelAdapter = (options) => {
2803
2874
  * nothing, which is every soul written before there was a choice.
2804
2875
  */
2805
2876
  const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
2806
- /**
2807
- * Whether an agent that judges is still paying attention to a thread:
2808
- * within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
2809
- * answered a message that addressed it, both read from the session
2810
- * rather than remembered here, so a restart forgets nothing. Anchored
2811
- * to an addressed answer and not to any reply: a reply the agent chose
2812
- * to give on a turn nobody asked for does not re-arm it, or a talkative
2813
- * judge would keep itself attentive for good and cost a call per message
2814
- * — the self-extension the spec declined. An agent never addressed in
2815
- * the thread is not attentive: it was mentioned and its first turn has
2816
- * not answered yet, or answered with nothing. Measured against the
2817
- * message's own timestamp, not the clock, so a delivery that ran late is
2818
- * judged as of when it was said. Bounded below as well as above: a
2819
- * message said before the answer the window starts from is outside it
2820
- * — Slack redelivers events a restarted daemon has no memory of, and a
2821
- * negative age would otherwise pass the minutes bound however old the
2822
- * message, on a turn the agent's answer already came after.
2823
- */
2824
- const attentive = (routing, messageTs, pending) => {
2825
- if (routing.lastAnsweredAt === undefined) {
2826
- return false;
2827
- }
2828
- const answeredAt = Date.parse(routing.lastAnsweredAt);
2829
- const saidAt = Number(messageTs) * 1000;
2830
- if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
2831
- return false;
2832
- }
2833
- return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
2834
- };
2835
- /**
2836
- * Messages this adapter has dispatched to a session that the session
2837
- * does not hold yet. The store counts what it holds, and a burst of
2838
- * messages typed inside one second would each read the same count and
2839
- * each be judged — the bound the window exists to give, blown by exactly
2840
- * the thread that needs it. The renderer queue already knows: a renderer
2841
- * is queued at dispatch and its turn starts (`turnStarted`) only once
2842
- * the runner has saved the message — the same moment the store's count
2843
- * takes it over — so the two never count one message twice.
2844
- */
2845
- const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
2846
- + (pendingObserves.get(sessionId) ?? 0);
2847
- /**
2848
- * Overhears placed on the gateway's chain and not yet written, per
2849
- * session: the other kind of message the store does not hold yet. An
2850
- * observe queued behind a running turn is heard only when the turn ends,
2851
- * and a burst of messages to a colleague would otherwise count toward
2852
- * nothing until then. Counted from placement to settlement.
2853
- */
2854
- const pendingObserves = new Map();
2855
- const placeObserve = (sessionId, observed) => {
2856
- pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
2857
- const release = () => {
2858
- const left = (pendingObserves.get(sessionId) ?? 1) - 1;
2859
- if (left > 0) {
2860
- pendingObserves.set(sessionId, left);
2861
- }
2862
- else {
2863
- pendingObserves.delete(sessionId);
2864
- }
2865
- };
2866
- void observed.then(release, release);
2867
- return observed;
2868
- };
2869
2877
  /**
2870
2878
  * Run `work` after everything already queued for this session's intake,
2871
2879
  * and hold the next link until it settles. The link is claimed
@@ -2987,7 +2995,7 @@ export const createSlackChannelAdapter = (options) => {
2987
2995
  // whoever installed it.
2988
2996
  const author = await displayNameFor(hearer, speaker.botUserId);
2989
2997
  const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
2990
- return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
2998
+ return { observed };
2991
2999
  });
2992
3000
  await observed;
2993
3001
  }
@@ -2996,6 +3004,73 @@ export const createSlackChannelAdapter = (options) => {
2996
3004
  }
2997
3005
  }));
2998
3006
  };
3007
+ /**
3008
+ * An agent's own post into a thread, heard in its own session for that
3009
+ * thread (card 364). A scheduled report or a `message.send` reply is a
3010
+ * bot message, which `admit` drops on purpose, and backfill reads a
3011
+ * thread only when a session for it is first made, so without this an
3012
+ * agent already in the thread never knew it had posted there: a reply
3013
+ * under its report reached a session with the question and not the
3014
+ * report. Through `observe`, so it starts no turn and lands only in a
3015
+ * session that already exists; a thread the agent was never in is read
3016
+ * by backfill when somebody first replies there.
3017
+ *
3018
+ * Labelled as the agent's own post, at the label the SENDING session's
3019
+ * words carry (`speaker.trust`, `sessionWriteTrust` of that session):
3020
+ * a report written by a firing that read a web page carries what that
3021
+ * page said, so it is heard at `external`, and one written from `user`
3022
+ * and `agent` context is heard at `agent`. Never higher, so hearing its
3023
+ * own report cannot launder anything into the thread. A post nobody
3024
+ * vouches for (no speaker) is not heard at all, rather than guessed at.
3025
+ *
3026
+ * Not when the sender IS that thread's session: that turn already holds
3027
+ * the call and what it said. Claimed on the intake chain BEFORE the
3028
+ * first chunk goes to Slack, so a reply typed under the report, even
3029
+ * while a long one is still being sent, is heard after it. `posted`
3030
+ * settles with what Slack accepted: all of it, the chunks before one it
3031
+ * refused (they are in the thread, so they are heard), or nothing. Not
3032
+ * awaited by `post`, only by the shutdown drain, so a long turn running
3033
+ * in the thread never holds up the sender, and two threads posting into
3034
+ * each other cannot wait on each other.
3035
+ */
3036
+ const hearOwnPost = (connection, channel, thread, posted, speaker) => {
3037
+ const gateway = gatewayRef;
3038
+ if (!gateway?.observe) {
3039
+ return;
3040
+ }
3041
+ const agentId = connection.config.agentId;
3042
+ const team = connection.teamId;
3043
+ const sessionId = channelSessionKey({ channel: 'slack', agentId, team, conversation: channel, thread });
3044
+ if (sessionId === speaker.sessionId) {
3045
+ return;
3046
+ }
3047
+ const metadata = {
3048
+ channel: 'slack',
3049
+ team,
3050
+ slackChannel: channel,
3051
+ slackUser: connection.botUserId,
3052
+ slackThread: thread,
3053
+ [SENDER_TRUST_METADATA_KEY]: leastTrusted('agent', speaker.trust),
3054
+ };
3055
+ const heard = onIntakeChain(sessionId, async () => {
3056
+ const text = await posted;
3057
+ if (text === undefined) {
3058
+ return { observed: undefined };
3059
+ }
3060
+ const author = await displayNameFor(connection, connection.botUserId);
3061
+ const observed = gateway.observe?.({ sessionId, agentId, message: `${author} (your own post, sent from another session): ${text}`, metadata });
3062
+ return { observed };
3063
+ });
3064
+ // Tracked, so stop() drains it: a restart right after a report must not
3065
+ // close the stores while the thread's session is still to hear it.
3066
+ track(heard
3067
+ .then(async ({ observed }) => {
3068
+ await observed;
3069
+ })
3070
+ .catch((error) => {
3071
+ warn(`slack: ${agentId} could not hear its own post in ${channel} thread ${thread}: ${error instanceof Error ? error.message : String(error)}`);
3072
+ }));
3073
+ };
2999
3074
  /**
3000
3075
  * The addressable outbound seam — the channel contract's first real
3001
3076
  * `OutboundConnection` implementation. Validate-then-hand-over: every
@@ -3041,16 +3116,31 @@ export const createSlackChannelAdapter = (options) => {
3041
3116
  // A reply under a message rather than a post beside it. Every chunk of
3042
3117
  // a long one goes to the same thread, so a split reply stays together.
3043
3118
  const thread = address.thread?.trim() || undefined;
3119
+ const speaker = address.speaker;
3044
3120
  return {
3045
3121
  async post(text) {
3046
3122
  const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
3123
+ // Heard in the thread's session (card 364): its place is claimed
3124
+ // now, before Slack shows anything, and filled once we know what
3125
+ // Slack accepted.
3126
+ let accepted = () => { };
3127
+ if (thread && speaker && text.trim().length > 0) {
3128
+ hearOwnPost(connection, channelId, thread, new Promise((resolve) => { accepted = resolve; }), speaker);
3129
+ }
3047
3130
  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 ?? '' };
3131
+ let sent = 0;
3132
+ try {
3133
+ for (const chunk of chunks) {
3134
+ const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
3135
+ sent += 1;
3136
+ if (!first) {
3137
+ first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
3138
+ }
3052
3139
  }
3053
3140
  }
3141
+ finally {
3142
+ accepted(sent === chunks.length ? text : sent > 0 ? chunks.slice(0, sent).join('\n') : undefined);
3143
+ }
3054
3144
  return first ?? { channel: channelId, ts: '' };
3055
3145
  },
3056
3146
  async edit(ref, text) {
@@ -3069,6 +3159,55 @@ export const createSlackChannelAdapter = (options) => {
3069
3159
  },
3070
3160
  };
3071
3161
  };
3162
+ /**
3163
+ * Channel or DM, as this agent's app sees a conversation, for the
3164
+ * operator's standing channel authorization (`approvals.channels`), which
3165
+ * covers channels only. Asked of Slack (`conversations.info`), never read
3166
+ * off the id: a `G…` id is a private channel or a group DM depending on
3167
+ * its age. A definite answer is kept, since a conversation never turns
3168
+ * from one into the other; a failed lookup is not, and reads as unknown,
3169
+ * so the call asks.
3170
+ */
3171
+ const conversationKinds = new Map();
3172
+ const CONVERSATION_KIND_CACHE_LIMIT = 1000;
3173
+ const conversationKindFor = async (agentId, conversation) => {
3174
+ const id = conversation.trim();
3175
+ const connection = connectionFor(agentId);
3176
+ if (!id || !connection) {
3177
+ return undefined;
3178
+ }
3179
+ const key = `${agentId}:${botIdentities.get(agentId)?.teamId ?? ''}:${id}`;
3180
+ const known = conversationKinds.get(key);
3181
+ if (known !== undefined) {
3182
+ return known;
3183
+ }
3184
+ let info;
3185
+ try {
3186
+ info = (await connection.web.conversations.info({ channel: id })).channel;
3187
+ }
3188
+ catch {
3189
+ return undefined;
3190
+ }
3191
+ if (!info?.id) {
3192
+ return undefined;
3193
+ }
3194
+ // A DM flag wins. Otherwise a channel only when Slack says so, by a
3195
+ // channel flag or by stating it is not a DM: a reply with none of
3196
+ // them is no proof of a channel.
3197
+ const kind = info.is_im === true || info.is_mpim === true
3198
+ ? 'direct'
3199
+ : info.is_channel === true || info.is_group === true || (info.is_im === false && info.is_mpim === false) ? 'channel' : undefined;
3200
+ if (kind !== undefined) {
3201
+ if (conversationKinds.size >= CONVERSATION_KIND_CACHE_LIMIT) {
3202
+ const oldest = conversationKinds.keys().next().value;
3203
+ if (oldest !== undefined) {
3204
+ conversationKinds.delete(oldest);
3205
+ }
3206
+ }
3207
+ conversationKinds.set(key, kind);
3208
+ }
3209
+ return kind;
3210
+ };
3072
3211
  /**
3073
3212
  * The read side of the same boundary `resolveOutbound` draws: a channel
3074
3213
  * the agent's own app is a member of, public or private. DMs and group
@@ -3242,8 +3381,21 @@ export const createSlackChannelAdapter = (options) => {
3242
3381
  const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
3243
3382
  ? metadata.slackChannel
3244
3383
  : undefined;
3245
- const channel = sameChannel ?? connection.config.approvalChannel;
3246
- const thread = sameChannel && typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
3384
+ // A scheduled firing has no conversation of its own. With no approval
3385
+ // channel configured it used to be denied at its first gated call, seen
3386
+ // only in the daemon log (card 218). Its schedule's destination is a
3387
+ // conversation a person approved as where it speaks, and only approvers
3388
+ // can answer there, so that is where it is asked. A configured approval
3389
+ // channel still wins: the operator chose it for exactly this. Another
3390
+ // channel's destination is not a Slack conversation id.
3391
+ const scheduled = event.scheduleDestination?.channel.trim().toLowerCase() === 'slack'
3392
+ ? event.scheduleDestination
3393
+ : undefined;
3394
+ const viaSchedule = !sameChannel && !connection.config.approvalChannel ? scheduled : undefined;
3395
+ const channel = sameChannel ?? connection.config.approvalChannel ?? viaSchedule?.to;
3396
+ const thread = sameChannel
3397
+ ? (typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined)
3398
+ : viaSchedule?.thread;
3247
3399
  // Undeliverable, not decided: nobody was asked, so filing this next to
3248
3400
  // the denials somebody actually made would make the audit record lie
3249
3401
  // about which is which.
@@ -3977,6 +4129,34 @@ export const createSlackChannelAdapter = (options) => {
3977
4129
  };
3978
4130
  /** One thread, whichever agent is talking in it. */
3979
4131
  const threadKeyFor = (parts) => `${parts.team}:${parts.conversation}:${parts.thread}`;
4132
+ /**
4133
+ * A thread's claims cut to `THREAD_HANDOVER_DEPTH`, oldest first out, but
4134
+ * never the two `ownerAt` reads past the rest, so at most one over: the
4135
+ * first claim, which is who the thread belongs to, and the latest claim
4136
+ * that took the thread.
4137
+ * Dropping that one would hand the thread back to its owner after enough
4138
+ * mentions of a third agent, while the judge that took it still answers.
4139
+ */
4140
+ const trimHandovers = (handovers) => {
4141
+ if (handovers.length <= THREAD_HANDOVER_DEPTH) {
4142
+ return handovers;
4143
+ }
4144
+ const first = handovers[0];
4145
+ let latestTake = -1;
4146
+ for (let index = handovers.length - 1; index > 0; index -= 1) {
4147
+ if (handovers[index]?.takes === true) {
4148
+ latestTake = index;
4149
+ break;
4150
+ }
4151
+ }
4152
+ const tailFrom = handovers.length - (THREAD_HANDOVER_DEPTH - 1);
4153
+ // The kept take sits on top of the full recent tail, one past the depth,
4154
+ // so `holderAt` still sees every recent claim it saw before.
4155
+ const tail = handovers.slice(tailFrom);
4156
+ return latestTake > 0 && latestTake < tailFrom
4157
+ ? [first, handovers[latestTake], ...tail]
4158
+ : [first, ...tail];
4159
+ };
3980
4160
  /**
3981
4161
  * Records that `agentId` holds the thread from the message at `ts`
3982
4162
  * onwards, wherever in the thread's history that message falls.
@@ -3989,7 +4169,8 @@ export const createSlackChannelAdapter = (options) => {
3989
4169
  * later insertion would take the thread from a reply that sits below it.
3990
4170
  * Only the same claim twice — the same message reaching a second
3991
4171
  * connection — is dropped.
3992
- * The oldest claims are dropped past `THREAD_HANDOVER_DEPTH`, and the
4172
+ * The oldest claims are dropped past `THREAD_HANDOVER_DEPTH` (but not
4173
+ * the first or the latest that took the thread: `trimHandovers`), and the
3993
4174
  * least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
3994
4175
  * losses cost a lookup, never an answer, since the sessions still hold
3995
4176
  * the durable version.
@@ -4003,7 +4184,7 @@ export const createSlackChannelAdapter = (options) => {
4003
4184
  * Slack timestamps compare as strings: `seconds.microseconds`, both
4004
4185
  * parts fixed width, so lexicographic order is chronological order.
4005
4186
  */
4006
- const rememberAddressee = (key, agentId, ts) => {
4187
+ const rememberAddressee = (key, agentId, ts, takes = false, cold = false) => {
4007
4188
  const handovers = threadAddressee.get(key) ?? [];
4008
4189
  let at = handovers.length;
4009
4190
  while (at > 0 && handovers[at - 1].ts > ts) {
@@ -4011,17 +4192,28 @@ export const createSlackChannelAdapter = (options) => {
4011
4192
  }
4012
4193
  const before = handovers[at - 1];
4013
4194
  if (before && before.agentId === agentId && before.ts === ts) {
4195
+ if (takes) {
4196
+ before.takes = true;
4197
+ }
4198
+ // Seen live as well: no longer only a reconstruction.
4199
+ if (!cold) {
4200
+ delete before.cold;
4201
+ }
4014
4202
  return;
4015
4203
  }
4016
- handovers.splice(at, 0, { agentId, ts });
4204
+ handovers.splice(at, 0, { agentId, ts, ...(takes ? { takes } : {}), ...(cold ? { cold } : {}) });
4017
4205
  // Re-inserted rather than mutated in place: a Map iterates in insertion
4018
4206
  // order, which is what makes the first key the one to evict.
4019
4207
  threadAddressee.delete(key);
4020
- threadAddressee.set(key, handovers.slice(-THREAD_HANDOVER_DEPTH));
4208
+ threadAddressee.set(key, trimHandovers(handovers));
4021
4209
  if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
4022
4210
  const oldest = threadAddressee.keys().next();
4023
4211
  if (!oldest.done) {
4024
4212
  threadAddressee.delete(oldest.value);
4213
+ // The owner goes with the record of who took the thread since: an
4214
+ // owner kept alone would stand in for a judge's takeover it no
4215
+ // longer knows of, where the thread read would see it.
4216
+ threadOrigins.delete(oldest.value);
4025
4217
  }
4026
4218
  }
4027
4219
  };
@@ -4030,19 +4222,122 @@ export const createSlackChannelAdapter = (options) => {
4030
4222
  * handover at or before it. Undefined when this process has no handover
4031
4223
  * that old, which sends the question to the sessions.
4032
4224
  */
4225
+ /**
4226
+ * Whose a stop at `ts` is, from the live record: who was asked last as
4227
+ * of `ts` (`holderAt`). Undefined when the record cannot say, either
4228
+ * because this process has seen nobody asked here by `ts`, or because the
4229
+ * newest entry by then was rebuilt from a thread read (card 508): a
4230
+ * mention since would not be among them. The stop is then left to a
4231
+ * fresh read of the thread, never sent to the owner on a guess.
4232
+ */
4233
+ const stopHolderAt = (key, ts) => holderAt(key, ts);
4033
4234
  const holderAt = (key, ts) => {
4034
4235
  const handovers = threadAddressee.get(key);
4035
4236
  if (!handovers) {
4036
4237
  return undefined;
4037
4238
  }
4239
+ // The newest entry at or before `ts` answers, and only if this process
4240
+ // saw it live. A claim rebuilt from a thread read is a post; who was
4241
+ // asked since may be a mention it cannot see, so it leaves the question
4242
+ // to a fresh read. And it is newer than every live entry before it, so
4243
+ // skipping it for one of those would answer with a claim it superseded.
4038
4244
  for (let index = handovers.length - 1; index >= 0; index -= 1) {
4039
4245
  const handover = handovers[index];
4040
4246
  if (handover && handover.ts <= ts) {
4041
- return handover.agentId;
4247
+ return handover.cold === true ? undefined : handover.agentId;
4042
4248
  }
4043
4249
  }
4044
4250
  return undefined;
4045
4251
  };
4252
+ /**
4253
+ * Who an untagged reply at `ts` belongs to (card 508): the agent the
4254
+ * thread belongs to, not whoever spoke last. That is the first to hold
4255
+ * it — the agent its first message named, a home channel's owner, or the
4256
+ * bot (this daemon's or another's) that posted it — unless a judging
4257
+ * agent has since chosen to speak, which takes it (`takes`). A mention
4258
+ * of another agent gets that agent's answer to that message and nothing
4259
+ * more: the thread stays where it was, and so does every reply after.
4260
+ * In a thread that began before this process did, and that the thread
4261
+ * read has not placed yet, the first claim this process SAW stands in
4262
+ * for the owner: the best it has, and no worse than the last-speaker
4263
+ * rule it replaces. Undefined when this process has seen no claim at
4264
+ * all, which sends the question to the sessions and the thread.
4265
+ */
4266
+ const ownerAt = (key, ts) => {
4267
+ const origin = threadOrigins.get(key);
4268
+ const handovers = threadAddressee.get(key) ?? [];
4269
+ for (let index = handovers.length - 1; index >= 0; index -= 1) {
4270
+ const handover = handovers[index];
4271
+ if (handover && handover.ts <= ts && (handover.takes === true || (index === 0 && typeof origin !== 'string'))) {
4272
+ return handover.agentId;
4273
+ }
4274
+ }
4275
+ return typeof origin === 'string' ? origin : undefined;
4276
+ };
4277
+ /**
4278
+ * The agent the thread `key` belongs to, whoever holds it now: the owner
4279
+ * the thread read settled, or else the first claim this process saw.
4280
+ * Answering in its own thread takes the thread back from a judge that
4281
+ * spoke since (card 508), so the owner is asked for here, not the holder.
4282
+ */
4283
+ const threadOwner = (key) => {
4284
+ const origin = threadOrigins.get(key);
4285
+ return typeof origin === 'string' ? origin : threadAddressee.get(key)?.[0]?.agentId;
4286
+ };
4287
+ /** Records that this process saw the thread `key` begin, or who owns it. */
4288
+ const noteOrigin = (key, owner = true) => {
4289
+ if (owner === true && threadOrigins.has(key)) {
4290
+ return;
4291
+ }
4292
+ threadOrigins.delete(key);
4293
+ threadOrigins.set(key, owner);
4294
+ if (threadOrigins.size > THREAD_ADDRESSEE_CAPACITY) {
4295
+ const oldest = threadOrigins.keys().next();
4296
+ if (!oldest.done) {
4297
+ threadOrigins.delete(oldest.value);
4298
+ }
4299
+ }
4300
+ };
4301
+ /**
4302
+ * A message that starts a thread, seen by any connection: from here on
4303
+ * this process sees the thread's first claim, so it knows the owner. One
4304
+ * this daemon's own agent posted (a schedule's delivery, a message.send)
4305
+ * is that agent's thread.
4306
+ */
4307
+ const noteThreadStart = (connection, args) => {
4308
+ const event = args.event;
4309
+ if (!event || (event.type !== 'message' && event.type !== 'app_mention') || event.channel_type === 'im') {
4310
+ return;
4311
+ }
4312
+ if (event.thread_ts !== undefined && event.thread_ts !== event.ts) {
4313
+ return;
4314
+ }
4315
+ if (event.subtype !== undefined && event.subtype !== 'bot_message' && !HUMAN_MESSAGE_SUBTYPES.has(event.subtype)) {
4316
+ return;
4317
+ }
4318
+ const team = args.body?.team_id ?? connection.teamId;
4319
+ const key = threadKeyFor({ team, conversation: event.channel, thread: event.ts });
4320
+ if (threadOrigins.has(key)) {
4321
+ return;
4322
+ }
4323
+ noteOrigin(key);
4324
+ if (event.user !== undefined) {
4325
+ for (const [agentId, identity] of botIdentities) {
4326
+ if (identity.teamId === team && identity.botUserId === event.user) {
4327
+ rememberAddressee(key, agentId, event.ts);
4328
+ }
4329
+ }
4330
+ }
4331
+ // A team agent on another gateway that starts a thread owns it, even
4332
+ // when its first message asks an agent here something: that agent
4333
+ // answers the question, and the thread stays the asker's. Recorded
4334
+ // here, ahead of the answer, since `noteForeignSpeaker` leaves out a
4335
+ // bot's question to an agent here.
4336
+ const bot = event.bot_id !== undefined || event.subtype === 'bot_message';
4337
+ if (bot && event.user !== undefined && !isFleetBot(team, event.user) && !startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
4338
+ rememberAddressee(key, `${FOREIGN_SPEAKER_PREFIX}${event.user}`, event.ts);
4339
+ }
4340
+ };
4046
4341
  /**
4047
4342
  * Whether `user` is one of this adapter's own agents in `team`. Their
4048
4343
  * replies reach the holder record through `ReplyOutcome.spokeAt`, and
@@ -4056,6 +4351,24 @@ export const createSlackChannelAdapter = (options) => {
4056
4351
  * held it. Used live and when a thread is read back after a restart.
4057
4352
  */
4058
4353
  const asksAgentHere = (team, asker, text) => asker !== undefined && [...botIdentities].some(([agentId, identity]) => identity.teamId === team && mentions(text ?? '', identity.botUserId) && answersAgentMention(agentId, team, asker));
4354
+ /**
4355
+ * Whether a bot message is another app starting a thread — an alert, a
4356
+ * deploy note — rather than an agent (card 508). That starts the thread
4357
+ * without owning it, so an agent named under it keeps the conversation it
4358
+ * was given. A team agent (`agentMentions.team`) starting one owns it the
4359
+ * way an agent here does, and any app replying inside a thread counts as
4360
+ * a speaker there, since an agent on a gateway nobody listed looks like
4361
+ * any other app.
4362
+ */
4363
+ const startsThreadAsIntegration = (team, user, threadTs, ts) => {
4364
+ if (threadTs !== undefined && threadTs !== ts) {
4365
+ return false;
4366
+ }
4367
+ return user === undefined || ![...botIdentities].some(([agentId, identity]) => {
4368
+ const config = agentConfigFor(agentId);
4369
+ return config !== undefined && teamAgentFor(config, identity.teamId, team, user) !== undefined;
4370
+ });
4371
+ };
4059
4372
  /**
4060
4373
  * Records a bot this adapter does not serve as the thread's holder from
4061
4374
  * the message it posted. An agent on another gateway answers in the same
@@ -4087,6 +4400,9 @@ export const createSlackChannelAdapter = (options) => {
4087
4400
  if (asksAgentHere(team, event.user, event.text)) {
4088
4401
  return;
4089
4402
  }
4403
+ if (startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
4404
+ return;
4405
+ }
4090
4406
  const thread = event.thread_ts ?? event.ts;
4091
4407
  rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
4092
4408
  };
@@ -4119,16 +4435,30 @@ export const createSlackChannelAdapter = (options) => {
4119
4435
  const botUserId = botIdentities.get(candidate.agentId)?.botUserId;
4120
4436
  if (botUserId !== undefined) {
4121
4437
  byBotUser.set(botUserId, candidate.agentId);
4122
- held.set(candidate.agentId, 0);
4438
+ held.set(candidate.agentId, Number.POSITIVE_INFINITY);
4123
4439
  continue;
4124
4440
  }
4125
4441
  const spoke = candidate.lastSpokeAt === undefined ? Number.NaN : Date.parse(candidate.lastSpokeAt) / 1000;
4126
- held.set(candidate.agentId, Number.isFinite(spoke) ? spoke : 0);
4442
+ held.set(candidate.agentId, Number.isFinite(spoke) ? spoke : Number.POSITIVE_INFINITY);
4443
+ }
4444
+ // Where each last held it, for a stop, which is for whoever was asked
4445
+ // last (card 337), not for the thread's owner.
4446
+ const last = new Map();
4447
+ for (const [agentId, at] of held) {
4448
+ last.set(agentId, Number.isFinite(at) ? at : 0);
4127
4449
  }
4128
4450
  const place = (agentId, at) => {
4129
- held.set(agentId, Math.max(held.get(agentId) ?? 0, at));
4451
+ held.set(agentId, Math.min(held.get(agentId) ?? Number.POSITIVE_INFINITY, at));
4452
+ last.set(agentId, Math.max(last.get(agentId) ?? 0, at));
4130
4453
  };
4131
- let foreign = 0;
4454
+ let foreign = Number.POSITIVE_INFINITY;
4455
+ let lastForeign = 0;
4456
+ // Each of these agents' own messages, and whether the message before it
4457
+ // had asked it anything: one nobody asked for is a judge taking the
4458
+ // thread, or its owner answering in it (card 508).
4459
+ const posts = [];
4460
+ let asked = new Set();
4461
+ const askedIn = (text) => new Set([...byBotUser].filter(([botUserId]) => mentions(text ?? '', botUserId)).map(([, agentId]) => agentId));
4132
4462
  try {
4133
4463
  let cursor;
4134
4464
  let pages = 0;
@@ -4150,14 +4480,35 @@ export const createSlackChannelAdapter = (options) => {
4150
4480
  const own = message.user === undefined ? undefined : byBotUser.get(message.user);
4151
4481
  if (own !== undefined) {
4152
4482
  place(own, at);
4483
+ posts.push({ agentId: own, at, ts: message.ts, asked: asked.has(own) });
4484
+ // An agent here naming a colleague that answers its mentions
4485
+ // asks it, as `admitAgentMention` has it live, so the answer is
4486
+ // not a judge speaking unasked.
4487
+ const sender = message.user;
4488
+ const named = sender === undefined
4489
+ ? []
4490
+ : [...askedIn(message.text)].filter((agentId) => agentId !== own && answersAgentMention(agentId, parts.team, sender));
4491
+ if (named.length > 0) {
4492
+ asked = new Set(named);
4493
+ }
4153
4494
  continue;
4154
4495
  }
4155
4496
  const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
4156
4497
  if (bot) {
4157
4498
  // A bot's mention hands nothing over live (admit ignores bots),
4158
4499
  // 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);
4500
+ // A team agent's question to an agent here is not it taking the
4501
+ // thread, unless it started the thread with it: then the thread
4502
+ // is its own, as it is live.
4503
+ const asks = asksAgentHere(parts.team, message.user, message.text);
4504
+ if (asks) {
4505
+ asked = askedIn(message.text);
4506
+ }
4507
+ if (!isFleetBot(parts.team, message.user) &&
4508
+ (!asks || message.ts === parts.thread) &&
4509
+ !startsThreadAsIntegration(parts.team, message.user, parts.thread, message.ts ?? '')) {
4510
+ foreign = Math.min(foreign, at);
4511
+ lastForeign = Math.max(lastForeign, at);
4161
4512
  }
4162
4513
  continue;
4163
4514
  }
@@ -4167,10 +4518,19 @@ export const createSlackChannelAdapter = (options) => {
4167
4518
  if (sender === undefined) {
4168
4519
  continue;
4169
4520
  }
4521
+ asked = askedIn(message.text);
4170
4522
  const named = agentNamedIn(message.text ?? '', parts.team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
4171
4523
  if (named !== undefined && held.has(named) && botIdentities.get(named)?.botUserId !== undefined) {
4172
4524
  place(named, at);
4173
4525
  }
4526
+ // A root that names nobody in a home channel is its owner's, as
4527
+ // the live home-channel rule has it.
4528
+ const home = message.ts === parts.thread && named === undefined
4529
+ ? homeOwners.get(`${parts.team}:${parts.conversation}`)
4530
+ : undefined;
4531
+ if (home !== undefined && held.has(home) && admitsSender(agentConfigFor(home), sender)) {
4532
+ place(home, at);
4533
+ }
4174
4534
  }
4175
4535
  cursor = page.response_metadata?.next_cursor || undefined;
4176
4536
  } while (cursor && pages < EARLIER_THREAD_PAGES);
@@ -4182,12 +4542,7 @@ export const createSlackChannelAdapter = (options) => {
4182
4542
  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
4543
  return undefined;
4184
4544
  }
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);
4545
+ return { held, last, foreign, lastForeign, posts };
4191
4546
  };
4192
4547
  /**
4193
4548
  * Which of this process's agents a message names, or undefined if it
@@ -4253,25 +4608,19 @@ export const createSlackChannelAdapter = (options) => {
4253
4608
  * restart forgets nothing.
4254
4609
  *
4255
4610
  * 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.
4611
+ * unaddressed reply belongs to the agent the thread belongs to (card
4612
+ * 508): the first to hold it, read off the thread itself. Only when the
4613
+ * thread can't be read do the sessions decide, and then by `lastSpokeAt`
4614
+ * — when each agent last *replied*, not when its session last changed,
4615
+ * because a turn saves on tool results and approval checkpoints without
4616
+ * having said anything — since that is the only order they keep.
4263
4617
  *
4264
4618
  * 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.
4619
+ * in the thread has spoken, or two tie — because guessing there would put
4620
+ * two answers under one message. The agent alone in its thread, which is
4621
+ * nearly every thread, is answered without the question arising.
4273
4622
  */
4274
- const resolveFollowUpWinner = async (parts, ts) => {
4623
+ const resolveFollowUpWinner = async (parts, ts, mode = 'reply') => {
4275
4624
  const gateway = gatewayRef;
4276
4625
  if (!gateway?.sessionRouting || !parts.thread) {
4277
4626
  return undefined;
@@ -4349,22 +4698,131 @@ export const createSlackChannelAdapter = (options) => {
4349
4698
  }
4350
4699
  for (const [agentId, identity] of botIdentities) {
4351
4700
  if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
4352
- return await foreignSpokeSince(agentId, parts, ts, undefined) ? undefined : agentId;
4701
+ noteOrigin(threadKeyFor(parts), agentId);
4702
+ return agentId;
4353
4703
  }
4354
4704
  }
4355
4705
  return undefined;
4356
4706
  }
4357
- if (engaged.length === 1) {
4358
- return await foreignSpokeSince(first.agentId, parts, ts, first.lastSpokeAt) ? undefined : first.agentId;
4707
+ // The thread says who it belongs to: whichever of these agents held it
4708
+ // first — its first message named it, or it posted there first — or
4709
+ // another app that spoke before any of them did (card 508). When it
4710
+ // can't be read, the sessions decide: the only agent in it, or, as a
4711
+ // last resort, the one that spoke last, which is all they record.
4712
+ // The agent that posted the root takes part even with no session here:
4713
+ // a post made through message.send or a schedule leaves none, and a
4714
+ // colleague named under it since must not look like the owner.
4715
+ const candidates = [...engaged];
4716
+ if (parts.parentUser !== undefined) {
4717
+ for (const [agentId, identity] of botIdentities) {
4718
+ if (identity.teamId === parts.team && identity.botUserId === parts.parentUser && !candidates.some((entry) => entry.agentId === agentId)) {
4719
+ candidates.push({ agentId });
4720
+ }
4721
+ }
4722
+ }
4723
+ const read = await threadPositions(candidates, parts, ts);
4724
+ if (!read) {
4725
+ return engaged.length === 1 ? first.agentId : lastSpeaker(engaged);
4726
+ }
4727
+ if (mode === 'stop') {
4728
+ // A stop is for whoever was asked last (card 337), as `holderAt` has
4729
+ // it live: the turn it means is that agent's, not the owner's.
4730
+ let holder;
4731
+ let tiedLast = false;
4732
+ for (const entry of candidates) {
4733
+ const at = read.last.get(entry.agentId) ?? 0;
4734
+ if (at <= 0) {
4735
+ continue;
4736
+ }
4737
+ if (!holder || at > holder.at) {
4738
+ holder = { agentId: entry.agentId, at };
4739
+ tiedLast = false;
4740
+ }
4741
+ else if (at === holder.at) {
4742
+ tiedLast = true;
4743
+ }
4744
+ }
4745
+ if (!holder) {
4746
+ return read.lastForeign > 0 ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
4747
+ }
4748
+ return tiedLast || read.lastForeign > holder.at ? undefined : holder.agentId;
4749
+ }
4750
+ let owner;
4751
+ let tiedAt = false;
4752
+ for (const entry of candidates) {
4753
+ const at = read.held.get(entry.agentId) ?? Number.POSITIVE_INFINITY;
4754
+ if (!Number.isFinite(at) || at <= 0) {
4755
+ continue;
4756
+ }
4757
+ if (!owner || at < owner.at) {
4758
+ owner = { agentId: entry.agentId, at };
4759
+ tiedAt = false;
4760
+ }
4761
+ else if (at === owner.at) {
4762
+ tiedAt = true;
4763
+ }
4764
+ }
4765
+ // A judging agent that spoke unasked took the thread, and the owner
4766
+ // answering in it took it back, as live (card 508): the latest of those
4767
+ // holds it. The live record of who took it is lost on a restart; the
4768
+ // thread keeps it. Only a judge takes by speaking unasked, as only its
4769
+ // turns are judged live; another agent's post there (a message.send)
4770
+ // takes nothing.
4771
+ const ownerId = owner && !tiedAt && !(read.foreign < owner.at) ? owner.agentId : undefined;
4772
+ let taken;
4773
+ const takeovers = [];
4774
+ for (const post of read.posts) {
4775
+ const takes = post.agentId === ownerId || (!post.asked && listensOf(post.agentId) === 'judge');
4776
+ if (takes) {
4777
+ takeovers.push(post);
4778
+ if (!taken || post.at > taken.at) {
4779
+ taken = post;
4780
+ }
4781
+ }
4359
4782
  }
4783
+ // Once the owner is known, the live record learns what the thread read
4784
+ // found, as it would have live: who owns the thread, and every post
4785
+ // that moved it, where it sits. With only the verdict, a judge's reply
4786
+ // to this message became the first claim this process saw and read as
4787
+ // the owner, so the real owner answering a mention could not take the
4788
+ // thread back (Codex 4290051968). Every take rather than the latest, so
4789
+ // a message delivered late is placed where it was typed, and the judge's
4790
+ // take at its own post, so a reply typed before its answer lands still
4791
+ // goes to the judge, as this verdict says.
4792
+ const warm = () => {
4793
+ if (ownerId === undefined) {
4794
+ return;
4795
+ }
4796
+ const key = threadKeyFor(parts);
4797
+ noteOrigin(key, ownerId);
4798
+ for (const post of takeovers) {
4799
+ rememberAddressee(key, post.agentId, post.ts, true, true);
4800
+ }
4801
+ };
4802
+ if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
4803
+ warm();
4804
+ return taken.agentId;
4805
+ }
4806
+ if (!owner) {
4807
+ return Number.isFinite(read.foreign) ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
4808
+ }
4809
+ if (ownerId === undefined) {
4810
+ return undefined;
4811
+ }
4812
+ warm();
4813
+ return ownerId;
4814
+ };
4815
+ /**
4816
+ * Of several agents in a thread, the one whose session says it spoke
4817
+ * last — the sessions' only ordering, used when the thread itself can't
4818
+ * be read. Undefined when nobody has spoken or the newest two tie, since
4819
+ * guessing there would put two answers under one message.
4820
+ */
4821
+ const lastSpeaker = (engaged) => {
4360
4822
  let latest;
4361
4823
  let tied = false;
4362
4824
  for (const entry of engaged) {
4363
4825
  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
4826
  continue;
4369
4827
  }
4370
4828
  if (!latest || entry.lastSpokeAt > latest.lastSpokeAt) {
@@ -4375,34 +4833,7 @@ export const createSlackChannelAdapter = (options) => {
4375
4833
  tied = true;
4376
4834
  }
4377
4835
  }
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;
4836
+ return tied ? undefined : latest?.agentId;
4406
4837
  };
4407
4838
  /**
4408
4839
  * `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
@@ -4422,13 +4853,15 @@ export const createSlackChannelAdapter = (options) => {
4422
4853
  * this after the winner has finished, and recomputing for that agent is
4423
4854
  * precisely the disagreement the memo exists to prevent.
4424
4855
  */
4425
- const followUpWinner = (parts, ts) => {
4426
- const key = `${threadKeyFor(parts)}:${ts}`;
4856
+ const followUpWinner = (parts, ts, mode = 'reply') => {
4857
+ // A stop asks a different question (whoever was asked last), so it
4858
+ // gets its own verdict, the same for every agent that asks it.
4859
+ const key = `${threadKeyFor(parts)}:${ts}${mode === 'stop' ? ':stop' : ''}`;
4427
4860
  const existing = coldVerdicts.get(key);
4428
4861
  if (existing) {
4429
4862
  return existing.verdict;
4430
4863
  }
4431
- const entry = { verdict: resolveFollowUpWinner(parts, ts), settled: false };
4864
+ const entry = { verdict: resolveFollowUpWinner(parts, ts, mode), settled: false };
4432
4865
  coldVerdicts.set(key, entry);
4433
4866
  void entry.verdict.then(() => {
4434
4867
  entry.settled = true;
@@ -4625,6 +5058,7 @@ export const createSlackChannelAdapter = (options) => {
4625
5058
  const admit = (connection, args) => {
4626
5059
  const event = args.event;
4627
5060
  if (gatewayRef) {
5061
+ noteThreadStart(connection, args);
4628
5062
  noteForeignSpeaker(connection, args);
4629
5063
  }
4630
5064
  if (gatewayRef && event && (event.bot_id !== undefined || event.subtype === 'bot_message')) {
@@ -4716,6 +5150,7 @@ export const createSlackChannelAdapter = (options) => {
4716
5150
  // Ava's transcript; only the map learns who holds the thread.
4717
5151
  if (threadKey && handoverTo) {
4718
5152
  rememberAddressee(threadKey, handoverTo, event.ts);
5153
+ rememberSenderAddressee(threadKey, sender, handoverTo, event.ts);
4719
5154
  }
4720
5155
  // A home-channel message hands its new thread to the channel's owner
4721
5156
  // the way a mention would, and is recorded by every socket for the same
@@ -4772,7 +5207,12 @@ export const createSlackChannelAdapter = (options) => {
4772
5207
  return undefined;
4773
5208
  }
4774
5209
  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;
5210
+ // Whose message this is, as for an admitted one: the thread's owner's
5211
+ // (card 508), or for a stop, whoever was asked last (card 337).
5212
+ const refusedStop = (connection.config.approvers ?? []).includes(sender) && isStopRequest(text, connection.botUserId);
5213
+ const held = threadKey === undefined
5214
+ ? undefined
5215
+ : refusedStop ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
4776
5216
  if (addressed || held === connection.config.agentId) {
4777
5217
  if (!alreadySeen(eventKey)) {
4778
5218
  log(refusal);
@@ -4791,7 +5231,7 @@ export const createSlackChannelAdapter = (options) => {
4791
5231
  conversation: event.channel,
4792
5232
  thread,
4793
5233
  ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
4794
- }, event.ts).then((winner) => {
5234
+ }, event.ts, refusedStop ? 'stop' : 'reply').then((winner) => {
4795
5235
  if (winner === connection.config.agentId) {
4796
5236
  log(refusal);
4797
5237
  }
@@ -4860,12 +5300,12 @@ export const createSlackChannelAdapter = (options) => {
4860
5300
  return { ...admission, overhear: true };
4861
5301
  }
4862
5302
  // An untagged reply. How this agent listens decides what it is: an
4863
- // agent that answers only mentions hears it and nothing more, and one
4864
- // that judges hears it and, if still attentive, takes a turn nobody
4865
- // asked for on it — decided in the intake, since only the session can
4866
- // say. Neither takes part in the holder rule below: that rule answers
4867
- // "whose is an untagged reply", and for these two agents the soul has
4868
- // already answered.
5303
+ // agent that answers only mentions hears it and nothing more. One that
5304
+ // judges takes a turn nobody asked for on it — one it may stay quiet
5305
+ // on — when the reply is its own by the rule every agent shares (card
5306
+ // 541): the agent this person last addressed, else the thread's owner.
5307
+ // No window: an owner an hour past its last answer still hears an
5308
+ // instruction as its own, and exactly one agent takes each reply.
4869
5309
  const listens = listensOf(connection.config.agentId);
4870
5310
  if (listens === 'mentions' || listens === 'judge') {
4871
5311
  // Except a stop (card 337): the holder rule decides whose it is, as
@@ -4876,21 +5316,39 @@ export const createSlackChannelAdapter = (options) => {
4876
5316
  (connection.config.approvers ?? []).includes(sender) &&
4877
5317
  isStopRequest(text, connection.botUserId);
4878
5318
  const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
4879
- // A stop held by another agent is only heard: judging it would let an
4880
- // attentive agent take a turn on a stop that was never its own.
4881
- const stopForAnother = stopHolder !== undefined && stopHolder !== connection.config.agentId;
5319
+ if (listens === 'judge' && !approverStop && threadKey !== undefined) {
5320
+ const target = replyTargetAt(threadKey, sender, event.ts);
5321
+ if (target === undefined) {
5322
+ // Nothing seen here says whose thread this is: the sessions and
5323
+ // the thread read decide, as for any agent.
5324
+ return { ...admission, overhear: true, judge: true, settled: false, judgeReason: 'owner, from the thread read' };
5325
+ }
5326
+ if (target !== connection.config.agentId) {
5327
+ return { ...admission, overhear: true, judgeReason: `${target}'s reply` };
5328
+ }
5329
+ const reason = senderAddresseeAt(threadKey, sender, event.ts) === target ? 'addressed it last' : 'owner';
5330
+ return { ...admission, overhear: true, judge: true, judgeReason: reason };
5331
+ }
5332
+ // An approver's stop is never judged (card 541): it stops the turn of
5333
+ // whoever holds the thread, and with nothing to stop it is only heard.
4882
5334
  return {
4883
5335
  ...admission,
4884
5336
  overhear: true,
4885
- ...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
4886
5337
  ...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
4887
5338
  ...(approverStop && stopHolder === undefined ? { settled: false } : {}),
4888
5339
  };
4889
5340
  }
4890
5341
  // The record is what this process has actually seen, in the order it
4891
5342
  // saw it — right even inside the window before a handover reaches the
4892
- // store.
4893
- const holder = threadKey === undefined ? undefined : holderAt(threadKey, event.ts);
5343
+ // store. Whose an untagged reply is, is the thread's owner's (card
5344
+ // 508), not the last speaker's — except a stop, which is for whoever
5345
+ // was asked last, since that is the turn it means (card 337).
5346
+ const stopRequest = threadKey !== undefined &&
5347
+ (connection.config.approvers ?? []).includes(sender) &&
5348
+ isStopRequest(text, connection.botUserId);
5349
+ const holder = threadKey === undefined
5350
+ ? undefined
5351
+ : stopRequest ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
4894
5352
  if (holder !== undefined && threadKey !== undefined) {
4895
5353
  if (holder !== connection.config.agentId) {
4896
5354
  // Standing down for the holder, whether or not the holder can still
@@ -4994,39 +5452,25 @@ export const createSlackChannelAdapter = (options) => {
4994
5452
  // not to be this agent's leaves an empty link behind, which is all it
4995
5453
  // should cost.
4996
5454
  let overhear = admitted.overhear;
4997
- // Judged, not ruled: a turn nobody asked for, while the agent is
4998
- // still attentive, and a free overhear past that. Read off the
4999
- // chain — a session the invitation is still creating reads as not
5000
- // there, and the message is heard rather than judged, which is the
5001
- // safe side. A host that cannot say leaves the agent hearing.
5455
+ // Judged, not ruled (card 541): the intake already placed this reply
5456
+ // with this agent, so it runs a turn nobody asked for, which may stay
5457
+ // quiet. A reply only the sessions can place waits for them below.
5002
5458
  let judged = false;
5003
- if (admitted.judge && gateway.sessionRouting) {
5004
- // The pending count is taken BEFORE the store is read: a message
5005
- // that settles between the two would be counted by neither the
5006
- // other way round, and a window that runs one message short in a
5007
- // race is a bound kept; one that runs a message long is not.
5008
- const pending = pendingFor(sessionId);
5009
- let routing;
5010
- try {
5011
- routing = await gateway.sessionRouting(sessionId);
5012
- }
5013
- catch (error) {
5014
- warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
5015
- }
5016
- if (routing && attentive(routing, event.ts, pending)) {
5017
- judged = true;
5018
- overhear = false;
5019
- }
5459
+ if (admitted.judge && admitted.settled) {
5460
+ judged = true;
5461
+ overhear = false;
5020
5462
  }
5021
5463
  if (!admitted.settled) {
5022
5464
  // One verdict for this message, shared with whichever other agents
5023
5465
  // are asking about it — see `followUpWinner`.
5466
+ const stop = (connection.config.approvers ?? []).includes(userId) &&
5467
+ isStopRequest(event.text ?? '', connection.botUserId);
5024
5468
  const winner = await followUpWinner({
5025
5469
  team,
5026
5470
  conversation: event.channel,
5027
5471
  thread: event.thread_ts ?? '',
5028
5472
  ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
5029
- }, event.ts);
5473
+ }, event.ts, stop ? 'stop' : 'reply');
5030
5474
  if (winner !== connection.config.agentId) {
5031
5475
  // Not this agent's to answer — and, if it is in the thread,
5032
5476
  // this agent's to hear, same as the two warm stand-downs. Only a
@@ -5036,6 +5480,10 @@ export const createSlackChannelAdapter = (options) => {
5036
5480
  overhear = true;
5037
5481
  }
5038
5482
  else {
5483
+ if (admitted.judge) {
5484
+ judged = true;
5485
+ overhear = false;
5486
+ }
5039
5487
  if (threadKey) {
5040
5488
  // Answered from the store rather than from memory: hold it, so
5041
5489
  // the rest of the thread is ordered by receipt like any other.
@@ -5051,6 +5499,12 @@ export const createSlackChannelAdapter = (options) => {
5051
5499
  }
5052
5500
  }
5053
5501
  }
5502
+ // One line per decision a judging agent makes on an untagged reply
5503
+ // (card 541): whether it took the turn, and why. Never the text.
5504
+ if (admitted.judgeReason !== undefined) {
5505
+ const reason = admitted.settled || judged ? admitted.judgeReason : 'another agent\'s, from the thread read';
5506
+ log(`slack: judge: ${connection.config.agentId} ${judged ? 'took' : 'heard'} ${event.ts} in ${event.channel} (${reason})`);
5507
+ }
5054
5508
  const cleaned = await humanizeMentions(connection, event.text ?? '');
5055
5509
  // Fetched inside the chain, like the lookups below: it is network
5056
5510
  // I/O, and a message whose screenshot is slow to arrive must not lose
@@ -5154,7 +5608,7 @@ export const createSlackChannelAdapter = (options) => {
5154
5608
  // an overhear queued behind a long turn would hold the next
5155
5609
  // message's place in this chain hostage to that turn.
5156
5610
  return {
5157
- observed: placeObserve(sessionId, gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata })),
5611
+ observed: gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata }),
5158
5612
  };
5159
5613
  }
5160
5614
  // A turn nobody asked for opens its placeholder on its first text,
@@ -5418,7 +5872,12 @@ export const createSlackChannelAdapter = (options) => {
5418
5872
  // the reader's next untagged message is for the lower of the two.
5419
5873
  // Late, either way, for a message typed while the turn was still
5420
5874
  // running, which the other may therefore still take.
5421
- rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
5875
+ // Under the owner rule (card 508) only two replies move the thread:
5876
+ // a judging agent that chose to speak, and the owner answering in
5877
+ // its own thread, which takes it back from a judge that spoke above.
5878
+ // An answer to a mention is that message's answer and no more.
5879
+ const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
5880
+ rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
5422
5881
  }
5423
5882
  // The reply just posted took the thread's status down with it, and a
5424
5883
  // turn queued behind this one is still working.
@@ -5437,8 +5896,10 @@ export const createSlackChannelAdapter = (options) => {
5437
5896
  ? await renderer.finalize(renderer.lazy ? '' : failure.message)
5438
5897
  : await renderer.fail(failure instanceof Error ? failure.message : String(failure));
5439
5898
  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);
5899
+ // A file it posted before breaking has still spoken, by the same
5900
+ // rule as a reply that landed.
5901
+ const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
5902
+ rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
5442
5903
  }
5443
5904
  renderers.get(sessionId)?.[0]?.refreshLoading();
5444
5905
  }
@@ -5450,6 +5911,7 @@ export const createSlackChannelAdapter = (options) => {
5450
5911
  // A channel id is unique only within its workspace, so a standing
5451
5912
  // grant on one is bound to the team the agent's app authenticated in.
5452
5913
  accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
5914
+ conversationKindFor,
5453
5915
  requestCredential,
5454
5916
  async start(gateway) {
5455
5917
  gatewayRef = gateway;
@@ -5665,8 +6127,8 @@ export const createSlackChannelAdapter = (options) => {
5665
6127
  connections.length = 0;
5666
6128
  approvalPosts.clear();
5667
6129
  threadAddressee.clear();
6130
+ senderAddressees.clear();
5668
6131
  coldVerdicts.clear();
5669
- pendingObserves.clear();
5670
6132
  botIdentities.clear();
5671
6133
  homeOwners.clear();
5672
6134
  rendering.clear();