@stratusagent/channel-slack 0.11.2 → 0.11.3

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
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
- import { latestTurnReply, SENDER_TRUST_METADATA_KEY, } from '@stratusagent/core';
5
+ import { filePathsOf, latestTurnReply, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, } from '@stratusagent/core';
6
6
  import { channelSessionKey, } from '@stratusagent/channels';
7
7
  const SLACK_MAX_MESSAGE_CHARS = 4000;
8
8
  const DEFAULT_EDIT_INTERVAL_MS = 1000;
@@ -21,7 +21,7 @@ const THREAD_ADDRESSEE_CAPACITY = 2000;
21
21
  */
22
22
  const COLD_VERDICT_CAPACITY = 256;
23
23
  /**
24
- * How many changes of hands one thread remembers.
24
+ * How many claims on one thread it remembers.
25
25
  *
26
26
  * A message older than all of them asks the sessions instead, which answer
27
27
  * for the thread as it stands rather than as it stood — so a reply that old
@@ -29,13 +29,14 @@ const COLD_VERDICT_CAPACITY = 256;
29
29
  * bounding this, and something has to bound it: the alternative is a list
30
30
  * that grows with a conversation for the life of the daemon.
31
31
  *
32
- * Eight is chosen to sit far past the case it covers. This only has to
32
+ * A claim is a change of hands or a reply that landed (see
33
+ * `rememberAddressee`), so a busy thread writes one or two per message.
34
+ * Thirty-two sits far past the case the record covers: it only has to
33
35
  * span the lag between two sockets, which is messages inside one delivery
34
- * window; crossing it needs a single thread to change hands eight times —
35
- * eight mentions, alternating between agents — while one socket stays
36
- * behind for all of them.
36
+ * window, and crossing it needs that many replies in one thread while a
37
+ * socket stays behind for all of them.
37
38
  */
38
- const THREAD_HANDOVER_DEPTH = 8;
39
+ const THREAD_HANDOVER_DEPTH = 32;
39
40
  /**
40
41
  * How many files an unrendered turn may queue for its outcome. Nothing
41
42
  * drains that queue until the outcome arrives, so a session whose outcome
@@ -45,6 +46,26 @@ const THREAD_HANDOVER_DEPTH = 8;
45
46
  */
46
47
  const MAX_UNRENDERED_FILES = 20;
47
48
  const PLACEHOLDER_TEXT = '…';
49
+ /**
50
+ * How long an agent that judges stays attentive after it last answered a
51
+ * message that addressed it: this many messages, or this many minutes,
52
+ * whichever ends first. Judging costs
53
+ * a model call per message, and two people talking for an hour is a
54
+ * hundred of them for one "no" — so attention decays the way a person's
55
+ * does. Outside the window a message is overheard for free, and a mention
56
+ * re-arms it, which is what a mention has always meant.
57
+ */
58
+ const ATTENTION_MESSAGES = 8;
59
+ const ATTENTION_MS = 15 * 60 * 1000;
60
+ /**
61
+ * A 5 MB image over a slow link is seconds, not minutes. Past this the
62
+ * downloads are abandoned and the turn runs with the attachments named as
63
+ * unreadable, rather than the thread waiting on them. One deadline for
64
+ * the whole message, not one per file: a message may carry ten, and ten
65
+ * stalls in a row would hold the thread — and `stop()` — ten times as
66
+ * long.
67
+ */
68
+ const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
48
69
  /** What a turn that produced no text puts in its message, wherever it is posted from. */
49
70
  const NO_REPLY_TEXT = '(no reply)';
50
71
  // Lazy (and CJS-interoperable) so tests with injected fakes never load the
@@ -58,6 +79,77 @@ const defaultWebClient = (botToken) => {
58
79
  const { WebClient } = requireModule('@slack/web-api');
59
80
  return new WebClient(botToken);
60
81
  };
82
+ /**
83
+ * The default fetcher over a `fetch`. Exported as a factory so the bounded
84
+ * read can be tested against a fake `fetch` with a body that never ends;
85
+ * the adapter uses it over the global one.
86
+ *
87
+ * The body is read a chunk at a time and abandoned the moment it passes
88
+ * `maxBytes`: `arrayBuffer()` would buffer the whole response first, and
89
+ * the size the adapter checked before asking came from Slack's metadata,
90
+ * which an oversized response is under no obligation to match. A
91
+ * `content-length` that already says too much is refused before a byte is
92
+ * read.
93
+ */
94
+ export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signal, maxBytes) => {
95
+ const response = await fetchImpl(url, { headers: { authorization: `Bearer ${botToken}` }, signal });
96
+ const contentType = response.headers.get('content-type');
97
+ const typed = contentType !== null ? { contentType } : {};
98
+ const declared = Number(response.headers.get('content-length'));
99
+ if (Number.isFinite(declared) && declared > maxBytes) {
100
+ await response.body?.cancel();
101
+ return { status: response.status, ...typed, body: Buffer.alloc(0), truncated: true };
102
+ }
103
+ if (response.body === null) {
104
+ return { status: response.status, ...typed, body: Buffer.alloc(0) };
105
+ }
106
+ const reader = response.body.getReader();
107
+ const chunks = [];
108
+ let received = 0;
109
+ try {
110
+ for (;;) {
111
+ const { done, value } = await reader.read();
112
+ if (done) {
113
+ break;
114
+ }
115
+ received += value.byteLength;
116
+ if (received > maxBytes) {
117
+ await reader.cancel();
118
+ return { status: response.status, ...typed, body: Buffer.alloc(0), truncated: true };
119
+ }
120
+ chunks.push(Buffer.from(value));
121
+ }
122
+ }
123
+ finally {
124
+ reader.releaseLock();
125
+ }
126
+ return { status: response.status, ...typed, body: Buffer.concat(chunks) };
127
+ };
128
+ const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
129
+ /**
130
+ * A clock reading as a Slack timestamp, for a file whose upload answered
131
+ * with no share ts. Slack timestamps are epoch seconds with six decimals,
132
+ * so the clock's reading compares with them as long as the two agree to
133
+ * within the gap between one message and the next.
134
+ */
135
+ const slackTs = (ms) => (ms / 1000).toFixed(6);
136
+ /**
137
+ * Where an upload sits in the thread: the ts of the message it became in
138
+ * `channel`, from the shares the upload answers with. Undefined when the
139
+ * answer carries none.
140
+ */
141
+ const uploadPlace = (result, channel) => {
142
+ for (const file of result.files ?? []) {
143
+ for (const scope of [file.shares?.public, file.shares?.private]) {
144
+ for (const share of scope?.[channel] ?? []) {
145
+ if (share.ts !== undefined) {
146
+ return share.ts;
147
+ }
148
+ }
149
+ }
150
+ }
151
+ return undefined;
152
+ };
61
153
  /**
62
154
  * Renders one turn's reply into Slack with the placeholder-then-edit
63
155
  * pattern: post `…` immediately, fold streaming deltas and tool status
@@ -103,17 +195,45 @@ class ReplyRenderer {
103
195
  */
104
196
  generation = 0;
105
197
  finalized = false;
198
+ /** Whether any upload of this turn's landed — a file is something said, placeholder or not. */
199
+ uploaded = false;
200
+ /** Whether any edit of this turn's landed — a streamed line Slack took is something said, whatever the final edit's fate. */
201
+ edited = false;
202
+ /**
203
+ * Where in the thread this turn's words stand: the ts of the lowest
204
+ * message Slack took them in — the placeholder an edit landed in, or an
205
+ * overflow chunk posted — and, separately, where a file of its landed
206
+ * (the share ts the upload answers with, or the clock — see `queueUpload`).
207
+ * What the thread rule orders by: an untagged reply is for whoever's
208
+ * reply sits lowest in the thread, the one a reader sees last, which is
209
+ * not the turn that finished last — a placeholder posted at intake and
210
+ * filled in slowly sits above a reply posted while it was being written.
211
+ */
212
+ textAt;
213
+ uploadedAt;
214
+ /**
215
+ * A turn nobody asked for: the placeholder is not posted at intake but
216
+ * on the first TEXT the turn streams, and a turn that ends having said
217
+ * nothing leaves the thread untouched — no placeholder, no `(no reply)`,
218
+ * no edit. Text and not a tool line, though the spec sketched either: a
219
+ * placeholder posted for a tool the turn ran on the way to deciding it
220
+ * had nothing to add is a message the decision cannot take back.
221
+ */
222
+ lazy;
106
223
  web;
107
224
  channel;
108
225
  threadTs;
109
226
  editIntervalMs;
110
227
  warn;
