@stratusagent/channel-slack 0.12.5 → 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
@@ -2293,6 +2293,67 @@ export const createSlackChannelAdapter = (options) => {
2293
2293
  * the addressee map; a lost entry costs a lookup.
2294
2294
  */
2295
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);
2296
2357
  /**
2297
2358
  * One cold verdict per message — see `followUpWinner`. Bounded, because
2298
2359
  * an entry outlives its own resolution on purpose; an entry that has not
@@ -2813,69 +2874,6 @@ export const createSlackChannelAdapter = (options) => {
2813
2874
  * nothing, which is every soul written before there was a choice.
2814
2875
  */
2815
2876
  const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
2816
- /**
2817
- * Whether an agent that judges is still paying attention to a thread:
2818
- * within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
2819
- * answered a message that addressed it, both read from the session
2820
- * rather than remembered here, so a restart forgets nothing. Anchored
2821
- * to an addressed answer and not to any reply: a reply the agent chose
2822
- * to give on a turn nobody asked for does not re-arm it, or a talkative
2823
- * judge would keep itself attentive for good and cost a call per message
2824
- * — the self-extension the spec declined. An agent never addressed in
2825
- * the thread is not attentive: it was mentioned and its first turn has
2826
- * not answered yet, or answered with nothing. Measured against the
2827
- * message's own timestamp, not the clock, so a delivery that ran late is
2828
- * judged as of when it was said. Bounded below as well as above: a
2829
- * message said before the answer the window starts from is outside it
2830
- * — Slack redelivers events a restarted daemon has no memory of, and a
2831
- * negative age would otherwise pass the minutes bound however old the
2832
- * message, on a turn the agent's answer already came after.
2833
- */
2834
- const attentive = (routing, messageTs, pending) => {
2835
- if (routing.lastAnsweredAt === undefined) {
2836
- return false;
2837
- }
2838
- const answeredAt = Date.parse(routing.lastAnsweredAt);
2839
- const saidAt = Number(messageTs) * 1000;
2840
- if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
2841
- return false;
2842
- }
2843
- return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
2844
- };
2845
- /**
2846
- * Messages this adapter has dispatched to a session that the session
2847
- * does not hold yet. The store counts what it holds, and a burst of
2848
- * messages typed inside one second would each read the same count and
2849
- * each be judged — the bound the window exists to give, blown by exactly
2850
- * the thread that needs it. The renderer queue already knows: a renderer
2851
- * is queued at dispatch and its turn starts (`turnStarted`) only once
2852
- * the runner has saved the message — the same moment the store's count
2853
- * takes it over — so the two never count one message twice.
2854
- */
2855
- const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
2856
- + (pendingObserves.get(sessionId) ?? 0);
2857
- /**
2858
- * Overhears placed on the gateway's chain and not yet written, per
2859
- * session: the other kind of message the store does not hold yet. An
2860
- * observe queued behind a running turn is heard only when the turn ends,
2861
- * and a burst of messages to a colleague would otherwise count toward
2862
- * nothing until then. Counted from placement to settlement.
2863
- */
2864
- const pendingObserves = new Map();
2865
- const placeObserve = (sessionId, observed) => {
2866
- pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
2867
- const release = () => {
2868
- const left = (pendingObserves.get(sessionId) ?? 1) - 1;
2869
- if (left > 0) {
2870
- pendingObserves.set(sessionId, left);
2871
- }
2872
- else {
2873
- pendingObserves.delete(sessionId);
2874
- }
2875
- };
2876
- void observed.then(release, release);
2877
- return observed;
2878
- };
2879
2877
  /**
2880
2878
  * Run `work` after everything already queued for this session's intake,
2881
2879
  * and hold the next link until it settles. The link is claimed
@@ -2997,7 +2995,7 @@ export const createSlackChannelAdapter = (options) => {
2997
2995
  // whoever installed it.
2998
2996
  const author = await displayNameFor(hearer, speaker.botUserId);
2999
2997
  const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
3000
- return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
2998
+ return { observed };
3001
2999
  });
3002
3000
  await observed;
3003
3001
  }
@@ -3061,7 +3059,7 @@ export const createSlackChannelAdapter = (options) => {
3061
3059
  }
3062
3060
  const author = await displayNameFor(connection, connection.botUserId);
3063
3061
  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) };
3062
+ return { observed };
3065
3063
  });
3066
3064
  // Tracked, so stop() drains it: a restart right after a report must not
3067
3065
  // close the stores while the thread's session is still to hear it.
@@ -3161,6 +3159,55 @@ export const createSlackChannelAdapter = (options) => {
3161
3159
  },
3162
3160
  };
3163
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
+ };
3164
3211
  /**
3165
3212
  * The read side of the same boundary `resolveOutbound` draws: a channel
3166
3213
  * the agent's own app is a member of, public or private. DMs and group
@@ -3334,8 +3381,21 @@ export const createSlackChannelAdapter = (options) => {
3334
3381
  const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
3335
3382
  ? metadata.slackChannel
3336
3383
  : undefined;
3337
- const channel = sameChannel ?? connection.config.approvalChannel;
3338
- 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;
3339
3399
  // Undeliverable, not decided: nobody was asked, so filing this next to
3340
3400
  // the denials somebody actually made would make the audit record lie
3341
3401
  // about which is which.
@@ -4124,7 +4184,7 @@ export const createSlackChannelAdapter = (options) => {
4124
4184
  * Slack timestamps compare as strings: `seconds.microseconds`, both
4125
4185
  * parts fixed width, so lexicographic order is chronological order.
4126
4186
  */