111
- constructor(web, channel, threadTs, editIntervalMs, warn) {
228
+ now;
229
+ constructor(web, channel, threadTs, editIntervalMs, warn, options = {}) {
112
230
  this.web = web;
113
231
  this.channel = channel;
114
232
  this.threadTs = threadTs;
115
233
  this.editIntervalMs = editIntervalMs;
116
234
  this.warn = warn;
235
+ this.lazy = options.lazy ?? false;
236
+ this.now = options.now ?? Date.now;
117
237
  }
118
238
  /**
119
239
  * The gateway has begun this renderer's turn. Whatever was streamed here
@@ -235,12 +355,27 @@ class ReplyRenderer {
235
355
  // failure instead of an unhandled stream error.
236
356
  .then(async () => {
237
357
  const data = await readFile(filePath);
238
- await this.web.files.uploadV2({
358
+ // Where the file will sit is Slack's to say — the ts of the
359
+ // message it becomes, in the shares the upload answers with. When
360
+ // the answer carries none, the clock stands in, read BEFORE the
361
+ // call: the file is posted somewhere inside it, and a colleague's
362
+ // placeholder posted while the answer was on its way back sits
363
+ // below the file. Read after, the file would be placed below that
364
+ // placeholder and take a thread the colleague holds; read before,
365
+ // the error runs the other way, toward the colleague — the fault
366
+ // an @ fixes.
367
+ const before = slackTs(this.now());
368
+ const result = await this.web.files.uploadV2({
239
369
  channel_id: this.channel,
240
370
  file: data,
241
371
  filename: path.basename(filePath),
242
372
  ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
243
373
  });
374
+ this.uploaded = true;
375
+ const placed = uploadPlace(result, this.channel) ?? before;
376
+ if (this.uploadedAt === undefined || placed > this.uploadedAt) {
377
+ this.uploadedAt = placed;
378
+ }
244
379
  })
245
380
  .catch((error) => this.warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`));
246
381
  }
@@ -261,11 +396,24 @@ class ReplyRenderer {
261
396
  if (this.finalized) {
262
397
  return;
263
398
  }
399
+ // Nothing said yet: a lazy turn has no placeholder to edit, and a
400
+ // tool line alone does not earn one (see `lazy`). Nor does text
401
+ // streamed here before this turn began — a recovery's, routed to
402
+ // the head of the queue — which `beginTurn` drops.
403
+ if (this.lazy && !this.ref && (!this.turnStarted || this.buffer.trim().length === 0)) {
404
+ return;
405
+ }
264
406
  this.lastEditAt = Date.now();
265
- this.queueEdit(messageText(this.currentText()));
407
+ void this.queueEdit(messageText(this.currentText()));
266
408
  }, delay);
267
409
  this.pendingEdit.unref?.();
268
410
  }
411
+ /**
412
+ * Queue one edit of the placeholder. Resolves to whether Slack took it —
413
+ * false when there was no placeholder to edit, the text belonged to a
414
+ * turn handed over meanwhile, or the update was refused — which is what
415
+ * lets `finalize` say whether the reply was ever published.
416
+ */
269
417
  queueEdit(text) {
270
418
  const generation = this.generation;
271
419
  // The handover as it stands when the edit is queued, not when it runs:
@@ -273,21 +421,48 @@ class ReplyRenderer {
273
421
  // edit that then waited for that handover would be a cycle nothing
274
422
  // could break.
275
423
  const handover = this.handover;
276
- this.editChain = this.editChain
424
+ const edit = this.editChain
277
425
  .then(() => handover)
278
- .then(() => {
426
+ .then(async () => {
427
+ if (this.lazy && !this.ref && this.turnStarted && generation === this.generation) {
428
+ // The first text of a turn nobody asked for: this is where its
429
+ // placeholder is posted, and the edit below fills it. A post
430
+ // that fails leaves the turn without one, and its reply is a
431
+ // message of its own when it comes (see `finalize`).
432
+ try {
433
+ await this.open();
434
+ }
435
+ catch (error) {
436
+ this.warn(`could not open a placeholder: ${error instanceof Error ? error.message : String(error)}`);
437
+ }
438
+ }
279
439
  // Read after the handover, not before: the placeholder may have
280
440
  // changed hands while this edit waited its turn — and the text
281
441
  // may belong to the turn that was handed over.
282
442
  const ref = this.ref;
283
- return ref && generation === this.generation
284
- ? this.web.chat.update({ channel: ref.channel, ts: ref.ts, text })
285
- : undefined;
443
+ if (!ref || generation !== this.generation) {
444
+ return false;
445
+ }
446
+ await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text });
447
+ this.edited = true;
448
+ this.placeText(ref.ts);
449
+ return true;
286
450
  })
287
- .then(() => undefined)
288
- .catch((error) => this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`));
451
+ .catch((error) => {
452
+ this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
453
+ return false;
454
+ });
455
+ this.editChain = edit.then(() => undefined);
456
+ return edit;
289
457
  }
290
- /** Replace the placeholder with the final reply, splitting if oversized. */
458
+ /**
459
+ * Replace the placeholder with the final reply, splitting if oversized.
460
+ * Resolves to a `ReplyOutcome`. A reply Slack refused any part of was
461
+ * not said in the thread as written, and nothing downstream may treat it
462
+ * as if it had been: a colleague hearing the whole of a reply the thread
463
+ * saw half of is reasoning from text nobody read. But a file beside a
464
+ * refused sentence is still the last thing said, for the thread rule.
465
+ */
291
466
  async finalize(reply) {
292
467
  await this.handover;
293
468
  this.finalized = true;
@@ -295,31 +470,121 @@ class ReplyRenderer {
295
470
  clearTimeout(this.pendingEdit);
296
471
  this.pendingEdit = undefined;
297
472
  }
473
+ // Let the edits already queued land before deciding anything from
474
+ // `ref`: a lazy turn's first text may be opening its placeholder in
475
+ // that chain right now, and reading `ref` ahead of it would post the
476
+ // reply as a message of its own beside a placeholder that then fills
477
+ // with stale partial text.
478
+ await this.editChain;
479
+ if (this.lazy && reply.trim().length === 0 && (!this.ref || await this.retract())) {
480
+ // Silence, decided: nothing was posted and nothing is — or what was
481
+ // posted has been taken back. The uploads still land: a file the
482
+ // turn produced is not nothing to say, and a turn that posted one
483
+ // has spoken, for the thread rule.
484
+ await this.editChain;
485
+ await this.uploadChain;
486
+ return this.outcome(false, this.uploaded);
487
+ }
298
488
  const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
299
489
  const chunks = messageChunks(text);
300
490
  // No placeholder — a handover could not open a fresh one — and the
301
491
  // reply is a message of its own rather than nothing at all.
302
492
  const rest = this.ref ? chunks.slice(1) : chunks;
303
- if (this.ref) {
304
- this.queueEdit(chunks[0] ?? NO_REPLY_TEXT);
305
- }
493
+ const edited = this.ref ? this.queueEdit(chunks[0] ?? NO_REPLY_TEXT) : Promise.resolve(false);
494
+ let landed = await edited;
495
+ let published = this.ref ? landed : true;
306
496
  await this.editChain;
307
497
  await this.uploadChain;
308
498
  for (const chunk of rest) {
309
499
  try {
310
- await this.web.chat.postMessage({
500
+ const posted = await this.web.chat.postMessage({
311
501
  channel: this.channel,
312
502
  text: chunk,
313
503
  ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
314
504
  });
505
+ landed = true;
506
+ this.placeText(posted.ts);
315
507
  }
316
508
  catch (error) {
509
+ published = false;
317
510
  this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
318
511
  }
319
512
  }
513
+ // A placeholder that opened for a turn nobody asked for and never
514
+ // took a word of it — every edit refused — is a `…` standing alone
515
+ // under the agent's name, the interruption a judged turn exists to
516
+ // avoid, so it is taken back like an abandoned attempt's. One Slack
517
+ // will not let go stands, and a message the thread can see is the
518
+ // last thing said whatever it says.
519
+ let stranded = false;
520
+ if (this.lazy && this.ref !== undefined && !landed && !this.edited) {
521
+ stranded = !(await this.retract());
522
+ if (stranded) {
523
+ this.placeText(this.ref?.ts);
524
+ }
525
+ }
526
+ return this.outcome(published, landed || this.edited || this.uploaded || stranded);
527
+ }
528
+ placeText(ts) {
529
+ if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
530
+ this.textAt = ts;
531
+ }
532
+ }
533
+ outcome(published, spoke) {
534
+ const spokeAt = [this.textAt, this.uploadedAt]
535
+ .filter((ts) => ts !== undefined)
536
+ .sort()
537
+ .at(-1);
538
+ return { published, spoke, ...(spoke && spokeAt !== undefined ? { spokeAt } : {}) };
539
+ }
540
+ /**
541
+ * Take back the placeholder a turn nobody asked for opened and then had
542
+ * nothing to put in: the line that opened it was an attempt the provider
543
+ * abandoned (`reset` — a fallback after a mid-stream failure), and the
544
+ * attempt that stood answered with nothing. Editing that placeholder to
545
+ * `(no reply)` would leave exactly the interruption a silent turn exists
546
+ * to avoid, and would count as speaking. Resolves to whether Slack let
547
+ * the message go: a delete refused leaves the abandoned line standing,
548
+ * and the turn finishes the ordinary way, since a message the thread
549
+ * can still see is the last thing said whatever it says.
550
+ */
551
+ async retract() {
552
+ const ref = this.ref;
553
+ if (!ref) {
554
+ return true;
555
+ }
556
+ try {
557
+ await this.web.chat.delete({ channel: ref.channel, ts: ref.ts });
558
+ }
559
+ catch (error) {
560
+ this.warn(`chat.delete failed: ${error instanceof Error ? error.message : String(error)}`);
561
+ return false;
562
+ }
563
+ this.ref = undefined;
564
+ // The line the placeholder took is gone with it: nothing of this
565
+ // turn's stands in the thread unless a file does.
566
+ this.edited = false;
567
+ this.textAt = undefined;
568
+ return true;
320
569
  }
321
570
  async fail(message) {
322
- await this.finalize(`Something went wrong: ${message}`);
571
+ // The same wait `finalize` takes, for the same reason: a lazy turn's
572
+ // first text may be opening its placeholder right now, and a failure
573
+ // read as "before saying anything" would leave that placeholder saying
574
+ // `(no reply)` where the failure belongs.
575
+ await this.editChain;
576
+ // "Before saying anything" is no placeholder AND nothing buffered: a
577
+ // first text whose zero-delay edit has not fired yet has still been
578
+ // said, and its failure is reported — as a message of its own, since
579
+ // there is no placeholder to edit — rather than swallowed with it.
580
+ if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
581
+ // A turn nobody asked for that failed before saying anything: the
582
+ // failure is in the daemon log, and an error note would be the
583
+ // interruption the turn existed to avoid.
584
+ this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
585
+ return this.finalize('');
586
+ }
587
+ return this.finalize(`Something went wrong: ${message}`);
323
588
  }
324
589
  /**
325
590
  * Claim the placeholder for a turn that finished ahead of this one — the
@@ -357,7 +622,7 @@ class ReplyRenderer {
357
622
  return {
358
623
  take: async (chunks, between) => {
359
624
  if (spent) {
360
- return false;
625
+ return 'untaken';
361
626
  }
362
627
  try {
363
628
  await previous;
@@ -380,7 +645,7 @@ class ReplyRenderer {
380
645
  const [first, ...rest] = chunks;
381
646
  const given = this.ref;
382
647
  if (first === undefined || this.finalized || !given) {
383
- return false;
648
+ return 'untaken';
384
649
  }
385
650
  await drained;
386
651
  try {
@@ -390,8 +655,9 @@ class ReplyRenderer {
390
655
  // Not handed over: the placeholder is still this turn's, and the
391
656
  // caller posts the reply as messages of its own.
392
657
  this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
393
- return false;
658
+ return 'untaken';
394
659
  }
660
+ let complete = true;
395
661
  // The placeholder is the earlier turn's now, and the reference goes
396
662
  // before the reopen, so a reopen that fails cannot leave this turn
397
663
  // writing over the reply just handed over. (What was streamed into
@@ -407,6 +673,7 @@ class ReplyRenderer {
407
673
  });
408
674
  }
409
675
  catch (error) {
676
+ complete = false;
410
677
  this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
411
678
  }
412
679
  }
@@ -425,32 +692,13 @@ class ReplyRenderer {
425
692
  // message of its own when it comes (see `finalize`).
426
693
  this.warn(`could not reopen a placeholder: ${error instanceof Error ? error.message : String(error)}`);
427
694
  }
428
- return true;
695
+ return complete ? 'complete' : 'partial';
429
696
  }
430
697
  }
431
- // The adapter-level convention for file-bearing tool results: an ok result
432
- // whose object output carries `file: string` or `files: string[]` refers to
433
- // local paths the channel should deliver as attachments.
434
- const collectFilePaths = (result) => {
435
- const output = result.output;
436
- if (typeof output !== 'object' || output === null || Array.isArray(output)) {
437
- return [];
438
- }
439
- const paths = [];
440
- const single = output.file;
441
- if (typeof single === 'string' && single.length > 0) {
442
- paths.push(single);
443
- }
444
- const many = output.files;
445
- if (Array.isArray(many)) {
446
- for (const entry of many) {
447
- if (typeof entry === 'string' && entry.length > 0) {
448
- paths.push(entry);
449
- }
450
- }
451
- }
452
- return paths;
453
- };
698
+ // The file-bearing result convention is the kernel's (`filePathsOf`), so
699
+ // the gateway counts a turn that produced a file as having spoken by the
700
+ // same rule this adapter uploads it by.
701
+ const collectFilePaths = (result) => filePathsOf(result);
454
702
  // A hard cut must never land between the halves of a UTF-16 surrogate
455
703
  // pair — an emoji on the boundary would reach Slack as two replacement
456
704
  // characters. Backs the index off the pair's high half when it would.
@@ -688,6 +936,57 @@ const convertHeadings = (text, code) => {
688
936
  return startsInside(closer, within) ? heading : `*${heading}*`;
689
937
  });
690
938
  };
939
+ /**
940
+ * A stand-in for one code segment while the prose around it is rewritten.
941
+ *
942
+ * Emphasis was converted one prose fragment at a time until this existed,
943
+ * and a fragment is not a run: inline code cuts one in two, so the `**`
944
+ * opening a bold run and the `**` closing it land in different fragments
945
+ * whenever a snippet sits between them, and neither half ever finds its
946
+ * partner. ``**the `fs.read` tool**`` reached a reader as four literal
947
+ * asterisks around a snippet, and so did every link, italic and
948
+ * strikethrough written the same way. It is the defect #142 found in
949
+ * headings, in the rule right next to it, and it has the same answer:
950
+ * rewrite the whole text, with code held out of the rewrite rather than
951
+ * cut out of the text.
952
+ *
953
+ * A mask names its segment rather than merely standing in for one, because
954
+ * a rewrite is allowed to move it: the link rule writes its two captures
955
+ * back in the other order, so `[use `a`](https://host/`b`)` hands the URL's
956
+ * snippet to the label and the label's to the URL, and a snippet that
957
+ * arrives somewhere else is the same defect as one that is rewritten. Named,
958
+ * it comes back to the place holding its own name. No rewrite can damage
959
+ * the name itself: none of the delimiters they look for appear in it, and
960
+ * every rule copies what it matched through to its replacement.
961
+ *
962
+ * A mask also carries whether its segment holds whitespace, because three
963
+ * rules refuse to cross it — a destination is `[^()\s]+`, a label and an
964
+ * emphasis run stop at a newline — and a name alone has none to stop them.
965
+ * `[label](https://host/`a b`)` is not a destination and was never meant to
966
+ * convert; masked shapelessly it did, and came back rearranged into a link
967
+ * Slack cannot render. Only whitespace is carried, and only ever so a rule
968
+ * refuses: a `*` in a snippet must still not act like one. Both ends of a
969
+ * mask stay the mask character, so a run that has to hug its content still
970
+ * hugs it — a segment opens and closes with a backtick, so that is not a
971
+ * lie about its ends.
972
+ *
973
+ * The character is chosen against the text rather than fixed, because a
974
+ * reply that already contained it would have a snippet spliced in where
975
+ * the writer's own character stood — the one thing this conversion must
976
+ * never do. A reply holding all 6400 of the private-use characters has no
977
+ * mask to be given and is left in Markdown instead: prose that reads as it
978
+ * was written is a cosmetic loss, and a snippet spliced somewhere else is
979
+ * not.
980
+ */
981
+ const maskFor = (text) => {
982
+ for (let point = 0xe000; point <= 0xf8ff; point += 1) {
983
+ const candidate = String.fromCharCode(point);
984
+ if (!text.includes(candidate)) {
985
+ return candidate;
986
+ }
987
+ }
988
+ return undefined;
989
+ };
691
990
  /**
692
991
  * Markdown as a model writes it, in the spelling Slack actually renders.
693
992
  *
@@ -703,28 +1002,49 @@ const convertHeadings = (text, code) => {
703
1002
  * add ways to be wrong and fix nothing.
704
1003
  */
705
1004
  const toSlackMrkdwn = (text) => {
706
- // Where each code span ends up in the converted text, which is not where
707
- // it started: the prose before it changes length as it is rewritten.
708
- const code = [];
1005
+ const mask = maskFor(text);
1006
+ if (mask === undefined) {
1007
+ return text;
1008
+ }
709
1009
  const segments = proseAndCode(text);
710
- let converted = '';
711
- segments.forEach((segment, index) => {
1010
+ const code = [];
1011
+ let masked = '';
1012
+ for (const [index, segment] of segments.entries()) {
712
1013
  if (index % 2 === 1) {
713
- // A code segment that ends the text is the unterminated fence: every
714
- // closed run is followed by a prose slice, even an empty one. It has
715
- // no end to be past, so nothing may be appended after it either —
716
- // recording the end as it looks would say a marker placed at the
717
- // text's end was safely outside.
718
- const ends = index === segments.length - 1
719
- ? Number.POSITIVE_INFINITY
720
- : converted.length + segment.length;
721
- code.push([converted.length, ends]);
722
- converted += segment;
723
- return;
1014
+ const shape = /\n/.test(segment) ? '\n' : /\s/.test(segment) ? ' ' : '';
1015
+ masked += `${mask}${code.length}${shape}${mask}`;
1016
+ code.push(segment);
1017
+ continue;
724
1018
  }
725
- converted += convertInline(segment);
726
- });
727
- return convertHeadings(converted, code);
1019
+ masked += segment;
1020
+ }
1021
+ // A code segment that ends the text is the unterminated fence: every
1022
+ // closed run is followed by a prose slice, even an empty one, so only an
1023
+ // unclosed one leaves the segments ending on a code index. Named, not
1024
+ // last: a rewrite may have moved it, and it is the segment that ran to
1025
+ // the text's end when it was cut, wherever it now sits.
1026
+ const unterminated = segments.length % 2 === 0 ? code.length - 1 : -1;
1027
+ // Where each code segment ends up in the converted text, which is not
1028
+ // where it started: the prose around it changes length as it is
1029
+ // rewritten, and the masks are the only record of where it went. Split
1030
+ // on the names, so the pieces alternate prose and the name of the
1031
+ // segment that follows it.
1032
+ const pieces = convertInline(masked).split(new RegExp(`${mask}(\\d+)[ \\n]?${mask}`));
1033
+ const spans = [];
1034
+ let converted = pieces[0] ?? '';
1035
+ for (let piece = 1; piece < pieces.length; piece += 2) {
1036
+ const named = Number(pieces[piece]);
1037
+ const segment = code[named] ?? '';
1038
+ // The unterminated fence has no end to be past, so nothing may be
1039
+ // appended after it either — recording the end as it looks would say a
1040
+ // marker placed at the text's end was safely outside.
1041
+ const ends = named === unterminated
1042
+ ? Number.POSITIVE_INFINITY
1043
+ : converted.length + segment.length;
1044
+ spans.push([converted.length, ends]);
1045
+ converted += `${segment}${pieces[piece + 1] ?? ''}`;
1046
+ }
1047
+ return convertHeadings(converted, spans);
728
1048
  };
729
1049
  const splitForSlack = (text) => {
730
1050
  if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
@@ -775,23 +1095,17 @@ const APPROVAL_ACTIONS = {
775
1095
  * agent does unattended, and an approver should see the scope they granted
776
1096
  * in the record of granting it.
777
1097
  *
778
- * It states the **floor**, because that is the only thing true in every
779
- * case. A call judged by a scope — a shell command, a browser site —
780
- * persists that scope in the agent's whitelist and normally keeps it
781
- * across restarts, but not when the daemon cannot write the file (a
782
- * hand-edit that will not parse) or was built without one, and by then
783
- * this message has already been sent. Nothing on the bus distinguishes
784
- * any of it: the policy returns a boolean, and `tool.approval-resolved`
785
- * carries the answer that was submitted, not how long the grant lasts.
786
- *
787
- * So it says what is always so and no more. Claiming only the session
788
- * lifetime, as this line first did, tells an approver a durable grant
789
- * revokes itself; claiming the restart, as it then did, promises one the
790
- * daemon may have failed to save. The daemon's log has the exact line.
1098
+ * What it lasts rode in on the request as `always` (see `AlwaysMeans`),
1099
+ * so the line names the grant that was made rather than hedging between
1100
+ * lifetimes — which this message once had to, when nothing on the bus said
1101
+ * whether an "always" was the session's or the file's. One thing the
1102
+ * request still cannot promise is the disk write: the daemon does not
1103
+ * write over a whitelist it could not read, and by then this message has
1104
+ * been sent. The daemon's log has that line; `stratus grants <agent>` has
1105
+ * the list.
791
1106
  */
792
1107
  const OUTCOME_TEXT = {
793
1108
  'decided:once': 'Allowed once',
794
- 'decided:always': 'Allowed and remembered — for this session at least',
795
1109
  'decided:deny': 'Denied',
796
1110
  'timeout:deny': 'Expired without an answer — denied',
797
1111
  // Covers both endings that reach here: the turn was aborted, and the
@@ -799,6 +1113,49 @@ const OUTCOME_TEXT = {
799
1113
  'cancelled:deny': 'Cancelled before anyone answered — denied',
800
1114
  'undeliverable:deny': 'Could not be put to an approver — denied',
801
1115
  };
1116
+ /**
1117
+ * What **Always allow** did, by the grant the request said it would make.
1118
+ * A tool grant names the agent because that is its scope — not this
1119
+ * thread, not the session — and says how to take it back, since a grant
1120
+ * an approver cannot find later is a ratchet.
1121
+ */
1122
+ const alwaysOutcome = (always, agentName, agentId) => {
1123
+ switch (always) {
1124
+ case 'scope':
1125
+ return 'Allowed, and this command\'s scope now runs without asking — until revoked';
1126
+ case 'origin':
1127
+ return 'Allowed, and this site is now acted on without asking — until revoked';
1128
+ case 'tool':
1129
+ return `Allowed, and granted to ${escapeSlackText(agentName)} until revoked (stratus grants ${escapeSlackText(agentId)})`;
1130
+ case 'session':
1131
+ return 'Allowed for the rest of this session';
1132
+ default:
1133
+ // An `always` submitted through `POST /approvals` against a request
1134
+ // this daemon did not label — an older daemon's event, say. The
1135
+ // request did not say, so neither does this.
1136
+ return 'Allowed and remembered — for this session at least';
1137
+ }
1138
+ };
1139
+ /**
1140
+ * What **Always allow** would do, said beside the button. The lifetime is
1141
+ * the half of the question the button widens, and it differs by tool: a
1142
+ * standing grant to the agent is a larger thing to hand out than a session,
1143
+ * and an approver should know which they are clicking.
1144
+ */
1145
+ const alwaysOffer = (always, agentName) => {
1146
+ switch (always) {
1147
+ case 'tool':
1148
+ return `*Always allow* grants this tool to ${escapeSlackText(agentName)} until an operator revokes it.`;
1149
+ case 'session':
1150
+ return '*Always allow* stops this tool asking again for the rest of this session.';
1151
+ case 'scope':
1152
+ return '*Always allow* remembers this command\'s scope for this agent, until revoked.';
1153
+ case 'origin':
1154
+ return '*Always allow* remembers this site for this agent, until revoked.';
1155
+ default:
1156
+ return undefined;
1157
+ }
1158
+ };
802
1159
  /** Comfortably inside Slack's 3000-character section limit. */
803
1160
  const APPROVAL_INPUT_LIMIT = 1400;
804
1161
  /** One line in a notification; the blocks carry the readable version. */
@@ -824,10 +1181,11 @@ const MAX_LISTED_ATTACHMENTS = 5;
824
1181
  * What a message's attachments are called, for a turn that cannot open
825
1182
  * them. Without this the agent is handed "here's the log" and no log, and
826
1183
  * answers as though it had read something — the note is what lets it say
827
- * the true thing instead.
1184
+ * the true thing instead. Images the model was actually shown are not
1185
+ * listed here: they travel on the dispatch, name included.
828
1186
  */
829
1187
  const attachmentNote = (files) => {
830
- if (!files || files.length === 0) {
1188
+ if (files.length === 0) {
831
1189
  return '';
832
1190
  }
833
1191
  const names = files.slice(0, MAX_LISTED_ATTACHMENTS).map((file) => file.name ?? file.title ?? 'an unnamed file');
@@ -835,6 +1193,150 @@ const attachmentNote = (files) => {
835
1193
  const listed = rest > 0 ? `${names.join(', ')}, and ${rest} more` : names.join(', ');
836
1194
  return `\n[Attached: ${listed}. Attachment contents cannot be read here — say so rather than guessing at them.]`;
837
1195
  };
1196
+ /** What Slack calls a file, in a warning about it. */
1197
+ const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed file';
1198
+ /**
1199
+ * A message's attachments, sorted into the images the model will be shown
1200
+ * and everything else. An image is fetched only when Slack's own metadata
1201
+ * says it is one the API accepts and small enough to send; that keeps a
1202
+ * 40 MB PNG from being downloaded just to be dropped, and a PDF from being
1203
+ * fetched at all. What comes back is checked as well: without `files:read`
1204
+ * Slack serves a sign-in page with a 200, and sending that to the model as
1205
+ * an image would fail the turn with an error naming nothing the person can
1206
+ * fix — the warning here names the scope instead, and the file falls back
1207
+ * to the by-name note like any other attachment the turn cannot open.
1208
+ */
1209
+ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn) => {
1210
+ // Decided from the last file back, the way the replay window is spent:
1211
+ // when a message's images do not all fit, the ones kept are the ones it
1212
+ // listed last, so intake and replay agree about which survive. Delivered
1213
+ // in the message's own order, because that is how the person sees them.
1214
+ const kept = new Map();
1215
+ const dropped = new Set();
1216
+ // The message's one deadline, shared by every download in it.
1217
+ const deadline = AbortSignal.timeout(timeoutMs);
1218
+ // Decoded bytes accepted so far. Checked against Slack's reported size
1219
+ // before a download and the real length after it, because the message
1220
+ // as a whole has a budget the per-image cap alone cannot keep.
1221
+ let total = 0;
1222
+ // Once an image that fits on its own does not fit what is left of the
1223
+ // message's budget, nothing listed before it is taken either: the window
1224
+ // is contiguous, as the replay window is, and a smaller older image must
1225
+ // not slip in past a larger newer one that was left out.
1226
+ let windowClosed = false;
1227
+ // Why an image is being left out, or nothing when it fits. One place for
1228
+ // both limits so the warning and the fallback cannot disagree.
1229
+ const overLimit = (label, bytes) => {
1230
+ if (bytes > IMAGE_ATTACHMENT_MAX_BYTES) {
1231
+ return `slack: ${label} is ${bytes} bytes, over the ${IMAGE_ATTACHMENT_MAX_BYTES}-byte limit an image can be sent to the model at; the turn is told it cannot be read.`;
1232
+ }
1233
+ if (total + bytes > IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES) {
1234
+ windowClosed = true;
1235
+ return `slack: ${label} would take this message's images past the ${IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES} bytes one request can carry; the turn is told it cannot be read. Send it in a message of its own.`;
1236
+ }
1237
+ return undefined;
1238
+ };
1239
+ for (let position = files.length - 1; position >= 0; position -= 1) {
1240
+ const file = files[position];
1241
+ const url = file.url_private_download ?? file.url_private;
1242
+ if (!isImageAttachmentMediaType(file.mimetype) || url === undefined) {
1243
+ dropped.add(position);
1244
+ continue;
1245
+ }
1246
+ if (windowClosed) {
1247
+ warn(`slack: ${fileLabel(file)} was not taken: an image listed after it already filled what this message's images can carry; the turn is told it cannot be read.`);
1248
+ dropped.add(position);
1249
+ continue;
1250
+ }
1251
+ const tooBig = file.size === undefined ? undefined : overLimit(fileLabel(file), file.size);
1252
+ if (tooBig !== undefined) {
1253
+ warn(tooBig);
1254
+ dropped.add(position);
1255
+ continue;
1256
+ }
1257
+ // What this image may weigh: the per-image cap, or what is left of the
1258
+ // message's budget if that is less. Handed to the fetcher so an
1259
+ // oversized response is abandoned mid-body rather than buffered whole.
1260
+ const maxBytes = Math.min(IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES - total);
1261
+ if (deadline.aborted) {
1262
+ warn(`slack: ${fileLabel(file)} was not downloaded: this message's downloads had already taken longer than ${timeoutMs}ms. The turn is told it cannot be read.`);
1263
+ dropped.add(position);
1264
+ continue;
1265
+ }
1266
+ let download;
1267
+ try {
1268
+ download = await fetchFile(url, botToken, deadline, maxBytes);
1269
+ }
1270
+ catch (error) {
1271
+ const reason = error instanceof Error && error.name === 'TimeoutError'
1272
+ ? `this message's downloads took longer than ${timeoutMs}ms`
1273
+ : (error instanceof Error ? error.message : String(error));
1274
+ warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
1275
+ dropped.add(position);
1276
+ continue;
1277
+ }
1278
+ // Refused on what Slack sends when it will not serve the file — a
1279
+ // non-200, or a 200 whose body is a page — rather than on the label of
1280
+ // what it does serve: a download URL may be typed as the image or as
1281
+ // a plain octet stream, and either is the bytes Slack said it was.
1282
+ const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
1283
+ if (download.status !== 200 || contentType.startsWith('text/')) {
1284
+ warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than an image. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
1285
+ dropped.add(position);
1286
+ continue;
1287
+ }
1288
+ if (download.truncated === true) {
1289
+ // Cut off at what was left of the message's budget, rather than at
1290
+ // the per-image cap: the same overflow the size check closes the
1291
+ // window on, learned from the bytes instead of the metadata.
1292
+ if (maxBytes < IMAGE_ATTACHMENT_MAX_BYTES) {
1293
+ windowClosed = true;
1294
+ }
1295
+ warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes this message could still take for an image; the download was abandoned and the turn is told it cannot be read.`);
1296
+ dropped.add(position);
1297
+ continue;
1298
+ }
1299
+ const stillTooBig = overLimit(fileLabel(file), download.body.length);
1300
+ if (stillTooBig !== undefined) {
1301
+ warn(stillTooBig);
1302
+ dropped.add(position);
1303
+ continue;
1304
+ }
1305
+ // Read from the bytes, not from Slack's metadata: an image the model
1306
+ // API refuses fails the turn it arrives on and, once stored, every turn
1307
+ // after — so what is stored has to be what the API takes.
1308
+ const dimensions = imageDimensions(download.body, file.mimetype);
1309
+ if (dimensions === undefined) {
1310
+ warn(`slack: ${fileLabel(file)} is not a complete ${file.mimetype} — its header or trailer is missing; the turn is told it cannot be read.`);
1311
+ dropped.add(position);
1312
+ continue;
1313
+ }
1314
+ if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
1315
+ warn(`slack: ${fileLabel(file)} is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION}-pixel side the model can take; the turn is told it cannot be read.`);
1316
+ dropped.add(position);
1317
+ continue;
1318
+ }
1319
+ total += download.body.length;
1320
+ const name = file.name ?? file.title;
1321
+ kept.set(position, {
1322
+ mediaType: file.mimetype,
1323
+ data: download.body.toString('base64'),
1324
+ ...(name !== undefined ? { name } : {}),
1325
+ });
1326
+ }
1327
+ const images = [];
1328
+ const unread = [];
1329
+ files.forEach((file, position) => {
1330
+ const image = kept.get(position);
1331
+ if (image !== undefined) {
1332
+ images.push(image);
1333
+ }
1334
+ else if (dropped.has(position)) {
1335
+ unread.push(file);
1336
+ }
1337
+ });
1338
+ return { images, unread };
1339
+ };
838
1340
  /**
839
1341
  * Whether `text` addresses a bot user by name. Slack writes a mention as
840
1342
  * `<@U123>` markup whoever typed it, so this is the same question for an
@@ -877,8 +1379,9 @@ const renderInvocation = (input) => {
877
1379
  truncated,
878
1380
  };
879
1381
  };
880
- const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot) => {
1382
+ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot, always) => {
881
1383
  const invocation = renderInvocation(input);
1384
+ const offer = oneShot ? undefined : alwaysOffer(always, agentName);
882
1385
  return [
883
1386
  {
884
1387
  type: 'section',
@@ -905,7 +1408,8 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
905
1408
  elements: [{ type: 'mrkdwn', text: ':warning: Arguments shown are truncated — the full call is longer than this.' }],
906
1409
  }]
907
1410
  : []),
908
- // Why there is no second button, when there is no second button.
1411
+ // Why there is no second button, when there is no second button — and
1412
+ // what the second button does, when there is one.
909
1413
  ...(oneShot
910
1414
  ? [{
911
1415
  type: 'context',
@@ -914,7 +1418,9 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
914
1418
  text: ':lock: An approval covers this call only — nothing about it is remembered.',
915
1419
  }],
916
1420
  }]
917
- : []),
1421
+ : offer !== undefined
1422
+ ? [{ type: 'context', elements: [{ type: 'mrkdwn', text: offer }] }]
1423
+ : []),
918
1424
  {
919
1425
  type: 'actions',
920
1426
  // The request id rides on every button rather than in the message
@@ -962,6 +1468,9 @@ export const createSlackChannelAdapter = (options) => {
962
1468
  const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
963
1469
  const createSocket = options.createSocketClient ?? defaultSocketClient;
964
1470
  const createWeb = options.createWebClient ?? defaultWebClient;
1471
+ const fetchFile = options.fetchFile ?? defaultFetchFile;
1472
+ const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
1473
+ const now = options.now ?? Date.now;
965
1474
  const connections = [];
966
1475
  // Every agent this adapter was asked to carry, connected or not — the
967
1476
  // difference between "mine and broken" and "not mine".
@@ -1145,6 +1654,199 @@ export const createSlackChannelAdapter = (options) => {
1145
1654
  return result.trim();
1146
1655
  };
1147
1656
  const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
1657
+ /**
1658
+ * How an agent listens, from its soul — read per message, so a roster
1659
+ * reload takes effect on the next one. `thread` when the soul says
1660
+ * nothing, which is every soul written before there was a choice.
1661
+ */
1662
+ const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
1663
+ /**
1664
+ * Whether an agent that judges is still paying attention to a thread:
1665
+ * within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
1666
+ * answered a message that addressed it, both read from the session
1667
+ * rather than remembered here, so a restart forgets nothing. Anchored
1668
+ * to an addressed answer and not to any reply: a reply the agent chose
1669
+ * to give on a turn nobody asked for does not re-arm it, or a talkative
1670
+ * judge would keep itself attentive for good and cost a call per message
1671
+ * — the self-extension the spec declined. An agent never addressed in
1672
+ * the thread is not attentive: it was mentioned and its first turn has
1673
+ * not answered yet, or answered with nothing. Measured against the
1674
+ * message's own timestamp, not the clock, so a delivery that ran late is
1675
+ * judged as of when it was said. Bounded below as well as above: a
1676
+ * message said before the answer the window starts from is outside it
1677
+ * — Slack redelivers events a restarted daemon has no memory of, and a
1678
+ * negative age would otherwise pass the minutes bound however old the
1679
+ * message, on a turn the agent's answer already came after.
1680
+ */
1681
+ const attentive = (routing, messageTs, pending) => {
1682
+ if (routing.lastAnsweredAt === undefined) {
1683
+ return false;
1684
+ }
1685
+ const answeredAt = Date.parse(routing.lastAnsweredAt);
1686
+ const saidAt = Number(messageTs) * 1000;
1687
+ if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
1688
+ return false;
1689
+ }
1690
+ return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
1691
+ };
1692
+ /**
1693
+ * Messages this adapter has dispatched to a session that the session
1694
+ * does not hold yet. The store counts what it holds, and a burst of
1695
+ * messages typed inside one second would each read the same count and
1696
+ * each be judged — the bound the window exists to give, blown by exactly
1697
+ * the thread that needs it. The renderer queue already knows: a renderer
1698
+ * is queued at dispatch and its turn starts (`turnStarted`) only once
1699
+ * the runner has saved the message — the same moment the store's count
1700
+ * takes it over — so the two never count one message twice.
1701
+ */
1702
+ const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
1703
+ + (pendingObserves.get(sessionId) ?? 0);
1704
+ /**
1705
+ * Overhears placed on the gateway's chain and not yet written, per
1706
+ * session: the other kind of message the store does not hold yet. An
1707
+ * observe queued behind a running turn is heard only when the turn ends,
1708
+ * and a burst of messages to a colleague would otherwise count toward
1709
+ * nothing until then. Counted from placement to settlement.
1710
+ */
1711
+ const pendingObserves = new Map();
1712
+ const placeObserve = (sessionId, observed) => {
1713
+ pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
1714
+ const release = () => {
1715
+ const left = (pendingObserves.get(sessionId) ?? 1) - 1;
1716
+ if (left > 0) {
1717
+ pendingObserves.set(sessionId, left);
1718
+ }
1719
+ else {
1720
+ pendingObserves.delete(sessionId);
1721
+ }
1722
+ };
1723
+ void observed.then(release, release);
1724
+ return observed;
1725
+ };
1726
+ /**
1727
+ * Run `work` after everything already queued for this session's intake,
1728
+ * and hold the next link until it settles. The link is claimed
1729
+ * synchronously, so a message's place is fixed by when the adapter
1730
+ * learned of it and not by how long its lookups take — see
1731
+ * `handleInbound`, whose ordering this is.
1732
+ */
1733
+ const onIntakeChain = (sessionId, work) => {
1734
+ const previous = intakeChains.get(sessionId) ?? Promise.resolve();
1735
+ const link = previous.then(work);
1736
+ const settled = link.then(() => undefined, () => undefined);
1737
+ intakeChains.set(sessionId, settled);
1738
+ void settled.then(() => {
1739
+ if (intakeChains.get(sessionId) === settled) {
1740
+ intakeChains.delete(sessionId);
1741
+ }
1742
+ });
1743
+ return link;
1744
+ };
1745
+ /**
1746
+ * What one agent REPLIED in a thread, heard by the thread's other agents.
1747
+ *
1748
+ * The other half of overhearing. What people say reaches an agent through
1749
+ * its socket (`admit`), but a colleague's reply is a bot message the
1750
+ * socket ignores on purpose, and its final text reaches Slack as a stream
1751
+ * of edits rather than one event — so it is not an inbound path at all.
1752
+ * The adapter that rendered the reply is the one thing that knows its
1753
+ * final text, and it observes that into the session every other agent
1754
+ * holds for the same thread, with no Slack event involved. Without this
1755
+ * an agent that followed a thread knew the questions its colleague was
1756
+ * asked and not the answers.
1757
+ *
1758
+ * Each hearer takes it on its own intake chain, claimed the moment the
1759
+ * reply is final: a message typed while the reply was still streaming
1760
+ * is already in that chain and is heard first, and one typed after the
1761
+ * final edit cannot overtake it. The observe itself is placed there and
1762
+ * not awaited inside the link — returned in a wrapper, since a promise
1763
+ * returned from the link is one the chain adopts — for the reason
1764
+ * `handleInbound` gives: a hearer with a long turn running must not hold
1765
+ * its next message's place hostage to that turn.
1766
+ *
1767
+ * Only once it is SAID, in full. The reservation is taken when the reply
1768
+ * is final, but the observe waits, inside the link, on `published` —
1769
+ * whether Slack took every edit and post of it — and a reply Slack
1770
+ * refused any part of is heard by nobody, since the thread did not see
1771
+ * it as written and the hearer has no way to know which part. That holds
1772
+ * the hearer's chain for the length of the final edit, which is the
1773
+ * same network round trip a person's message holds it for.
1774
+ *
1775
+ * Two boundaries decide who hears it, and neither is "same workspace".
1776
+ * Whether the hearer is in the THREAD is the gateway's answer on the
1777
+ * session's chain, as for every overhear: an agent never invited here
1778
+ * has no session under the key. Whether it is still in the CHANNEL is
1779
+ * Slack's, asked per reply: a message a person types reaches an agent
1780
+ * only through its own socket, so an app removed from a private channel
1781
+ * simply stops hearing — but a reply is forwarded by this process, and
1782
+ * would keep reaching an agent the operator took out of the room. That
1783
+ * is the one path where membership has to be checked rather than
1784
+ * assumed, and a lookup that fails leaves the agent not hearing, the
1785
+ * direction a boundary should fail in.
1786
+ *
1787
+ * Sent under the speaker's own label rather than `user`: an agent's
1788
+ * reply is at most its own word, and carries whatever its session has
1789
+ * been exposed to — a restatement of a stranger's text is still the
1790
+ * stranger's, which is exactly what `sessionWriteTrust` says.
1791
+ *
1792
+ * Only replies: a turn that said nothing posts `(no reply)`, which is
1793
+ * nothing to hear, and a failed turn's error note is the failure's, not
1794
+ * the agent's.
1795
+ */
1796
+ const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
1797
+ const gateway = gatewayRef;
1798
+ if (!gateway?.observe || reply.trim().length === 0) {
1799
+ return;
1800
+ }
1801
+ const team = speaker.teamId;
1802
+ const metadata = {
1803
+ channel: 'slack',
1804
+ team,
1805
+ slackChannel: channel,
1806
+ slackUser: speaker.botUserId,
1807
+ slackThread: thread,
1808
+ [SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(spoken),
1809
+ };
1810
+ // In the reply's own workspace only, because a session key carries a
1811
+ // team — the same reason `resolveFollowUpWinner` stays in-workspace.
1812
+ const hearers = connections.filter((candidate) => candidate.teamId === team && candidate.config.agentId !== speaker.config.agentId);
1813
+ await Promise.all(hearers.map(async (hearer) => {
1814
+ const sessionId = channelSessionKey({
1815
+ channel: 'slack',
1816
+ agentId: hearer.config.agentId,
1817
+ team,
1818
+ conversation: channel,
1819
+ thread,
1820
+ });
1821
+ try {
1822
+ const { observed } = await onIntakeChain(sessionId, async () => {
1823
+ if (!(await published)) {
1824
+ return { observed: undefined };
1825
+ }
1826
+ let member;
1827
+ try {
1828
+ member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
1829
+ }
1830
+ catch (error) {
1831
+ warn(`slack: could not tell whether ${hearer.config.agentId} is still in ${channel}, so it does not hear ${speaker.config.agentId}'s reply there: ${error instanceof Error ? error.message : String(error)}`);
1832
+ }
1833
+ if (member !== true) {
1834
+ return { observed: undefined };
1835
+ }
1836
+ // Named the way a person's message names its speaker: whoever
1837
+ // spoke, by display name. A bot user's is the app's own, set by
1838
+ // whoever installed it.
1839
+ const author = await displayNameFor(hearer, speaker.botUserId);
1840
+ const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${reply}`, metadata });
1841
+ return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
1842
+ });
1843
+ await observed;
1844
+ }
1845
+ catch (error) {
1846
+ warn(`slack: ${hearer.config.agentId} could not overhear ${speaker.config.agentId}'s reply in ${channel}: ${error instanceof Error ? error.message : String(error)}`);
1847
+ }
1848
+ }));
1849
+ };
1148
1850
  /**
1149
1851
  * The addressable outbound seam — the channel contract's first real
1150
1852
  * `OutboundConnection` implementation. Validate-then-hand-over: every
@@ -1293,7 +1995,7 @@ export const createSlackChannelAdapter = (options) => {
1293
1995
  + `${event.origin ? ` on ${event.origin}` : ''}`
1294
1996
  + `${invocationSummary ? `: ${invocationSummary}` : ''}.`),
1295
1997
  ...(thread ? { thread_ts: thread } : {}),
1296
- blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true),
1998
+ blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true, event.always),
1297
1999
  });
1298
2000
  post = posted.ts
1299
2001
  ? {
@@ -1305,6 +2007,8 @@ export const createSlackChannelAdapter = (options) => {
1305
2007
  toolName: event.call.toolName,
1306
2008
  risk: event.risk,
1307
2009
  oneShot: event.oneShot === true,
2010
+ ...(event.always !== undefined ? { always: event.always } : {}),
2011
+ agentId: event.agentId,
1308
2012
  approvers,
1309
2013
  }
1310
2014
  : undefined;
@@ -1409,7 +2113,9 @@ export const createSlackChannelAdapter = (options) => {
1409
2113
  ? post.risk === 'dangerous'
1410
2114
  ? 'Allowed once — a dangerous tool is never remembered'
1411
2115
  : 'Allowed once — nothing about this call could be remembered'
1412
- : OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
2116
+ : event.reason === 'decided' && event.answer === 'always'
2117
+ ? alwaysOutcome(post.always, post.agentName, post.agentId)
2118
+ : OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
1413
2119
  const by = event.actor ? ` by <@${event.actor}>` : '';
1414
2120
  const text = `*${escapeSlackText(post.agentName)}* — \`${escapeSlackText(post.toolName)}\` (${post.risk}): ${outcome}${by}.`;
1415
2121
  try {
@@ -1445,9 +2151,13 @@ export const createSlackChannelAdapter = (options) => {
1445
2151
  * cost the rest of the answer.
1446
2152
  */
1447
2153
  const postAheadOf = async (behind, connection, channel, thread, chunks, between) => {
1448
- if (behind && await behind.take(chunks, between)) {
1449
- return;
2154
+ const taken = behind ? await behind.take(chunks, between) : 'untaken';
2155
+ if (taken !== 'untaken') {
2156
+ return taken === 'complete';
1450
2157
  }
2158
+ // Whether ALL of it landed, as `finalize` answers: a reply the thread
2159
+ // saw part of is not one anybody else may be told the whole of.
2160
+ let published = true;
1451
2161
  for (const chunk of chunks) {
1452
2162
  try {
1453
2163
  await connection.web.chat.postMessage({
@@ -1457,12 +2167,14 @@ export const createSlackChannelAdapter = (options) => {
1457
2167
  });
1458
2168
  }
1459
2169
  catch (error) {
2170
+ published = false;
1460
2171
  warn(`slack: could not post part of a turn's outcome: ${error instanceof Error ? error.message : String(error)}`);
1461
2172
  }
1462
2173
  }
1463
2174
  if (between) {
1464
2175
  await between();
1465
2176
  }
2177
+ return published;
1466
2178
  };
1467
2179
  /**
1468
2180
  * The files an unrendered turn produced, taken off the session the moment
@@ -1519,6 +2231,26 @@ export const createSlackChannelAdapter = (options) => {
1519
2231
  }
1520
2232
  const channel = metadata.slackChannel;
1521
2233
  const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
2234
+ if (routing.unaddressed === true && routing.reply === undefined) {
2235
+ // A turn nobody asked for that said nothing before it broke — the
2236
+ // daemon died inside it, and the next start failed it with no
2237
+ // renderer here to keep quiet for it. The same rule as the lazy
2238
+ // renderer's: the failure is in the log, and the files it produced
2239
+ // still land, since a file is not nothing to say.
2240
+ //
2241
+ // "Said nothing" as the session can tell it: no reply saved for the
2242
+ // turn. A line the old process had streamed into a placeholder and
2243
+ // never saved is invisible here — the session records what was
2244
+ // said, not what Slack took (roadmap 31's open item) — so such a
2245
+ // turn is failed without a note under that line. Taken over the
2246
+ // alternative on purpose: a note for every judged turn a restart
2247
+ // caught turns a silent turn, the common case, into the
2248
+ // interruption it existed to avoid; a line left standing happens
2249
+ // only to a turn that had already chosen to speak.
2250
+ warn(`slack: a turn nobody asked for failed before saying anything: ${event.error}`);
2251
+ await uploadUnrenderedFiles(connection, channel, thread, files);
2252
+ return;
2253
+ }
1522
2254
  await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), () => uploadUnrenderedFiles(connection, channel, thread, files));
1523
2255
  };
1524
2256
  /**
@@ -1567,7 +2299,14 @@ export const createSlackChannelAdapter = (options) => {
1567
2299
  await uploads();
1568
2300
  return;
1569
2301
  }
1570
- await postAheadOf(behind, connection, channel, thread, posting, uploads);
2302
+ // A reply the thread's other agents hear like any other, taking their
2303
+ // place for it before the post, as `handleInbound` does.
2304
+ const published = postAheadOf(behind, connection, channel, thread, posting, uploads);
2305
+ const heard = routing.reply !== undefined && thread !== undefined
2306
+ ? overhearReply(connection, channel, thread, routing.reply, routing, published)
2307
+ : undefined;
2308
+ await published;
2309
+ await heard;
1571
2310
  log(`slack: posted the reply of a turn finished after a restart to ${channel}`);
1572
2311
  };
1573
2312
  const handleInteractive = async (connection, args) => {
@@ -1647,14 +2386,25 @@ export const createSlackChannelAdapter = (options) => {
1647
2386
  * Records that `agentId` holds the thread from the message at `ts`
1648
2387
  * onwards, wherever in the thread's history that message falls.
1649
2388
  *
1650
- * Only actual handovers are kept: a write naming the agent that already
1651
- * held the thread at that point adds nothing, so the list stays as short
1652
- * as the conversation's changes of hands rather than growing per message.
1653
- * The oldest handovers are dropped past `THREAD_HANDOVER_DEPTH`, and the
2389
+ * Every claim is kept, not only changes of hands. A claim by the agent
2390
+ * already holding the thread looks redundant until another agent's reply
2391
+ * is placed between it and the one before — a judge's reply posted below
2392
+ * the holder's older message and above its newer one — at which point it
2393
+ * is what keeps the newer message the holder's; dropped, the judge's
2394
+ * later insertion would take the thread from a reply that sits below it.
2395
+ * Only the same claim twice — the same message reaching a second
2396
+ * connection — is dropped.
2397
+ * The oldest claims are dropped past `THREAD_HANDOVER_DEPTH`, and the
1654
2398
  * least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
1655
2399
  * losses cost a lookup, never an answer, since the sessions still hold
1656
2400
  * the durable version.
1657
2401
  *
2402
+ * `ts` is a message's place in the thread: the message that handed the
2403
+ * thread over at intake, or — once a reply has landed — the reply's own
2404
+ * (`ReplyOutcome.spokeAt`), which is what puts a judge's reply posted
2405
+ * below a colleague's placeholder after it, and one streamed above it
2406
+ * before, whichever turn finished first.
2407
+ *
1658
2408
  * Slack timestamps compare as strings: `seconds.microseconds`, both
1659
2409
  * parts fixed width, so lexicographic order is chronological order.
1660
2410
  */
@@ -1665,9 +2415,7 @@ export const createSlackChannelAdapter = (options) => {
1665
2415
  at -= 1;
1666
2416
  }
1667
2417
  const before = handovers[at - 1];
1668
- if (before && before.agentId === agentId) {
1669
- // Already this agent's from earlier in the thread; the same message
1670
- // reaching a second connection lands here too.
2418
+ if (before && before.agentId === agentId && before.ts === ts) {
1671
2419
  return;
1672
2420
  }
1673
2421
  handovers.splice(at, 0, { agentId, ts });
@@ -1896,10 +2644,14 @@ export const createSlackChannelAdapter = (options) => {
1896
2644
  * the dedupe and the handover record included, which are the two pieces a
1897
2645
  * later message reads.
1898
2646
  *
1899
- * `undefined` means the message is not this agent's. `settled: false`
1900
- * means only the sessions can still decide it, which is the one case that
1901
- * has to wait — and it is the cold case, where by definition no message
1902
- * of this thread has been seen in this process.
2647
+ * `undefined` means the message is nothing to this agent — a DM to
2648
+ * someone else, the room rather than a thread, its own words.
2649
+ * `overhear: true` means it is somebody else's to answer and this
2650
+ * agent's to hear, if it is in the thread at all, which only the sessions
2651
+ * can say. `settled: false` means only the sessions can decide even that
2652
+ * much, which is the one case that has to wait — and it is the cold
2653
+ * case, where by definition no message of this thread has been seen in
2654
+ * this process.
1903
2655
  */
1904
2656
  const admit = (connection, args) => {
1905
2657
  const event = args.event;
@@ -1942,13 +2694,6 @@ export const createSlackChannelAdapter = (options) => {
1942
2694
  // or stands alone, is a follow-up to nothing.
1943
2695
  return undefined;
1944
2696
  }
1945
- if (named) {
1946
- // Somebody else was asked. Tagging an agent hands it the question,
1947
- // and the agent that had it is no longer being asked — two answers
1948
- // to one message is what a thread with a roster in it must never
1949
- // produce.
1950
- return undefined;
1951
- }
1952
2697
  }
1953
2698
  // One Slack MESSAGE, not one delivery. An app subscribed to both
1954
2699
  // `app_mention` and `message.channels` is told about a mention twice —
@@ -1960,7 +2705,9 @@ export const createSlackChannelAdapter = (options) => {
1960
2705
  // After the handover record and not before it: a redelivery re-records
1961
2706
  // the same agent, which costs nothing, while deduping first would drop
1962
2707
  // the record whenever the other copy of a doubly-delivered mention is
1963
- // the one a connection sees second.
2708
+ // the one a connection sees second. And before every stand-down below,
2709
+ // because standing down is no longer nothing: an agent that hears a
2710
+ // message it is not answering must hear it once.
1964
2711
  const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
1965
2712
  if (alreadySeen(eventKey)) {
1966
2713
  return undefined;
@@ -1980,6 +2727,8 @@ export const createSlackChannelAdapter = (options) => {
1980
2727
  ...(thread ? { thread } : {}),
1981
2728
  ...(threadKey ? { threadKey } : {}),
1982
2729
  settled: true,
2730
+ overhear: false,
2731
+ judge: false,
1983
2732
  };
1984
2733
  if (addressed) {
1985
2734
  // A message that named this agent and others is recorded above under
@@ -1989,9 +2738,33 @@ export const createSlackChannelAdapter = (options) => {
1989
2738
  // reading of a message that asked several.
1990
2739
  return admission;
1991
2740
  }
1992
- // An untagged reply. The record is what this process has actually seen,
1993
- // in the order it saw it — right even inside the window before a
1994
- // handover reaches the store.
2741
+ if (named) {
2742
+ // Somebody else was asked. Tagging an agent hands it the question,
2743
+ // and the agent that had it is no longer being asked — two answers
2744
+ // to one message is what a thread with a roster in it must never
2745
+ // produce. But the agent that had it is still in the room, and a
2746
+ // person in that position hears the question asked of their
2747
+ // colleague: it goes into this agent's session, unanswered, so the
2748
+ // next thing it is asked is asked of someone who followed along.
2749
+ return { ...admission, overhear: true };
2750
+ }
2751
+ // An untagged reply. How this agent listens decides what it is: an
2752
+ // agent that answers only mentions hears it and nothing more, and one
2753
+ // that judges hears it and, if still attentive, takes a turn nobody
2754
+ // asked for on it — decided in the intake, since only the session can
2755
+ // say. Neither takes part in the holder rule below: that rule answers
2756
+ // "whose is an untagged reply", and for these two agents the soul has
2757
+ // already answered.
2758
+ const listens = listensOf(connection.config.agentId);
2759
+ if (listens === 'mentions') {
2760
+ return { ...admission, overhear: true };
2761
+ }
2762
+ if (listens === 'judge') {
2763
+ return { ...admission, overhear: true, judge: true };
2764
+ }
2765
+ // The record is what this process has actually seen, in the order it
2766
+ // saw it — right even inside the window before a handover reaches the
2767
+ // store.
1995
2768
  const holder = threadKey === undefined ? undefined : holderAt(threadKey, event.ts);
1996
2769
  if (holder !== undefined && threadKey !== undefined) {
1997
2770
  if (holder !== connection.config.agentId) {
@@ -2004,7 +2777,10 @@ export const createSlackChannelAdapter = (options) => {
2004
2777
  // membership the adapter does not track, and would have to be
2005
2778
  // tracked in the durable path too, since a removed app is still a
2006
2779
  // live connection with the newest session there.
2007
- return undefined;
2780
+ //
2781
+ // Standing down is not leaving: the reply is the holder's, and this
2782
+ // agent hears it.
2783
+ return { ...admission, overhear: true };
2008
2784
  }
2009
2785
  rememberAddressee(threadKey, connection.config.agentId, event.ts);
2010
2786
  return admission;
@@ -2047,42 +2823,127 @@ export const createSlackChannelAdapter = (options) => {
2047
2823
  // reverse order in durable history. The chain is claimed here,
2048
2824
  // synchronously, so a message's place in it is fixed by when Slack
2049
2825
  // delivered it and not by how long anything it does takes.
2050
- const previousIntake = intakeChains.get(sessionId) ?? Promise.resolve();
2051
- const intake = previousIntake.then(async () => {
2826
+ const intake = onIntakeChain(sessionId, async () => {
2052
2827
  // The durable half of addressing runs INSIDE the chain, for the same
2053
2828
  // reason: it awaits, and a message that had to ask the sessions must
2054
2829
  // not lose its place to one that did not. A message that turns out
2055
2830
  // not to be this agent's leaves an empty link behind, which is all it
2056
2831
  // should cost.
2832
+ let overhear = admitted.overhear;
2833
+ // Judged, not ruled: a turn nobody asked for, while the agent is
2834
+ // still attentive, and a free overhear past that. Read off the
2835
+ // chain — a session the invitation is still creating reads as not
2836
+ // there, and the message is heard rather than judged, which is the
2837
+ // safe side. A host that cannot say leaves the agent hearing.
2838
+ let judged = false;
2839
+ if (admitted.judge && gateway.sessionRouting) {
2840
+ // The pending count is taken BEFORE the store is read: a message
2841
+ // that settles between the two would be counted by neither the
2842
+ // other way round, and a window that runs one message short in a
2843
+ // race is a bound kept; one that runs a message long is not.
2844
+ const pending = pendingFor(sessionId);
2845
+ let routing;
2846
+ try {
2847
+ routing = await gateway.sessionRouting(sessionId);
2848
+ }
2849
+ catch (error) {
2850
+ warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
2851
+ }
2852
+ if (routing && attentive(routing, event.ts, pending)) {
2853
+ judged = true;
2854
+ overhear = false;
2855
+ }
2856
+ }
2057
2857
  if (!admitted.settled) {
2058
2858
  // One verdict for this message, shared with whichever other agents
2059
2859
  // are asking about it — see `followUpWinner`.
2060
2860
  const winner = await followUpWinner({ team, conversation: event.channel, thread: event.thread_ts ?? '' }, event.ts);
2061
2861
  if (winner !== connection.config.agentId) {
2062
- return undefined;
2862
+ // Not this agent's to answer — and, if it is in the thread,
2863
+ // this agent's to hear, same as the two warm stand-downs.
2864
+ overhear = true;
2063
2865
  }
2064
- // Answered from the store rather than from memory: hold it, so the
2065
- // rest of the thread is ordered by receipt like any other.
2066
- if (threadKey) {
2866
+ else if (threadKey) {
2867
+ // Answered from the store rather than from memory: hold it, so
2868
+ // the rest of the thread is ordered by receipt like any other.
2067
2869
  rememberAddressee(threadKey, connection.config.agentId, event.ts);
2068
2870
  }
2069
2871
  }
2070
2872
  const cleaned = await humanizeMentions(connection, event.text ?? '');
2071
- if (cleaned.length === 0) {
2873
+ // Fetched inside the chain, like the lookups below: it is network
2874
+ // I/O, and a message whose screenshot is slow to arrive must not lose
2875
+ // its place to the one typed after it. Only for a message this agent
2876
+ // will answer: an overheard one is appended as text with no turn run
2877
+ // on it, and its attachments reach the transcript by name, as every
2878
+ // attachment did before images.
2879
+ // Nor for one it will judge: the kernel refuses images on a turn
2880
+ // nobody asked for, since they reach the model ahead of the frame
2881
+ // that marks the message as somebody else's, and a judged message
2882
+ // names its attachments the way an overheard one does.
2883
+ const { images, unread } = overhear || judged
2884
+ ? { images: [], unread: event.files ?? [] }
2885
+ : await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
2886
+ // An image with nothing said is still a question ("what's this?"); a
2887
+ // file the turn could not open with nothing said is not one, and gets
2888
+ // no reply.
2889
+ if (cleaned.length === 0 && images.length === 0) {
2072
2890
  return undefined;
2073
2891
  }
2074
2892
  const author = await displayNameFor(connection, userId);
2075
2893
  // In shared channels the model should know who is speaking; a DM is
2076
- // unambiguous.
2077
- const spoken = isDm ? cleaned : `${author}: ${cleaned}`;
2078
- const userMessage = `${spoken}${attachmentNote(event.files)}`;
2079
- const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn);
2080
- try {
2081
- await renderer.open();
2894
+ // unambiguous. A bare image in a channel still says who sent it.
2895
+ const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
2896
+ const userMessage = `${spoken}${attachmentNote(unread)}`;
2897
+ // Who sent this, judged against the operator's list — per message,
2898
+ // never remembered from the first one in the thread. A DM proves
2899
+ // nothing about who is typing, so a DM from an unlisted member is
2900
+ // `unknown` exactly as a channel mention would be. The same label
2901
+ // whether the agent answers or only hears: the text is in its
2902
+ // transcript either way.
2903
+ const senderTrust = (connection.config.principals ?? []).includes(userId) ? 'user' : 'unknown';
2904
+ const metadata = {
2905
+ channel: 'slack',
2906
+ team,
2907
+ slackChannel: event.channel,
2908
+ slackUser: userId,
2909
+ // Carried so a mid-turn approval question is asked in the thread
2910
+ // the turn belongs to rather than at the top of a busy channel.
2911
+ ...(thread ? { slackThread: thread } : {}),
2912
+ [SENDER_TRUST_METADATA_KEY]: senderTrust,
2913
+ };
2914
+ if (overhear) {
2915
+ // Heard, not answered: no placeholder, no turn. Only into a
2916
+ // conversation this agent is already in — a session under this
2917
+ // thread's key exists exactly when it was invited here — and the
2918
+ // gateway is the one to say whether it is, on the session's chain:
2919
+ // a first mention whose dispatch is queued ahead of this message
2920
+ // has created the session by the time the observe runs, where a
2921
+ // membership read from here would find nothing and drop a
2922
+ // message said moments after the invitation. A host that cannot
2923
+ // take it leaves the agent hearing what it answers, as every
2924
+ // agent did before.
2925
+ if (!gateway.observe) {
2926
+ return undefined;
2927
+ }
2928
+ // Placed on the gateway's chain here, in receipt order, and not
2929
+ // awaited here: a dispatch is placed the same way, and awaiting
2930
+ // an overhear queued behind a long turn would hold the next
2931
+ // message's place in this chain hostage to that turn.
2932
+ return {
2933
+ observed: placeObserve(sessionId, gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata })),
2934
+ };
2082
2935
  }
2083
- catch (error) {
2084
- warn(`could not post to ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
2085
- return undefined;
2936
+ // A turn nobody asked for opens its placeholder on its first text,
2937
+ // if it ever has any — see `ReplyRenderer.lazy`.
2938
+ const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, { lazy: judged, now });
2939
+ if (!judged) {
2940
+ try {
2941
+ await renderer.open();
2942
+ }
2943
+ catch (error) {
2944
+ warn(`could not post to ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
2945
+ return undefined;
2946
+ }
2086
2947
  }
2087
2948
  // The push and the dispatch happen in the same microtask, so queue
2088
2949
  // order matches the gateway's single-flight turn order for this
@@ -2094,35 +2955,29 @@ export const createSlackChannelAdapter = (options) => {
2094
2955
  sessionId,
2095
2956
  agentId: connection.config.agentId,
2096
2957
  userMessage,
2958
+ ...(images.length > 0 ? { images } : {}),
2959
+ ...(judged ? { addressed: false } : {}),
2097
2960
  turnId: renderer.turnId,
2098
- metadata: {
2099
- channel: 'slack',
2100
- team,
2101
- slackChannel: event.channel,
2102
- slackUser: userId,
2103
- // Carried so a mid-turn approval question is asked in the thread
2104
- // the turn belongs to rather than at the top of a busy channel.
2105
- ...(thread ? { slackThread: thread } : {}),
2106
- // This turn's sender, judged against the operator's list — per
2107
- // message, never remembered from the first one in the thread. A
2108
- // DM proves nothing about who is typing, so a DM from an unlisted
2109
- // member is `unknown` exactly as a channel mention would be.
2110
- [SENDER_TRUST_METADATA_KEY]: (connection.config.principals ?? []).includes(userId) ? 'user' : 'unknown',
2111
- },
2961
+ metadata,
2112
2962
  });
2113
2963
  return { renderer, turn };
2114
2964
  });
2115
- const settledIntake = intake.then(() => undefined, () => undefined);
2116
- intakeChains.set(sessionId, settledIntake);
2117
- void settledIntake.then(() => {
2118
- if (intakeChains.get(sessionId) === settledIntake) {
2119
- intakeChains.delete(sessionId);
2120
- }
2121
- });
2122
2965
  const started = await intake;
2123
2966
  if (!started) {
2124
2967
  return;
2125
2968
  }
2969
+ if ('observed' in started) {
2970
+ // Nothing to render; the one outcome worth a line is the host
2971
+ // refusing it — a turn parked across a restart, most likely — since
2972
+ // a message dropped on the floor is the thing this exists to end.
2973
+ try {
2974
+ await started.observed;
2975
+ }
2976
+ catch (error) {
2977
+ warn(`slack: ${connection.config.agentId} could not overhear a message in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
2978
+ }
2979
+ return;
2980
+ }
2126
2981
  const { renderer, turn } = started;
2127
2982
  const removeFromQueue = () => {
2128
2983
  const current = renderers.get(sessionId);
@@ -2149,10 +3004,41 @@ export const createSlackChannelAdapter = (options) => {
2149
3004
  // the potentially slow final edit, uploads, and overflow posts.
2150
3005
  removeFromQueue();
2151
3006
  if (session) {
2152
- await renderer.finalize(lastAssistantReply(session));
3007
+ // The reply is final here, so this is when the thread's other agents
3008
+ // take their place for it — before the final edit and the overflow
3009
+ // posts, which are network I/O a later message must not overtake.
3010
+ // A DM has no thread, and nobody else in it.
3011
+ const reply = latestTurnReply(session);
3012
+ // A turn nobody asked for that said nothing posts nothing; every
3013
+ // other turn says `(no reply)` where its answer would have gone.
3014
+ const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : lastAssistantReply(session));
3015
+ const heard = thread !== undefined && reply !== undefined
3016
+ ? overhearReply(connection, event.channel, thread, reply, session, finalized.then((outcome) => outcome.published))
3017
+ : undefined;
3018
+ const { spoke, spokeAt } = await finalized;
3019
+ if (spoke && threadKey !== undefined) {
3020
+ // The voice that just answered, recorded where its reply sits in
3021
+ // the thread. For a judging agent this is the only record there
3022
+ // is — it took no part in the holder rule at intake — and without
3023
+ // it a thread-rule colleague that held the thread before would
3024
+ // keep answering beside it. For any other turn the intake wrote
3025
+ // one at the message answered, and this one says where the answer
3026
+ // landed, which is what settles two turns running at once: a
3027
+ // placeholder posted at intake sits above a reply a judge posted
3028
+ // while it was being filled, whichever turn finished first, and
3029
+ // the reader's next untagged message is for the lower of the two.
3030
+ // Late, either way, for a message typed while the turn was still
3031
+ // running, which the other may therefore still take.
3032
+ rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
3033
+ }
3034
+ await heard;
2153
3035
  }
2154
3036
  else {
2155
- await renderer.fail(failure instanceof Error ? failure.message : String(failure));
3037
+ const { spoke, spokeAt } = await renderer.fail(failure instanceof Error ? failure.message : String(failure));
3038
+ if (spoke && threadKey !== undefined) {
3039
+ // A file it posted before breaking is still the last thing said.
3040
+ rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
3041
+ }
2156
3042
  }
2157
3043
  };
2158
3044
  return {
@@ -2344,6 +3230,7 @@ export const createSlackChannelAdapter = (options) => {
2344
3230
  approvalPosts.clear();
2345
3231
  threadAddressee.clear();
2346
3232
  coldVerdicts.clear();
3233
+ pendingObserves.clear();
2347
3234
  botIdentities.clear();
2348
3235
  rendering.clear();
2349
3236
  resolvedWhileRendering.clear();