4127
- const rememberAddressee = (key, agentId, ts, takes = false) => {
4187
+ const rememberAddressee = (key, agentId, ts, takes = false, cold = false) => {
4128
4188
  const handovers = threadAddressee.get(key) ?? [];
4129
4189
  let at = handovers.length;
4130
4190
  while (at > 0 && handovers[at - 1].ts > ts) {
@@ -4135,9 +4195,13 @@ export const createSlackChannelAdapter = (options) => {
4135
4195
  if (takes) {
4136
4196
  before.takes = true;
4137
4197
  }
4198
+ // Seen live as well: no longer only a reconstruction.
4199
+ if (!cold) {
4200
+ delete before.cold;
4201
+ }
4138
4202
  return;
4139
4203
  }
4140
- handovers.splice(at, 0, takes ? { agentId, ts, takes } : { agentId, ts });
4204
+ handovers.splice(at, 0, { agentId, ts, ...(takes ? { takes } : {}), ...(cold ? { cold } : {}) });
4141
4205
  // Re-inserted rather than mutated in place: a Map iterates in insertion
4142
4206
  // order, which is what makes the first key the one to evict.
4143
4207
  threadAddressee.delete(key);
@@ -4158,15 +4222,29 @@ export const createSlackChannelAdapter = (options) => {
4158
4222
  * handover at or before it. Undefined when this process has no handover
4159
4223
  * that old, which sends the question to the sessions.
4160
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);
4161
4234
  const holderAt = (key, ts) => {
4162
4235
  const handovers = threadAddressee.get(key);
4163
4236
  if (!handovers) {
4164
4237
  return undefined;
4165
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.
4166
4244
  for (let index = handovers.length - 1; index >= 0; index -= 1) {
4167
4245
  const handover = handovers[index];
4168
4246
  if (handover && handover.ts <= ts) {
4169
- return handover.agentId;
4247
+ return handover.cold === true ? undefined : handover.agentId;
4170
4248
  }
4171
4249
  }
4172
4250
  return undefined;
@@ -4402,7 +4480,7 @@ export const createSlackChannelAdapter = (options) => {
4402
4480
  const own = message.user === undefined ? undefined : byBotUser.get(message.user);
4403
4481
  if (own !== undefined) {
4404
4482
  place(own, at);
4405
- posts.push({ agentId: own, at, asked: asked.has(own) });
4483
+ posts.push({ agentId: own, at, ts: message.ts, asked: asked.has(own) });
4406
4484
  // An agent here naming a colleague that answers its mentions
4407
4485
  // asks it, as `admitAgentMention` has it live, so the answer is
4408
4486
  // not a judge speaking unasked.
@@ -4692,13 +4770,37 @@ export const createSlackChannelAdapter = (options) => {
4692
4770
  // takes nothing.
4693
4771
  const ownerId = owner && !tiedAt && !(read.foreign < owner.at) ? owner.agentId : undefined;
4694
4772
  let taken;
4773
+ const takeovers = [];
4695
4774
  for (const post of read.posts) {
4696
4775
  const takes = post.agentId === ownerId || (!post.asked && listensOf(post.agentId) === 'judge');
4697
- if (takes && (!taken || post.at > taken.at)) {
4698
- taken = post;
4776
+ if (takes) {
4777
+ takeovers.push(post);
4778
+ if (!taken || post.at > taken.at) {
4779
+ taken = post;
4780
+ }
4699
4781
  }
4700
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
+ };
4701
4802
  if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
4803
+ warm();
4702
4804
  return taken.agentId;
4703
4805
  }
4704
4806
  if (!owner) {
@@ -4707,7 +4809,7 @@ export const createSlackChannelAdapter = (options) => {
4707
4809
  if (ownerId === undefined) {
4708
4810
  return undefined;
4709
4811
  }
4710
- noteOrigin(threadKeyFor(parts), ownerId);
4812
+ warm();
4711
4813
  return ownerId;
4712
4814
  };
4713
4815
  /**
@@ -5048,6 +5150,7 @@ export const createSlackChannelAdapter = (options) => {
5048
5150
  // Ava's transcript; only the map learns who holds the thread.
5049
5151
  if (threadKey && handoverTo) {
5050
5152
  rememberAddressee(threadKey, handoverTo, event.ts);
5153
+ rememberSenderAddressee(threadKey, sender, handoverTo, event.ts);
5051
5154
  }
5052
5155
  // A home-channel message hands its new thread to the channel's owner
5053
5156
  // the way a mention would, and is recorded by every socket for the same
@@ -5109,7 +5212,7 @@ export const createSlackChannelAdapter = (options) => {
5109
5212
  const refusedStop = (connection.config.approvers ?? []).includes(sender) && isStopRequest(text, connection.botUserId);
5110
5213
  const held = threadKey === undefined
5111
5214
  ? undefined
5112
- : (refusedStop ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
5215
+ : refusedStop ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
5113
5216
  if (addressed || held === connection.config.agentId) {
5114
5217
  if (!alreadySeen(eventKey)) {
5115
5218
  log(refusal);
@@ -5197,12 +5300,12 @@ export const createSlackChannelAdapter = (options) => {
5197
5300
  return { ...admission, overhear: true };
5198
5301
  }
5199
5302
  // An untagged reply. How this agent listens decides what it is: an
5200
- // agent that answers only mentions hears it and nothing more, and one
5201
- // that judges hears it and, if still attentive, takes a turn nobody
5202
- // asked for on it — decided in the intake, since only the session can
5203
- // say. Neither takes part in the holder rule below: that rule answers
5204
- // "whose is an untagged reply", and for these two agents the soul has
5205
- // 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.
5206
5309
  const listens = listensOf(connection.config.agentId);
5207
5310
  if (listens === 'mentions' || listens === 'judge') {
5208
5311
  // Except a stop (card 337): the holder rule decides whose it is, as
@@ -5213,13 +5316,24 @@ export const createSlackChannelAdapter = (options) => {
5213
5316
  (connection.config.approvers ?? []).includes(sender) &&
5214
5317
  isStopRequest(text, connection.botUserId);
5215
5318
  const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
5216
- // A stop held by another agent is only heard: judging it would let an
5217
- // attentive agent take a turn on a stop that was never its own.
5218
- const stopForAnother = stopHolder !== undefined && stopHolder !== connection.config.agentId;
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.
5219
5334
  return {
5220
5335
  ...admission,
5221
5336
  overhear: true,
5222
- ...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
5223
5337
  ...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
5224
5338
  ...(approverStop && stopHolder === undefined ? { settled: false } : {}),
5225
5339
  };
@@ -5234,7 +5348,7 @@ export const createSlackChannelAdapter = (options) => {
5234
5348
  isStopRequest(text, connection.botUserId);
5235
5349
  const holder = threadKey === undefined
5236
5350
  ? undefined
5237
- : (stopRequest ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
5351
+ : stopRequest ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
5238
5352
  if (holder !== undefined && threadKey !== undefined) {
5239
5353
  if (holder !== connection.config.agentId) {
5240
5354
  // Standing down for the holder, whether or not the holder can still
@@ -5338,29 +5452,13 @@ export const createSlackChannelAdapter = (options) => {
5338
5452
  // not to be this agent's leaves an empty link behind, which is all it
5339
5453
  // should cost.
5340
5454
  let overhear = admitted.overhear;
5341
- // Judged, not ruled: a turn nobody asked for, while the agent is
5342
- // still attentive, and a free overhear past that. Read off the
5343
- // chain — a session the invitation is still creating reads as not
5344
- // there, and the message is heard rather than judged, which is the
5345
- // 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.
5346
5458
  let judged = false;
5347
- if (admitted.judge && gateway.sessionRouting) {
5348
- // The pending count is taken BEFORE the store is read: a message
5349
- // that settles between the two would be counted by neither the
5350
- // other way round, and a window that runs one message short in a
5351
- // race is a bound kept; one that runs a message long is not.
5352
- const pending = pendingFor(sessionId);
5353
- let routing;
5354
- try {
5355
- routing = await gateway.sessionRouting(sessionId);
5356
- }
5357
- catch (error) {
5358
- warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
5359
- }
5360
- if (routing && attentive(routing, event.ts, pending)) {
5361
- judged = true;
5362
- overhear = false;
5363
- }
5459
+ if (admitted.judge && admitted.settled) {
5460
+ judged = true;
5461
+ overhear = false;
5364
5462
  }
5365
5463
  if (!admitted.settled) {
5366
5464
  // One verdict for this message, shared with whichever other agents
@@ -5382,6 +5480,10 @@ export const createSlackChannelAdapter = (options) => {
5382
5480
  overhear = true;
5383
5481
  }
5384
5482
  else {
5483
+ if (admitted.judge) {
5484
+ judged = true;
5485
+ overhear = false;
5486
+ }
5385
5487
  if (threadKey) {
5386
5488
  // Answered from the store rather than from memory: hold it, so
5387
5489
  // the rest of the thread is ordered by receipt like any other.
@@ -5397,6 +5499,12 @@ export const createSlackChannelAdapter = (options) => {
5397
5499
  }
5398
5500
  }
5399
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
+ }
5400
5508
  const cleaned = await humanizeMentions(connection, event.text ?? '');
5401
5509
  // Fetched inside the chain, like the lookups below: it is network
5402
5510
  // I/O, and a message whose screenshot is slow to arrive must not lose
@@ -5500,7 +5608,7 @@ export const createSlackChannelAdapter = (options) => {
5500
5608
  // an overhear queued behind a long turn would hold the next
5501
5609
  // message's place in this chain hostage to that turn.
5502
5610
  return {
5503
- 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 }),
5504
5612
  };
5505
5613
  }
5506
5614
  // A turn nobody asked for opens its placeholder on its first text,
@@ -5803,6 +5911,7 @@ export const createSlackChannelAdapter = (options) => {
5803
5911
  // A channel id is unique only within its workspace, so a standing
5804
5912
  // grant on one is bound to the team the agent's app authenticated in.
5805
5913
  accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
5914
+ conversationKindFor,
5806
5915
  requestCredential,
5807
5916
  async start(gateway) {
5808
5917
  gatewayRef = gateway;
@@ -6018,8 +6127,8 @@ export const createSlackChannelAdapter = (options) => {
6018
6127
  connections.length = 0;
6019
6128
  approvalPosts.clear();
6020
6129
  threadAddressee.clear();
6130
+ senderAddressees.clear();
6021
6131
  coldVerdicts.clear();
6022
- pendingObserves.clear();
6023
6132
  botIdentities.clear();
6024
6133
  homeOwners.clear();
6025
6134
  rendering.clear();