@stratusagent/channel-slack 0.11.2 → 0.11.4

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,8 +2,13 @@ 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, escapeControlCharacters, } from '@stratusagent/core';
6
6
  import { channelSessionKey, } from '@stratusagent/channels';
7
+ /**
8
+ * The longest display name the model is shown. Slack caps profile names
9
+ * well under this; the bound is for a profile that is not a name.
10
+ */
11
+ const MAX_DISPLAY_NAME_LENGTH = 80;
7
12
  const SLACK_MAX_MESSAGE_CHARS = 4000;
8
13
  const DEFAULT_EDIT_INTERVAL_MS = 1000;
9
14
  const DEDUPE_CAPACITY = 2000;
@@ -21,7 +26,7 @@ const THREAD_ADDRESSEE_CAPACITY = 2000;
21
26
  */
22
27
  const COLD_VERDICT_CAPACITY = 256;
23
28
  /**
24
- * How many changes of hands one thread remembers.
29
+ * How many claims on one thread it remembers.
25
30
  *
26
31
  * A message older than all of them asks the sessions instead, which answer
27
32
  * for the thread as it stands rather than as it stood — so a reply that old
@@ -29,13 +34,14 @@ const COLD_VERDICT_CAPACITY = 256;
29
34
  * bounding this, and something has to bound it: the alternative is a list
30
35
  * that grows with a conversation for the life of the daemon.
31
36
  *
32
- * Eight is chosen to sit far past the case it covers. This only has to
37
+ * A claim is a change of hands or a reply that landed (see
38
+ * `rememberAddressee`), so a busy thread writes one or two per message.
39
+ * Thirty-two sits far past the case the record covers: it only has to
33
40
  * 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.
41
+ * window, and crossing it needs that many replies in one thread while a
42
+ * socket stays behind for all of them.
37
43
  */
38
- const THREAD_HANDOVER_DEPTH = 8;
44
+ const THREAD_HANDOVER_DEPTH = 32;
39
45
  /**
40
46
  * How many files an unrendered turn may queue for its outcome. Nothing
41
47
  * drains that queue until the outcome arrives, so a session whose outcome
@@ -45,6 +51,26 @@ const THREAD_HANDOVER_DEPTH = 8;
45
51
  */
46
52
  const MAX_UNRENDERED_FILES = 20;
47
53
  const PLACEHOLDER_TEXT = '…';
54
+ /**
55
+ * How long an agent that judges stays attentive after it last answered a
56
+ * message that addressed it: this many messages, or this many minutes,
57
+ * whichever ends first. Judging costs
58
+ * a model call per message, and two people talking for an hour is a
59
+ * hundred of them for one "no" — so attention decays the way a person's
60
+ * does. Outside the window a message is overheard for free, and a mention
61
+ * re-arms it, which is what a mention has always meant.
62
+ */
63
+ const ATTENTION_MESSAGES = 8;
64
+ const ATTENTION_MS = 15 * 60 * 1000;
65
+ /**
66
+ * A 5 MB image over a slow link is seconds, not minutes. Past this the
67
+ * downloads are abandoned and the turn runs with the attachments named as
68
+ * unreadable, rather than the thread waiting on them. One deadline for
69
+ * the whole message, not one per file: a message may carry ten, and ten
70
+ * stalls in a row would hold the thread — and `stop()` — ten times as
71
+ * long.
72
+ */
73
+ const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
48
74
  /** What a turn that produced no text puts in its message, wherever it is posted from. */
49
75
  const NO_REPLY_TEXT = '(no reply)';
50
76
  // Lazy (and CJS-interoperable) so tests with injected fakes never load the
@@ -58,6 +84,77 @@ const defaultWebClient = (botToken) => {
58
84
  const { WebClient } = requireModule('@slack/web-api');
59
85
  return new WebClient(botToken);
60
86
  };
87
+ /**
88
+ * The default fetcher over a `fetch`. Exported as a factory so the bounded
89
+ * read can be tested against a fake `fetch` with a body that never ends;
90
+ * the adapter uses it over the global one.
91
+ *
92
+ * The body is read a chunk at a time and abandoned the moment it passes
93
+ * `maxBytes`: `arrayBuffer()` would buffer the whole response first, and
94
+ * the size the adapter checked before asking came from Slack's metadata,
95
+ * which an oversized response is under no obligation to match. A
96
+ * `content-length` that already says too much is refused before a byte is
97
+ * read.
98
+ */
99
+ export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signal, maxBytes) => {
100
+ const response = await fetchImpl(url, { headers: { authorization: `Bearer ${botToken}` }, signal });
101
+ const contentType = response.headers.get('content-type');
102
+ const typed = contentType !== null ? { contentType } : {};
103
+ const declared = Number(response.headers.get('content-length'));
104
+ if (Number.isFinite(declared) && declared > maxBytes) {
105
+ await response.body?.cancel();
106
+ return { status: response.status, ...typed, body: Buffer.alloc(0), truncated: true };
107
+ }
108
+ if (response.body === null) {
109
+ return { status: response.status, ...typed, body: Buffer.alloc(0) };
110
+ }
111
+ const reader = response.body.getReader();
112
+ const chunks = [];
113
+ let received = 0;
114
+ try {
115
+ for (;;) {
116
+ const { done, value } = await reader.read();
117
+ if (done) {
118
+ break;
119
+ }
120
+ received += value.byteLength;
121
+ if (received > maxBytes) {
122
+ await reader.cancel();
123
+ return { status: response.status, ...typed, body: Buffer.alloc(0), truncated: true };
124
+ }
125
+ chunks.push(Buffer.from(value));
126
+ }
127
+ }
128
+ finally {
129
+ reader.releaseLock();
130
+ }
131
+ return { status: response.status, ...typed, body: Buffer.concat(chunks) };
132
+ };
133
+ const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
134
+ /**
135
+ * A clock reading as a Slack timestamp, for a file whose upload answered
136
+ * with no share ts. Slack timestamps are epoch seconds with six decimals,
137
+ * so the clock's reading compares with them as long as the two agree to
138
+ * within the gap between one message and the next.
139
+ */
140
+ const slackTs = (ms) => (ms / 1000).toFixed(6);
141
+ /**
142
+ * Where an upload sits in the thread: the ts of the message it became in
143
+ * `channel`, from the shares the upload answers with. Undefined when the
144
+ * answer carries none.
145
+ */
146
+ const uploadPlace = (result, channel) => {
147
+ for (const file of result.files ?? []) {
148
+ for (const scope of [file.shares?.public, file.shares?.private]) {
149
+ for (const share of scope?.[channel] ?? []) {
150
+ if (share.ts !== undefined) {
151
+ return share.ts;
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return undefined;
157
+ };
61
158
  /**
62
159
  * Renders one turn's reply into Slack with the placeholder-then-edit
63
160
  * pattern: post `…` immediately, fold streaming deltas and tool status
@@ -103,17 +200,45 @@ class ReplyRenderer {
103
200
  */
104
201
  generation = 0;
105
202
  finalized = false;
203
+ /** Whether any upload of this turn's landed — a file is something said, placeholder or not. */
204
+ uploaded = false;
205
+ /** Whether any edit of this turn's landed — a streamed line Slack took is something said, whatever the final edit's fate. */
206
+ edited = false;
207
+ /**
208
+ * Where in the thread this turn's words stand: the ts of the lowest
209
+ * message Slack took them in — the placeholder an edit landed in, or an
210
+ * overflow chunk posted — and, separately, where a file of its landed
211
+ * (the share ts the upload answers with, or the clock — see `queueUpload`).
212
+ * What the thread rule orders by: an untagged reply is for whoever's
213
+ * reply sits lowest in the thread, the one a reader sees last, which is
214
+ * not the turn that finished last — a placeholder posted at intake and
215
+ * filled in slowly sits above a reply posted while it was being written.
216
+ */
217
+ textAt;
218
+ uploadedAt;
219
+ /**
220
+ * A turn nobody asked for: the placeholder is not posted at intake but
221
+ * on the first TEXT the turn streams, and a turn that ends having said
222
+ * nothing leaves the thread untouched — no placeholder, no `(no reply)`,
223
+ * no edit. Text and not a tool line, though the spec sketched either: a
224
+ * placeholder posted for a tool the turn ran on the way to deciding it
225
+ * had nothing to add is a message the decision cannot take back.
226
+ */
227
+ lazy;
106
228
  web;
107
229
  channel;
108
230
  threadTs;
109
231
  editIntervalMs;
110
232
  warn;
111
- constructor(web, channel, threadTs, editIntervalMs, warn) {
233
+ now;
234
+ constructor(web, channel, threadTs, editIntervalMs, warn, options = {}) {
112
235
  this.web = web;
113
236
  this.channel = channel;
114
237
  this.threadTs = threadTs;
115
238
  this.editIntervalMs = editIntervalMs;
116
239
  this.warn = warn;
240
+ this.lazy = options.lazy ?? false;
241
+ this.now = options.now ?? Date.now;
117
242
  }
118
243
  /**
119
244
  * The gateway has begun this renderer's turn. Whatever was streamed here
@@ -235,12 +360,27 @@ class ReplyRenderer {
235
360
  // failure instead of an unhandled stream error.
236
361
  .then(async () => {
237
362
  const data = await readFile(filePath);
238
- await this.web.files.uploadV2({
363
+ // Where the file will sit is Slack's to say — the ts of the
364
+ // message it becomes, in the shares the upload answers with. When
365
+ // the answer carries none, the clock stands in, read BEFORE the
366
+ // call: the file is posted somewhere inside it, and a colleague's
367
+ // placeholder posted while the answer was on its way back sits
368
+ // below the file. Read after, the file would be placed below that
369
+ // placeholder and take a thread the colleague holds; read before,
370
+ // the error runs the other way, toward the colleague — the fault
371
+ // an @ fixes.
372
+ const before = slackTs(this.now());
373
+ const result = await this.web.files.uploadV2({
239
374
  channel_id: this.channel,
240
375
  file: data,
241
376
  filename: path.basename(filePath),
242
377
  ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
243
378
  });
379
+ this.uploaded = true;
380
+ const placed = uploadPlace(result, this.channel) ?? before;
381
+ if (this.uploadedAt === undefined || placed > this.uploadedAt) {
382
+ this.uploadedAt = placed;
383
+ }
244
384
  })
245
385
  .catch((error) => this.warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`));
246
386
  }
@@ -261,11 +401,24 @@ class ReplyRenderer {
261
401
  if (this.finalized) {
262
402
  return;
263
403
  }
404
+ // Nothing said yet: a lazy turn has no placeholder to edit, and a
405
+ // tool line alone does not earn one (see `lazy`). Nor does text
406
+ // streamed here before this turn began — a recovery's, routed to
407
+ // the head of the queue — which `beginTurn` drops.
408
+ if (this.lazy && !this.ref && (!this.turnStarted || this.buffer.trim().length === 0)) {
409
+ return;
410
+ }
264
411
  this.lastEditAt = Date.now();
265
- this.queueEdit(messageText(this.currentText()));
412
+ void this.queueEdit(messageText(this.currentText()));
266
413
  }, delay);
267
414
  this.pendingEdit.unref?.();
268
415
  }
416
+ /**
417
+ * Queue one edit of the placeholder. Resolves to whether Slack took it —
418
+ * false when there was no placeholder to edit, the text belonged to a
419
+ * turn handed over meanwhile, or the update was refused — which is what
420
+ * lets `finalize` say whether the reply was ever published.
421
+ */
269
422
  queueEdit(text) {
270
423
  const generation = this.generation;
271
424
  // The handover as it stands when the edit is queued, not when it runs:
@@ -273,21 +426,48 @@ class ReplyRenderer {
273
426
  // edit that then waited for that handover would be a cycle nothing
274
427
  // could break.
275
428
  const handover = this.handover;
276
- this.editChain = this.editChain
429
+ const edit = this.editChain
277
430
  .then(() => handover)
278
- .then(() => {
431
+ .then(async () => {
432
+ if (this.lazy && !this.ref && this.turnStarted && generation === this.generation) {
433
+ // The first text of a turn nobody asked for: this is where its
434
+ // placeholder is posted, and the edit below fills it. A post
435
+ // that fails leaves the turn without one, and its reply is a
436
+ // message of its own when it comes (see `finalize`).
437
+ try {
438
+ await this.open();
439
+ }
440
+ catch (error) {
441
+ this.warn(`could not open a placeholder: ${error instanceof Error ? error.message : String(error)}`);
442
+ }
443
+ }
279
444
  // Read after the handover, not before: the placeholder may have
280
445
  // changed hands while this edit waited its turn — and the text
281
446
  // may belong to the turn that was handed over.
282
447
  const ref = this.ref;
283
- return ref && generation === this.generation
284
- ? this.web.chat.update({ channel: ref.channel, ts: ref.ts, text })
285
- : undefined;
448
+ if (!ref || generation !== this.generation) {
449
+ return false;
450
+ }
451
+ await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text });
452
+ this.edited = true;
453
+ this.placeText(ref.ts);
454
+ return true;
286
455
  })
287
- .then(() => undefined)
288
- .catch((error) => this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`));
456
+ .catch((error) => {
457
+ this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
458
+ return false;
459
+ });
460
+ this.editChain = edit.then(() => undefined);
461
+ return edit;
289
462
  }
290
- /** Replace the placeholder with the final reply, splitting if oversized. */
463
+ /**
464
+ * Replace the placeholder with the final reply, splitting if oversized.
465
+ * Resolves to a `ReplyOutcome`. A reply Slack refused any part of was
466
+ * not said in the thread as written, and nothing downstream may treat it
467
+ * as if it had been: a colleague hearing the whole of a reply the thread
468
+ * saw half of is reasoning from text nobody read. But a file beside a
469
+ * refused sentence is still the last thing said, for the thread rule.
470
+ */
291
471
  async finalize(reply) {
292
472
  await this.handover;
293
473
  this.finalized = true;
@@ -295,31 +475,121 @@ class ReplyRenderer {
295
475
  clearTimeout(this.pendingEdit);
296
476
  this.pendingEdit = undefined;
297
477
  }
478
+ // Let the edits already queued land before deciding anything from
479
+ // `ref`: a lazy turn's first text may be opening its placeholder in
480
+ // that chain right now, and reading `ref` ahead of it would post the
481
+ // reply as a message of its own beside a placeholder that then fills
482
+ // with stale partial text.
483
+ await this.editChain;
484
+ if (this.lazy && reply.trim().length === 0 && (!this.ref || await this.retract())) {
485
+ // Silence, decided: nothing was posted and nothing is — or what was
486
+ // posted has been taken back. The uploads still land: a file the
487
+ // turn produced is not nothing to say, and a turn that posted one
488
+ // has spoken, for the thread rule.
489
+ await this.editChain;
490
+ await this.uploadChain;
491
+ return this.outcome(false, this.uploaded);
492
+ }
298
493
  const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
299
494
  const chunks = messageChunks(text);
300
495
  // No placeholder — a handover could not open a fresh one — and the
301
496
  // reply is a message of its own rather than nothing at all.
302
497
  const rest = this.ref ? chunks.slice(1) : chunks;
303
- if (this.ref) {
304
- this.queueEdit(chunks[0] ?? NO_REPLY_TEXT);
305
- }
498
+ const edited = this.ref ? this.queueEdit(chunks[0] ?? NO_REPLY_TEXT) : Promise.resolve(false);
499
+ let landed = await edited;
500
+ let published = this.ref ? landed : true;
306
501
  await this.editChain;
307
502
  await this.uploadChain;
308
503
  for (const chunk of rest) {
309
504
  try {
310
- await this.web.chat.postMessage({
505
+ const posted = await this.web.chat.postMessage({
311
506
  channel: this.channel,
312
507
  text: chunk,
313
508
  ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
314
509
  });
510
+ landed = true;
511
+ this.placeText(posted.ts);
315
512
  }
316
513
  catch (error) {
514
+ published = false;
317
515
  this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
318
516
  }
319
517
  }
518
+ // A placeholder that opened for a turn nobody asked for and never
519
+ // took a word of it — every edit refused — is a `…` standing alone
520
+ // under the agent's name, the interruption a judged turn exists to
521
+ // avoid, so it is taken back like an abandoned attempt's. One Slack
522
+ // will not let go stands, and a message the thread can see is the
523
+ // last thing said whatever it says.
524
+ let stranded = false;
525
+ if (this.lazy && this.ref !== undefined && !landed && !this.edited) {
526
+ stranded = !(await this.retract());
527
+ if (stranded) {
528
+ this.placeText(this.ref?.ts);
529
+ }
530
+ }
531
+ return this.outcome(published, landed || this.edited || this.uploaded || stranded);
532
+ }
533
+ placeText(ts) {
534
+ if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
535
+ this.textAt = ts;
536
+ }
537
+ }
538
+ outcome(published, spoke) {
539
+ const spokeAt = [this.textAt, this.uploadedAt]
540
+ .filter((ts) => ts !== undefined)
541
+ .sort()
542
+ .at(-1);
543
+ return { published, spoke, ...(spoke && spokeAt !== undefined ? { spokeAt } : {}) };
544
+ }
545
+ /**
546
+ * Take back the placeholder a turn nobody asked for opened and then had
547
+ * nothing to put in: the line that opened it was an attempt the provider
548
+ * abandoned (`reset` — a fallback after a mid-stream failure), and the
549
+ * attempt that stood answered with nothing. Editing that placeholder to
550
+ * `(no reply)` would leave exactly the interruption a silent turn exists
551
+ * to avoid, and would count as speaking. Resolves to whether Slack let
552
+ * the message go: a delete refused leaves the abandoned line standing,
553
+ * and the turn finishes the ordinary way, since a message the thread
554
+ * can still see is the last thing said whatever it says.
555
+ */
556
+ async retract() {
557
+ const ref = this.ref;
558
+ if (!ref) {
559
+ return true;
560
+ }
561
+ try {
562
+ await this.web.chat.delete({ channel: ref.channel, ts: ref.ts });
563
+ }
564
+ catch (error) {
565
+ this.warn(`chat.delete failed: ${error instanceof Error ? error.message : String(error)}`);
566
+ return false;
567
+ }
568
+ this.ref = undefined;
569
+ // The line the placeholder took is gone with it: nothing of this
570
+ // turn's stands in the thread unless a file does.
571
+ this.edited = false;
572
+ this.textAt = undefined;
573
+ return true;
320
574
  }
321
575
  async fail(message) {
322
- await this.finalize(`Something went wrong: ${message}`);
576
+ // The same wait `finalize` takes, for the same reason: a lazy turn's
577
+ // first text may be opening its placeholder right now, and a failure
578
+ // read as "before saying anything" would leave that placeholder saying
579
+ // `(no reply)` where the failure belongs.
580
+ await this.editChain;
581
+ // "Before saying anything" is no placeholder AND nothing buffered: a
582
+ // first text whose zero-delay edit has not fired yet has still been
583
+ // said, and its failure is reported — as a message of its own, since
584
+ // there is no placeholder to edit — rather than swallowed with it.
585
+ if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
586
+ // A turn nobody asked for that failed before saying anything: the
587
+ // failure is in the daemon log, and an error note would be the
588
+ // interruption the turn existed to avoid.
589
+ this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
590
+ return this.finalize('');
591
+ }
592
+ return this.finalize(`Something went wrong: ${message}`);
323
593
  }
324
594
  /**
325
595
  * Claim the placeholder for a turn that finished ahead of this one — the
@@ -357,7 +627,7 @@ class ReplyRenderer {
357
627
  return {
358
628
  take: async (chunks, between) => {
359
629
  if (spent) {
360
- return false;
630
+ return 'untaken';
361
631
  }
362
632
  try {
363
633
  await previous;
@@ -380,7 +650,7 @@ class ReplyRenderer {
380
650
  const [first, ...rest] = chunks;
381
651
  const given = this.ref;
382
652
  if (first === undefined || this.finalized || !given) {
383
- return false;
653
+ return 'untaken';
384
654
  }
385
655
  await drained;
386
656
  try {
@@ -390,8 +660,9 @@ class ReplyRenderer {
390
660
  // Not handed over: the placeholder is still this turn's, and the
391
661
  // caller posts the reply as messages of its own.
392
662
  this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
393
- return false;
663
+ return 'untaken';
394
664
  }
665
+ let complete = true;
395
666
  // The placeholder is the earlier turn's now, and the reference goes
396
667
  // before the reopen, so a reopen that fails cannot leave this turn
397
668
  // writing over the reply just handed over. (What was streamed into
@@ -407,6 +678,7 @@ class ReplyRenderer {
407
678
  });
408
679
  }
409
680
  catch (error) {
681
+ complete = false;
410
682
  this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
411
683
  }
412
684
  }
@@ -425,32 +697,13 @@ class ReplyRenderer {
425
697
  // message of its own when it comes (see `finalize`).
426
698
  this.warn(`could not reopen a placeholder: ${error instanceof Error ? error.message : String(error)}`);
427
699
  }
428
- return true;
700
+ return complete ? 'complete' : 'partial';
429
701
  }
430
702
  }
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
- };
703
+ // The file-bearing result convention is the kernel's (`filePathsOf`), so
704
+ // the gateway counts a turn that produced a file as having spoken by the
705
+ // same rule this adapter uploads it by.
706
+ const collectFilePaths = (result) => filePathsOf(result);
454
707
  // A hard cut must never land between the halves of a UTF-16 surrogate
455
708
  // pair — an emoji on the boundary would reach Slack as two replacement
456
709
  // characters. Backs the index off the pair's high half when it would.
@@ -688,6 +941,57 @@ const convertHeadings = (text, code) => {
688
941
  return startsInside(closer, within) ? heading : `*${heading}*`;
689
942
  });
690
943
  };
944
+ /**
945
+ * A stand-in for one code segment while the prose around it is rewritten.
946
+ *
947
+ * Emphasis was converted one prose fragment at a time until this existed,
948
+ * and a fragment is not a run: inline code cuts one in two, so the `**`
949
+ * opening a bold run and the `**` closing it land in different fragments
950
+ * whenever a snippet sits between them, and neither half ever finds its
951
+ * partner. ``**the `fs.read` tool**`` reached a reader as four literal
952
+ * asterisks around a snippet, and so did every link, italic and
953
+ * strikethrough written the same way. It is the defect #142 found in
954
+ * headings, in the rule right next to it, and it has the same answer:
955
+ * rewrite the whole text, with code held out of the rewrite rather than
956
+ * cut out of the text.
957
+ *
958
+ * A mask names its segment rather than merely standing in for one, because
959
+ * a rewrite is allowed to move it: the link rule writes its two captures
960
+ * back in the other order, so `[use `a`](https://host/`b`)` hands the URL's
961
+ * snippet to the label and the label's to the URL, and a snippet that
962
+ * arrives somewhere else is the same defect as one that is rewritten. Named,
963
+ * it comes back to the place holding its own name. No rewrite can damage
964
+ * the name itself: none of the delimiters they look for appear in it, and
965
+ * every rule copies what it matched through to its replacement.
966
+ *
967
+ * A mask also carries whether its segment holds whitespace, because three
968
+ * rules refuse to cross it — a destination is `[^()\s]+`, a label and an
969
+ * emphasis run stop at a newline — and a name alone has none to stop them.
970
+ * `[label](https://host/`a b`)` is not a destination and was never meant to
971
+ * convert; masked shapelessly it did, and came back rearranged into a link
972
+ * Slack cannot render. Only whitespace is carried, and only ever so a rule
973
+ * refuses: a `*` in a snippet must still not act like one. Both ends of a
974
+ * mask stay the mask character, so a run that has to hug its content still
975
+ * hugs it — a segment opens and closes with a backtick, so that is not a
976
+ * lie about its ends.
977
+ *
978
+ * The character is chosen against the text rather than fixed, because a
979
+ * reply that already contained it would have a snippet spliced in where
980
+ * the writer's own character stood — the one thing this conversion must
981
+ * never do. A reply holding all 6400 of the private-use characters has no
982
+ * mask to be given and is left in Markdown instead: prose that reads as it
983
+ * was written is a cosmetic loss, and a snippet spliced somewhere else is
984
+ * not.
985
+ */
986
+ const maskFor = (text) => {
987
+ for (let point = 0xe000; point <= 0xf8ff; point += 1) {
988
+ const candidate = String.fromCharCode(point);
989
+ if (!text.includes(candidate)) {
990
+ return candidate;
991
+ }
992
+ }
993
+ return undefined;
994
+ };
691
995
  /**
692
996
  * Markdown as a model writes it, in the spelling Slack actually renders.
693
997
  *
@@ -703,28 +1007,49 @@ const convertHeadings = (text, code) => {
703
1007
  * add ways to be wrong and fix nothing.
704
1008
  */
705
1009
  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 = [];
1010
+ const mask = maskFor(text);
1011
+ if (mask === undefined) {
1012
+ return text;
1013
+ }
709
1014
  const segments = proseAndCode(text);
710
- let converted = '';
711
- segments.forEach((segment, index) => {
1015
+ const code = [];
1016
+ let masked = '';
1017
+ for (const [index, segment] of segments.entries()) {
712
1018
  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;
1019
+ const shape = /\n/.test(segment) ? '\n' : /\s/.test(segment) ? ' ' : '';
1020
+ masked += `${mask}${code.length}${shape}${mask}`;
1021
+ code.push(segment);
1022
+ continue;
724
1023
  }
725
- converted += convertInline(segment);
726
- });
727
- return convertHeadings(converted, code);
1024
+ masked += segment;
1025
+ }
1026
+ // A code segment that ends the text is the unterminated fence: every
1027
+ // closed run is followed by a prose slice, even an empty one, so only an
1028
+ // unclosed one leaves the segments ending on a code index. Named, not
1029
+ // last: a rewrite may have moved it, and it is the segment that ran to
1030
+ // the text's end when it was cut, wherever it now sits.
1031
+ const unterminated = segments.length % 2 === 0 ? code.length - 1 : -1;
1032
+ // Where each code segment ends up in the converted text, which is not
1033
+ // where it started: the prose around it changes length as it is
1034
+ // rewritten, and the masks are the only record of where it went. Split
1035
+ // on the names, so the pieces alternate prose and the name of the
1036
+ // segment that follows it.
1037
+ const pieces = convertInline(masked).split(new RegExp(`${mask}(\\d+)[ \\n]?${mask}`));
1038
+ const spans = [];
1039
+ let converted = pieces[0] ?? '';
1040
+ for (let piece = 1; piece < pieces.length; piece += 2) {
1041
+ const named = Number(pieces[piece]);
1042
+ const segment = code[named] ?? '';
1043
+ // The unterminated fence has no end to be past, so nothing may be
1044
+ // appended after it either — recording the end as it looks would say a
1045
+ // marker placed at the text's end was safely outside.
1046
+ const ends = named === unterminated
1047
+ ? Number.POSITIVE_INFINITY
1048
+ : converted.length + segment.length;
1049
+ spans.push([converted.length, ends]);
1050
+ converted += `${segment}${pieces[piece + 1] ?? ''}`;
1051
+ }
1052
+ return convertHeadings(converted, spans);
728
1053
  };
729
1054
  const splitForSlack = (text) => {
730
1055
  if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
@@ -775,23 +1100,17 @@ const APPROVAL_ACTIONS = {
775
1100
  * agent does unattended, and an approver should see the scope they granted
776
1101
  * in the record of granting it.
777
1102
  *
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.
1103
+ * What it lasts rode in on the request as `always` (see `AlwaysMeans`),
1104
+ * so the line names the grant that was made rather than hedging between
1105
+ * lifetimes — which this message once had to, when nothing on the bus said
1106
+ * whether an "always" was the session's or the file's. One thing the
1107
+ * request still cannot promise is the disk write: the daemon does not
1108
+ * write over a whitelist it could not read, and by then this message has
1109
+ * been sent. The daemon's log has that line; `stratus grants <agent>` has
1110
+ * the list.
791
1111
  */
792
1112
  const OUTCOME_TEXT = {
793
1113
  'decided:once': 'Allowed once',
794
- 'decided:always': 'Allowed and remembered — for this session at least',
795
1114
  'decided:deny': 'Denied',
796
1115
  'timeout:deny': 'Expired without an answer — denied',
797
1116
  // Covers both endings that reach here: the turn was aborted, and the
@@ -799,6 +1118,49 @@ const OUTCOME_TEXT = {
799
1118
  'cancelled:deny': 'Cancelled before anyone answered — denied',
800
1119
  'undeliverable:deny': 'Could not be put to an approver — denied',
801
1120
  };
1121
+ /**
1122
+ * What **Always allow** did, by the grant the request said it would make.
1123
+ * A tool grant names the agent because that is its scope — not this
1124
+ * thread, not the session — and says how to take it back, since a grant
1125
+ * an approver cannot find later is a ratchet.
1126
+ */
1127
+ const alwaysOutcome = (always, agentName, agentId) => {
1128
+ switch (always) {
1129
+ case 'scope':
1130
+ return 'Allowed, and this command\'s scope now runs without asking — until revoked';
1131
+ case 'origin':
1132
+ return 'Allowed, and this site is now acted on without asking — until revoked';
1133
+ case 'tool':
1134
+ return `Allowed, and granted to ${escapeSlackText(agentName)} until revoked (stratus grants ${escapeSlackText(agentId)})`;
1135
+ case 'session':
1136
+ return 'Allowed for the rest of this session';
1137
+ default:
1138
+ // An `always` submitted through `POST /approvals` against a request
1139
+ // this daemon did not label — an older daemon's event, say. The
1140
+ // request did not say, so neither does this.
1141
+ return 'Allowed and remembered — for this session at least';
1142
+ }
1143
+ };
1144
+ /**
1145
+ * What **Always allow** would do, said beside the button. The lifetime is
1146
+ * the half of the question the button widens, and it differs by tool: a
1147
+ * standing grant to the agent is a larger thing to hand out than a session,
1148
+ * and an approver should know which they are clicking.
1149
+ */
1150
+ const alwaysOffer = (always, agentName) => {
1151
+ switch (always) {
1152
+ case 'tool':
1153
+ return `*Always allow* grants this tool to ${escapeSlackText(agentName)} until an operator revokes it.`;
1154
+ case 'session':
1155
+ return '*Always allow* stops this tool asking again for the rest of this session.';
1156
+ case 'scope':
1157
+ return '*Always allow* remembers this command\'s scope for this agent, until revoked.';
1158
+ case 'origin':
1159
+ return '*Always allow* remembers this site for this agent, until revoked.';
1160
+ default:
1161
+ return undefined;
1162
+ }
1163
+ };
802
1164
  /** Comfortably inside Slack's 3000-character section limit. */
803
1165
  const APPROVAL_INPUT_LIMIT = 1400;
804
1166
  /** One line in a notification; the blocks carry the readable version. */
@@ -824,10 +1186,11 @@ const MAX_LISTED_ATTACHMENTS = 5;
824
1186
  * What a message's attachments are called, for a turn that cannot open
825
1187
  * them. Without this the agent is handed "here's the log" and no log, and
826
1188
  * answers as though it had read something — the note is what lets it say
827
- * the true thing instead.
1189
+ * the true thing instead. Images the model was actually shown are not
1190
+ * listed here: they travel on the dispatch, name included.
828
1191
  */
829
1192
  const attachmentNote = (files) => {
830
- if (!files || files.length === 0) {
1193
+ if (files.length === 0) {
831
1194
  return '';
832
1195
  }
833
1196
  const names = files.slice(0, MAX_LISTED_ATTACHMENTS).map((file) => file.name ?? file.title ?? 'an unnamed file');
@@ -835,6 +1198,150 @@ const attachmentNote = (files) => {
835
1198
  const listed = rest > 0 ? `${names.join(', ')}, and ${rest} more` : names.join(', ');
836
1199
  return `\n[Attached: ${listed}. Attachment contents cannot be read here — say so rather than guessing at them.]`;
837
1200
  };
1201
+ /** What Slack calls a file, in a warning about it. */
1202
+ const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed file';
1203
+ /**
1204
+ * A message's attachments, sorted into the images the model will be shown
1205
+ * and everything else. An image is fetched only when Slack's own metadata
1206
+ * says it is one the API accepts and small enough to send; that keeps a
1207
+ * 40 MB PNG from being downloaded just to be dropped, and a PDF from being
1208
+ * fetched at all. What comes back is checked as well: without `files:read`
1209
+ * Slack serves a sign-in page with a 200, and sending that to the model as
1210
+ * an image would fail the turn with an error naming nothing the person can
1211
+ * fix — the warning here names the scope instead, and the file falls back
1212
+ * to the by-name note like any other attachment the turn cannot open.
1213
+ */
1214
+ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn) => {
1215
+ // Decided from the last file back, the way the replay window is spent:
1216
+ // when a message's images do not all fit, the ones kept are the ones it
1217
+ // listed last, so intake and replay agree about which survive. Delivered
1218
+ // in the message's own order, because that is how the person sees them.
1219
+ const kept = new Map();
1220
+ const dropped = new Set();
1221
+ // The message's one deadline, shared by every download in it.
1222
+ const deadline = AbortSignal.timeout(timeoutMs);
1223
+ // Decoded bytes accepted so far. Checked against Slack's reported size
1224
+ // before a download and the real length after it, because the message
1225
+ // as a whole has a budget the per-image cap alone cannot keep.
1226
+ let total = 0;
1227
+ // Once an image that fits on its own does not fit what is left of the
1228
+ // message's budget, nothing listed before it is taken either: the window
1229
+ // is contiguous, as the replay window is, and a smaller older image must
1230
+ // not slip in past a larger newer one that was left out.
1231
+ let windowClosed = false;
1232
+ // Why an image is being left out, or nothing when it fits. One place for
1233
+ // both limits so the warning and the fallback cannot disagree.
1234
+ const overLimit = (label, bytes) => {
1235
+ if (bytes > IMAGE_ATTACHMENT_MAX_BYTES) {
1236
+ 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.`;
1237
+ }
1238
+ if (total + bytes > IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES) {
1239
+ windowClosed = true;
1240
+ 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.`;
1241
+ }
1242
+ return undefined;
1243
+ };
1244
+ for (let position = files.length - 1; position >= 0; position -= 1) {
1245
+ const file = files[position];
1246
+ const url = file.url_private_download ?? file.url_private;
1247
+ if (!isImageAttachmentMediaType(file.mimetype) || url === undefined) {
1248
+ dropped.add(position);
1249
+ continue;
1250
+ }
1251
+ if (windowClosed) {
1252
+ 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.`);
1253
+ dropped.add(position);
1254
+ continue;
1255
+ }
1256
+ const tooBig = file.size === undefined ? undefined : overLimit(fileLabel(file), file.size);
1257
+ if (tooBig !== undefined) {
1258
+ warn(tooBig);
1259
+ dropped.add(position);
1260
+ continue;
1261
+ }
1262
+ // What this image may weigh: the per-image cap, or what is left of the
1263
+ // message's budget if that is less. Handed to the fetcher so an
1264
+ // oversized response is abandoned mid-body rather than buffered whole.
1265
+ const maxBytes = Math.min(IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES - total);
1266
+ if (deadline.aborted) {
1267
+ 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.`);
1268
+ dropped.add(position);
1269
+ continue;
1270
+ }
1271
+ let download;
1272
+ try {
1273
+ download = await fetchFile(url, botToken, deadline, maxBytes);
1274
+ }
1275
+ catch (error) {
1276
+ const reason = error instanceof Error && error.name === 'TimeoutError'
1277
+ ? `this message's downloads took longer than ${timeoutMs}ms`
1278
+ : (error instanceof Error ? error.message : String(error));
1279
+ warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
1280
+ dropped.add(position);
1281
+ continue;
1282
+ }
1283
+ // Refused on what Slack sends when it will not serve the file — a
1284
+ // non-200, or a 200 whose body is a page — rather than on the label of
1285
+ // what it does serve: a download URL may be typed as the image or as
1286
+ // a plain octet stream, and either is the bytes Slack said it was.
1287
+ const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
1288
+ if (download.status !== 200 || contentType.startsWith('text/')) {
1289
+ 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.`);
1290
+ dropped.add(position);
1291
+ continue;
1292
+ }
1293
+ if (download.truncated === true) {
1294
+ // Cut off at what was left of the message's budget, rather than at
1295
+ // the per-image cap: the same overflow the size check closes the
1296
+ // window on, learned from the bytes instead of the metadata.
1297
+ if (maxBytes < IMAGE_ATTACHMENT_MAX_BYTES) {
1298
+ windowClosed = true;
1299
+ }
1300
+ 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.`);
1301
+ dropped.add(position);
1302
+ continue;
1303
+ }
1304
+ const stillTooBig = overLimit(fileLabel(file), download.body.length);
1305
+ if (stillTooBig !== undefined) {
1306
+ warn(stillTooBig);
1307
+ dropped.add(position);
1308
+ continue;
1309
+ }
1310
+ // Read from the bytes, not from Slack's metadata: an image the model
1311
+ // API refuses fails the turn it arrives on and, once stored, every turn
1312
+ // after — so what is stored has to be what the API takes.
1313
+ const dimensions = imageDimensions(download.body, file.mimetype);
1314
+ if (dimensions === undefined) {
1315
+ warn(`slack: ${fileLabel(file)} is not a complete ${file.mimetype} — its header or trailer is missing; the turn is told it cannot be read.`);
1316
+ dropped.add(position);
1317
+ continue;
1318
+ }
1319
+ if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
1320
+ 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.`);
1321
+ dropped.add(position);
1322
+ continue;
1323
+ }
1324
+ total += download.body.length;
1325
+ const name = file.name ?? file.title;
1326
+ kept.set(position, {
1327
+ mediaType: file.mimetype,
1328
+ data: download.body.toString('base64'),
1329
+ ...(name !== undefined ? { name } : {}),
1330
+ });
1331
+ }
1332
+ const images = [];
1333
+ const unread = [];
1334
+ files.forEach((file, position) => {
1335
+ const image = kept.get(position);
1336
+ if (image !== undefined) {
1337
+ images.push(image);
1338
+ }
1339
+ else if (dropped.has(position)) {
1340
+ unread.push(file);
1341
+ }
1342
+ });
1343
+ return { images, unread };
1344
+ };
838
1345
  /**
839
1346
  * Whether `text` addresses a bot user by name. Slack writes a mention as
840
1347
  * `<@U123>` markup whoever typed it, so this is the same question for an
@@ -877,8 +1384,9 @@ const renderInvocation = (input) => {
877
1384
  truncated,
878
1385
  };
879
1386
  };
880
- const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot) => {
1387
+ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot, always) => {
881
1388
  const invocation = renderInvocation(input);
1389
+ const offer = oneShot ? undefined : alwaysOffer(always, agentName);
882
1390
  return [
883
1391
  {
884
1392
  type: 'section',
@@ -905,7 +1413,8 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
905
1413
  elements: [{ type: 'mrkdwn', text: ':warning: Arguments shown are truncated — the full call is longer than this.' }],
906
1414
  }]
907
1415
  : []),
908
- // Why there is no second button, when there is no second button.
1416
+ // Why there is no second button, when there is no second button — and
1417
+ // what the second button does, when there is one.
909
1418
  ...(oneShot
910
1419
  ? [{
911
1420
  type: 'context',
@@ -914,7 +1423,9 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
914
1423
  text: ':lock: An approval covers this call only — nothing about it is remembered.',
915
1424
  }],
916
1425
  }]
917
- : []),
1426
+ : offer !== undefined
1427
+ ? [{ type: 'context', elements: [{ type: 'mrkdwn', text: offer }] }]
1428
+ : []),
918
1429
  {
919
1430
  type: 'actions',
920
1431
  // The request id rides on every button rather than in the message
@@ -962,6 +1473,9 @@ export const createSlackChannelAdapter = (options) => {
962
1473
  const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
963
1474
  const createSocket = options.createSocketClient ?? defaultSocketClient;
964
1475
  const createWeb = options.createWebClient ?? defaultWebClient;
1476
+ const fetchFile = options.fetchFile ?? defaultFetchFile;
1477
+ const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
1478
+ const now = options.now ?? Date.now;
965
1479
  const connections = [];
966
1480
  // Every agent this adapter was asked to carry, connected or not — the
967
1481
  // difference between "mine and broken" and "not mine".
@@ -1121,6 +1635,39 @@ export const createSlackChannelAdapter = (options) => {
1121
1635
  return userId;
1122
1636
  }
1123
1637
  };
1638
+ /**
1639
+ * A display name as the model may read it: one line, bounded. It is text
1640
+ * its owner typed into their profile, and it lands in the speaker
1641
+ * position of a user turn (`Name: text`) or as `@Name` inside one — where
1642
+ * a newline, or a name long enough to be a paragraph, would let its owner
1643
+ * speak in a place nobody vouched for. Escaped rather than dropped, the
1644
+ * way memory entries are, so a strange name still reads as strange.
1645
+ */
1646
+ const boundedDisplayName = (name) => {
1647
+ // Code points, not UTF-16 units: an emoji is one character of a name,
1648
+ // and a cut inside a surrogate pair is a malformed speaker.
1649
+ const characters = Array.from(escapeControlCharacters(name).trim());
1650
+ return characters.length > MAX_DISPLAY_NAME_LENGTH
1651
+ ? `${characters.slice(0, MAX_DISPLAY_NAME_LENGTH - 1).join('')}…`
1652
+ : characters.join('');
1653
+ };
1654
+ /**
1655
+ * Who is speaking, as the model reads it. A principal's profile is
1656
+ * trusted the way their messages are, so it is their display name. With
1657
+ * a principals list in force everyone else is their stable id, as a
1658
+ * mention of them already is: named, not quoted. With no list at all
1659
+ * there is nobody to prefer, so every author keeps their name — the turn
1660
+ * is labelled `unknown` either way. "No list" is the key being absent:
1661
+ * an explicit empty list is how an agent is excluded from a shared one,
1662
+ * and it means nobody is preferred, not that nobody has been named.
1663
+ */
1664
+ const authorFor = async (connection, userId) => {
1665
+ const principals = connection.config.principals;
1666
+ if (principals !== undefined && !principals.includes(userId)) {
1667
+ return userId;
1668
+ }
1669
+ return boundedDisplayName(await displayNameFor(connection, userId));
1670
+ };
1124
1671
  // Mentions arrive as <@U123> markup; the model should read names.
1125
1672
  /**
1126
1673
  * `<@U…>` mentions become `@name` — for principals only. A display name
@@ -1139,12 +1686,218 @@ export const createSlackChannelAdapter = (options) => {
1139
1686
  let result = withoutBot;
1140
1687
  for (const id of new Set(ids)) {
1141
1688
  if (principals.has(id)) {
1142
- result = result.replaceAll(`<@${id}>`, `@${await displayNameFor(connection, id)}`);
1689
+ result = result.replaceAll(`<@${id}>`, `@${boundedDisplayName(await displayNameFor(connection, id))}`);
1143
1690
  }
1144
1691
  }
1145
1692
  return result.trim();
1146
1693
  };
1147
1694
  const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
1695
+ /** An agent's configuration, whether or not its socket came up. */
1696
+ const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
1697
+ /**
1698
+ * Whether an agent takes a message from this sender at all — judged on
1699
+ * its configuration, not on a live connection: an agent whose socket
1700
+ * failed to start is still recognizable in a mention, and its door is
1701
+ * still its own. An agent this adapter was never configured for is
1702
+ * nobody's to refuse here, so it admits — the answer from before the
1703
+ * door existed.
1704
+ */
1705
+ const admitsSender = (config, userId) => config === undefined
1706
+ || config.admit !== 'principals'
1707
+ || (config.principals ?? []).includes(userId);
1708
+ /**
1709
+ * How an agent listens, from its soul — read per message, so a roster
1710
+ * reload takes effect on the next one. `thread` when the soul says
1711
+ * nothing, which is every soul written before there was a choice.
1712
+ */
1713
+ const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
1714
+ /**
1715
+ * Whether an agent that judges is still paying attention to a thread:
1716
+ * within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
1717
+ * answered a message that addressed it, both read from the session
1718
+ * rather than remembered here, so a restart forgets nothing. Anchored
1719
+ * to an addressed answer and not to any reply: a reply the agent chose
1720
+ * to give on a turn nobody asked for does not re-arm it, or a talkative
1721
+ * judge would keep itself attentive for good and cost a call per message
1722
+ * — the self-extension the spec declined. An agent never addressed in
1723
+ * the thread is not attentive: it was mentioned and its first turn has
1724
+ * not answered yet, or answered with nothing. Measured against the
1725
+ * message's own timestamp, not the clock, so a delivery that ran late is
1726
+ * judged as of when it was said. Bounded below as well as above: a
1727
+ * message said before the answer the window starts from is outside it
1728
+ * — Slack redelivers events a restarted daemon has no memory of, and a
1729
+ * negative age would otherwise pass the minutes bound however old the
1730
+ * message, on a turn the agent's answer already came after.
1731
+ */
1732
+ const attentive = (routing, messageTs, pending) => {
1733
+ if (routing.lastAnsweredAt === undefined) {
1734
+ return false;
1735
+ }
1736
+ const answeredAt = Date.parse(routing.lastAnsweredAt);
1737
+ const saidAt = Number(messageTs) * 1000;
1738
+ if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
1739
+ return false;
1740
+ }
1741
+ return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
1742
+ };
1743
+ /**
1744
+ * Messages this adapter has dispatched to a session that the session
1745
+ * does not hold yet. The store counts what it holds, and a burst of
1746
+ * messages typed inside one second would each read the same count and
1747
+ * each be judged — the bound the window exists to give, blown by exactly
1748
+ * the thread that needs it. The renderer queue already knows: a renderer
1749
+ * is queued at dispatch and its turn starts (`turnStarted`) only once
1750
+ * the runner has saved the message — the same moment the store's count
1751
+ * takes it over — so the two never count one message twice.
1752
+ */
1753
+ const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
1754
+ + (pendingObserves.get(sessionId) ?? 0);
1755
+ /**
1756
+ * Overhears placed on the gateway's chain and not yet written, per
1757
+ * session: the other kind of message the store does not hold yet. An
1758
+ * observe queued behind a running turn is heard only when the turn ends,
1759
+ * and a burst of messages to a colleague would otherwise count toward
1760
+ * nothing until then. Counted from placement to settlement.
1761
+ */
1762
+ const pendingObserves = new Map();
1763
+ const placeObserve = (sessionId, observed) => {
1764
+ pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
1765
+ const release = () => {
1766
+ const left = (pendingObserves.get(sessionId) ?? 1) - 1;
1767
+ if (left > 0) {
1768
+ pendingObserves.set(sessionId, left);
1769
+ }
1770
+ else {
1771
+ pendingObserves.delete(sessionId);
1772
+ }
1773
+ };
1774
+ void observed.then(release, release);
1775
+ return observed;
1776
+ };
1777
+ /**
1778
+ * Run `work` after everything already queued for this session's intake,
1779
+ * and hold the next link until it settles. The link is claimed
1780
+ * synchronously, so a message's place is fixed by when the adapter
1781
+ * learned of it and not by how long its lookups take — see
1782
+ * `handleInbound`, whose ordering this is.
1783
+ */
1784
+ const onIntakeChain = (sessionId, work) => {
1785
+ const previous = intakeChains.get(sessionId) ?? Promise.resolve();
1786
+ const link = previous.then(work);
1787
+ const settled = link.then(() => undefined, () => undefined);
1788
+ intakeChains.set(sessionId, settled);
1789
+ void settled.then(() => {
1790
+ if (intakeChains.get(sessionId) === settled) {
1791
+ intakeChains.delete(sessionId);
1792
+ }
1793
+ });
1794
+ return link;
1795
+ };
1796
+ /**
1797
+ * What one agent REPLIED in a thread, heard by the thread's other agents.
1798
+ *
1799
+ * The other half of overhearing. What people say reaches an agent through
1800
+ * its socket (`admit`), but a colleague's reply is a bot message the
1801
+ * socket ignores on purpose, and its final text reaches Slack as a stream
1802
+ * of edits rather than one event — so it is not an inbound path at all.
1803
+ * The adapter that rendered the reply is the one thing that knows its
1804
+ * final text, and it observes that into the session every other agent
1805
+ * holds for the same thread, with no Slack event involved. Without this
1806
+ * an agent that followed a thread knew the questions its colleague was
1807
+ * asked and not the answers.
1808
+ *
1809
+ * Each hearer takes it on its own intake chain, claimed the moment the
1810
+ * reply is final: a message typed while the reply was still streaming
1811
+ * is already in that chain and is heard first, and one typed after the
1812
+ * final edit cannot overtake it. The observe itself is placed there and
1813
+ * not awaited inside the link — returned in a wrapper, since a promise
1814
+ * returned from the link is one the chain adopts — for the reason
1815
+ * `handleInbound` gives: a hearer with a long turn running must not hold
1816
+ * its next message's place hostage to that turn.
1817
+ *
1818
+ * Only once it is SAID, in full. The reservation is taken when the reply
1819
+ * is final, but the observe waits, inside the link, on `published` —
1820
+ * whether Slack took every edit and post of it — and a reply Slack
1821
+ * refused any part of is heard by nobody, since the thread did not see
1822
+ * it as written and the hearer has no way to know which part. That holds
1823
+ * the hearer's chain for the length of the final edit, which is the
1824
+ * same network round trip a person's message holds it for.
1825
+ *
1826
+ * Two boundaries decide who hears it, and neither is "same workspace".
1827
+ * Whether the hearer is in the THREAD is the gateway's answer on the
1828
+ * session's chain, as for every overhear: an agent never invited here
1829
+ * has no session under the key. Whether it is still in the CHANNEL is
1830
+ * Slack's, asked per reply: a message a person types reaches an agent
1831
+ * only through its own socket, so an app removed from a private channel
1832
+ * simply stops hearing — but a reply is forwarded by this process, and
1833
+ * would keep reaching an agent the operator took out of the room. That
1834
+ * is the one path where membership has to be checked rather than
1835
+ * assumed, and a lookup that fails leaves the agent not hearing, the
1836
+ * direction a boundary should fail in.
1837
+ *
1838
+ * Sent under the speaker's own label rather than `user`: an agent's
1839
+ * reply is at most its own word, and carries whatever its session has
1840
+ * been exposed to — a restatement of a stranger's text is still the
1841
+ * stranger's, which is exactly what `sessionWriteTrust` says.
1842
+ *
1843
+ * Only replies: a turn that said nothing posts `(no reply)`, which is
1844
+ * nothing to hear, and a failed turn's error note is the failure's, not
1845
+ * the agent's.
1846
+ */
1847
+ const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
1848
+ const gateway = gatewayRef;
1849
+ if (!gateway?.observe || reply.trim().length === 0) {
1850
+ return;
1851
+ }
1852
+ const team = speaker.teamId;
1853
+ const metadata = {
1854
+ channel: 'slack',
1855
+ team,
1856
+ slackChannel: channel,
1857
+ slackUser: speaker.botUserId,
1858
+ slackThread: thread,
1859
+ [SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(spoken),
1860
+ };
1861
+ // In the reply's own workspace only, because a session key carries a
1862
+ // team — the same reason `resolveFollowUpWinner` stays in-workspace.
1863
+ const hearers = connections.filter((candidate) => candidate.teamId === team && candidate.config.agentId !== speaker.config.agentId);
1864
+ await Promise.all(hearers.map(async (hearer) => {
1865
+ const sessionId = channelSessionKey({
1866
+ channel: 'slack',
1867
+ agentId: hearer.config.agentId,
1868
+ team,
1869
+ conversation: channel,
1870
+ thread,
1871
+ });
1872
+ try {
1873
+ const { observed } = await onIntakeChain(sessionId, async () => {
1874
+ if (!(await published)) {
1875
+ return { observed: undefined };
1876
+ }
1877
+ let member;
1878
+ try {
1879
+ member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
1880
+ }
1881
+ catch (error) {
1882
+ 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)}`);
1883
+ }
1884
+ if (member !== true) {
1885
+ return { observed: undefined };
1886
+ }
1887
+ // Named the way a person's message names its speaker: whoever
1888
+ // spoke, by display name. A bot user's is the app's own, set by
1889
+ // whoever installed it.
1890
+ const author = await displayNameFor(hearer, speaker.botUserId);
1891
+ const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${reply}`, metadata });
1892
+ return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
1893
+ });
1894
+ await observed;
1895
+ }
1896
+ catch (error) {
1897
+ warn(`slack: ${hearer.config.agentId} could not overhear ${speaker.config.agentId}'s reply in ${channel}: ${error instanceof Error ? error.message : String(error)}`);
1898
+ }
1899
+ }));
1900
+ };
1148
1901
  /**
1149
1902
  * The addressable outbound seam — the channel contract's first real
1150
1903
  * `OutboundConnection` implementation. Validate-then-hand-over: every
@@ -1293,7 +2046,7 @@ export const createSlackChannelAdapter = (options) => {
1293
2046
  + `${event.origin ? ` on ${event.origin}` : ''}`
1294
2047
  + `${invocationSummary ? `: ${invocationSummary}` : ''}.`),
1295
2048
  ...(thread ? { thread_ts: thread } : {}),
1296
- blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true),
2049
+ blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true, event.always),
1297
2050
  });
1298
2051
  post = posted.ts
1299
2052
  ? {
@@ -1305,6 +2058,8 @@ export const createSlackChannelAdapter = (options) => {
1305
2058
  toolName: event.call.toolName,
1306
2059
  risk: event.risk,
1307
2060
  oneShot: event.oneShot === true,
2061
+ ...(event.always !== undefined ? { always: event.always } : {}),
2062
+ agentId: event.agentId,
1308
2063
  approvers,
1309
2064
  }
1310
2065
  : undefined;
@@ -1409,8 +2164,17 @@ export const createSlackChannelAdapter = (options) => {
1409
2164
  ? post.risk === 'dangerous'
1410
2165
  ? 'Allowed once — a dangerous tool is never remembered'
1411
2166
  : 'Allowed once — nothing about this call could be remembered'
1412
- : OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
1413
- const by = event.actor ? ` by <@${event.actor}>` : '';
2167
+ : event.reason === 'decided' && event.answer === 'always'
2168
+ ? alwaysOutcome(post.always, post.agentName, post.agentId)
2169
+ : OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
2170
+ // A mention only for an actor this post knows as a Slack user — its
2171
+ // approvers, which is the only set a click can come from. An approval
2172
+ // decided through the control API arrives as `api:…` or `dashboard`,
2173
+ // and `<@api:ops-bot>` is not a mention, it is a broken one; escaped
2174
+ // plain text says who it was.
2175
+ const by = event.actor
2176
+ ? ` by ${post.approvers.has(event.actor) ? `<@${event.actor}>` : escapeSlackText(event.actor)}`
2177
+ : '';
1414
2178
  const text = `*${escapeSlackText(post.agentName)}* — \`${escapeSlackText(post.toolName)}\` (${post.risk}): ${outcome}${by}.`;
1415
2179
  try {
1416
2180
  await post.connection.web.chat.update({
@@ -1445,9 +2209,13 @@ export const createSlackChannelAdapter = (options) => {
1445
2209
  * cost the rest of the answer.
1446
2210
  */
1447
2211
  const postAheadOf = async (behind, connection, channel, thread, chunks, between) => {
1448
- if (behind && await behind.take(chunks, between)) {
1449
- return;
2212
+ const taken = behind ? await behind.take(chunks, between) : 'untaken';
2213
+ if (taken !== 'untaken') {
2214
+ return taken === 'complete';
1450
2215
  }
2216
+ // Whether ALL of it landed, as `finalize` answers: a reply the thread
2217
+ // saw part of is not one anybody else may be told the whole of.
2218
+ let published = true;
1451
2219
  for (const chunk of chunks) {
1452
2220
  try {
1453
2221
  await connection.web.chat.postMessage({
@@ -1457,12 +2225,14 @@ export const createSlackChannelAdapter = (options) => {
1457
2225
  });
1458
2226
  }
1459
2227
  catch (error) {
2228
+ published = false;
1460
2229
  warn(`slack: could not post part of a turn's outcome: ${error instanceof Error ? error.message : String(error)}`);
1461
2230
  }
1462
2231
  }
1463
2232
  if (between) {
1464
2233
  await between();
1465
2234
  }
2235
+ return published;
1466
2236
  };
1467
2237
  /**
1468
2238
  * The files an unrendered turn produced, taken off the session the moment
@@ -1519,6 +2289,26 @@ export const createSlackChannelAdapter = (options) => {
1519
2289
  }
1520
2290
  const channel = metadata.slackChannel;
1521
2291
  const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
2292
+ if (routing.unaddressed === true && routing.reply === undefined) {
2293
+ // A turn nobody asked for that said nothing before it broke — the
2294
+ // daemon died inside it, and the next start failed it with no
2295
+ // renderer here to keep quiet for it. The same rule as the lazy
2296
+ // renderer's: the failure is in the log, and the files it produced
2297
+ // still land, since a file is not nothing to say.
2298
+ //
2299
+ // "Said nothing" as the session can tell it: no reply saved for the
2300
+ // turn. A line the old process had streamed into a placeholder and
2301
+ // never saved is invisible here — the session records what was
2302
+ // said, not what Slack took (roadmap 31's open item) — so such a
2303
+ // turn is failed without a note under that line. Taken over the
2304
+ // alternative on purpose: a note for every judged turn a restart
2305
+ // caught turns a silent turn, the common case, into the
2306
+ // interruption it existed to avoid; a line left standing happens
2307
+ // only to a turn that had already chosen to speak.
2308
+ warn(`slack: a turn nobody asked for failed before saying anything: ${event.error}`);
2309
+ await uploadUnrenderedFiles(connection, channel, thread, files);
2310
+ return;
2311
+ }
1522
2312
  await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), () => uploadUnrenderedFiles(connection, channel, thread, files));
1523
2313
  };
1524
2314
  /**
@@ -1567,7 +2357,14 @@ export const createSlackChannelAdapter = (options) => {
1567
2357
  await uploads();
1568
2358
  return;
1569
2359
  }
1570
- await postAheadOf(behind, connection, channel, thread, posting, uploads);
2360
+ // A reply the thread's other agents hear like any other, taking their
2361
+ // place for it before the post, as `handleInbound` does.
2362
+ const published = postAheadOf(behind, connection, channel, thread, posting, uploads);
2363
+ const heard = routing.reply !== undefined && thread !== undefined
2364
+ ? overhearReply(connection, channel, thread, routing.reply, routing, published)
2365
+ : undefined;
2366
+ await published;
2367
+ await heard;
1571
2368
  log(`slack: posted the reply of a turn finished after a restart to ${channel}`);
1572
2369
  };
1573
2370
  const handleInteractive = async (connection, args) => {
@@ -1647,14 +2444,25 @@ export const createSlackChannelAdapter = (options) => {
1647
2444
  * Records that `agentId` holds the thread from the message at `ts`
1648
2445
  * onwards, wherever in the thread's history that message falls.
1649
2446
  *
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
2447
+ * Every claim is kept, not only changes of hands. A claim by the agent
2448
+ * already holding the thread looks redundant until another agent's reply
2449
+ * is placed between it and the one before — a judge's reply posted below
2450
+ * the holder's older message and above its newer one — at which point it
2451
+ * is what keeps the newer message the holder's; dropped, the judge's
2452
+ * later insertion would take the thread from a reply that sits below it.
2453
+ * Only the same claim twice — the same message reaching a second
2454
+ * connection — is dropped.
2455
+ * The oldest claims are dropped past `THREAD_HANDOVER_DEPTH`, and the
1654
2456
  * least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
1655
2457
  * losses cost a lookup, never an answer, since the sessions still hold
1656
2458
  * the durable version.
1657
2459
  *
2460
+ * `ts` is a message's place in the thread: the message that handed the
2461
+ * thread over at intake, or — once a reply has landed — the reply's own
2462
+ * (`ReplyOutcome.spokeAt`), which is what puts a judge's reply posted
2463
+ * below a colleague's placeholder after it, and one streamed above it
2464
+ * before, whichever turn finished first.
2465
+ *
1658
2466
  * Slack timestamps compare as strings: `seconds.microseconds`, both
1659
2467
  * parts fixed width, so lexicographic order is chronological order.
1660
2468
  */
@@ -1665,9 +2473,7 @@ export const createSlackChannelAdapter = (options) => {
1665
2473
  at -= 1;
1666
2474
  }
1667
2475
  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.
2476
+ if (before && before.agentId === agentId && before.ts === ts) {
1671
2477
  return;
1672
2478
  }
1673
2479
  handovers.splice(at, 0, { agentId, ts });
@@ -1724,10 +2530,17 @@ export const createSlackChannelAdapter = (options) => {
1724
2530
  * so is one whose `auth.test` itself failed, since its bot id was never
1725
2531
  * learned.
1726
2532
  */
1727
- const agentNamedIn = (text, team) => {
2533
+ const agentNamedIn = (text, team,
2534
+ /**
2535
+ * Whether a named agent would take this message at all. A message
2536
+ * naming two agents hands the thread to one that admits the sender,
2537
+ * not to the first one named: the agent that refuses will not answer,
2538
+ * and a handover to it is a thread nobody holds.
2539
+ */
2540
+ admits = () => true) => {
1728
2541
  let offline;
1729
2542
  for (const [agentId, identity] of botIdentities) {
1730
- if (identity.teamId !== team || !mentions(text, identity.botUserId)) {
2543
+ if (identity.teamId !== team || !mentions(text, identity.botUserId) || !admits(agentId)) {
1731
2544
  continue;
1732
2545
  }
1733
2546
  if (connections.some((live) => live.config.agentId === agentId)) {
@@ -1896,10 +2709,14 @@ export const createSlackChannelAdapter = (options) => {
1896
2709
  * the dedupe and the handover record included, which are the two pieces a
1897
2710
  * later message reads.
1898
2711
  *
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.
2712
+ * `undefined` means the message is nothing to this agent — a DM to
2713
+ * someone else, the room rather than a thread, its own words.
2714
+ * `overhear: true` means it is somebody else's to answer and this
2715
+ * agent's to hear, if it is in the thread at all, which only the sessions
2716
+ * can say. `settled: false` means only the sessions can decide even that
2717
+ * much, which is the one case that has to wait — and it is the cold
2718
+ * case, where by definition no message of this thread has been seen in
2719
+ * this process.
1903
2720
  */
1904
2721
  const admit = (connection, args) => {
1905
2722
  const event = args.event;
@@ -1912,6 +2729,9 @@ export const createSlackChannelAdapter = (options) => {
1912
2729
  if (event.user === connection.botUserId) {
1913
2730
  return undefined;
1914
2731
  }
2732
+ // Bound once the guard above has run: the closures below cannot see
2733
+ // that narrowing.
2734
+ const sender = event.user;
1915
2735
  const isDm = event.channel_type === 'im';
1916
2736
  const team = args.body?.team_id ?? connection.teamId;
1917
2737
  // A top-level mention has no thread_ts; its own ts roots the reply
@@ -1932,9 +2752,76 @@ export const createSlackChannelAdapter = (options) => {
1932
2752
  // handover, Ava could take the next untagged reply while Bea's socket
1933
2753
  // was still behind, and answer a question that had just been handed
1934
2754
  // away. The message Ava must record is exactly the one Ava ignores.
1935
- const named = agentNamedIn(text, team);
1936
- if (threadKey && named) {
1937
- rememberAddressee(threadKey, named, event.ts);
2755
+ // Who the message is for, judged on the named agents' own terms rather
2756
+ // than this one's: agents may list different principals, and a sender
2757
+ // Ava admits but Bea refuses must not be able to hand Bea the thread
2758
+ // from Ava's socket — Bea's own socket refuses the same message, and
2759
+ // the map is shared. A message naming Bea and Cy is Cy's, who will
2760
+ // answer it and hold the thread. One naming only agents that refuse
2761
+ // the sender is still theirs and not this agent's to answer — so the
2762
+ // refusing agent stays `named`, and only the handover is withheld.
2763
+ const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
2764
+ const named = handoverTo ?? agentNamedIn(text, team);
2765
+ // The key one Slack MESSAGE is known by, whichever delivery carried
2766
+ // it; see the dedupe below for why it is not the event id.
2767
+ const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
2768
+ // Recorded ahead of this agent's own refusal below, and on the named
2769
+ // agent's admission rather than this one's: a sender Ava refuses and
2770
+ // Bea admits has handed Bea the thread, and Ava's socket may be the
2771
+ // one that hears it first. If Ava withheld the record, a fast untagged
2772
+ // follow-up from someone Ava does admit would go to the old holder on
2773
+ // Ava's socket and to Bea once Bea's socket caught up — two answers to
2774
+ // one message. The refused message itself still takes no place in
2775
+ // Ava's transcript; only the map learns who holds the thread.
2776
+ if (threadKey && handoverTo) {
2777
+ rememberAddressee(threadKey, handoverTo, event.ts);
2778
+ }
2779
+ // Who may speak at all, judged before anything below remembers this
2780
+ // message. The adapter's own checks establish nothing about who is
2781
+ // typing; under `admit: 'principals'` the operator's list is the door,
2782
+ // not a label, and a sender not on it gets neither a turn nor a place
2783
+ // in a transcript the agent reads — nor a say in who holds the thread
2784
+ // beyond what the named agent's own admission above allowed.
2785
+ // Logged rather than answered — a reply is a conversation with someone
2786
+ // the operator chose not to have one with — and logged only for a
2787
+ // message this agent would have taken up: a mention, or an untagged
2788
+ // reply inside a thread this agent is known to hold. A channel
2789
+ // subscribed for thread follow-through delivers every post and every
2790
+ // reply in every thread, including the threads this agent was never
2791
+ // part of, and a line per stranger per reply is a log with nothing
2792
+ // left to read. Known from what this process has seen, as the
2793
+ // admitted path below judges it; a thread nothing here remembers is
2794
+ // one the sessions would have to be asked about, and a refused message
2795
+ // does not get a place in that queue. Once per MESSAGE, on the same key
2796
+ // the admitted path dedupes on: a mention arrives twice under both
2797
+ // subscriptions, and a delivery Slack did not see acknowledged arrives
2798
+ // again, and each copy would otherwise write its own line during
2799
+ // exactly the traffic an operator reads the log to understand.
2800
+ // Consuming the key here is safe because a refused message records
2801
+ // nothing a later copy could be needed for.
2802
+ if (!admitsSender(connection.config, sender)) {
2803
+ const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
2804
+ const held = threadKey !== undefined ? holderAt(threadKey, event.ts) : undefined;
2805
+ if (addressed || held === connection.config.agentId) {
2806
+ if (!alreadySeen(eventKey)) {
2807
+ log(refusal);
2808
+ }
2809
+ }
2810
+ else if (held === undefined && event.thread_ts !== undefined && !alreadySeen(eventKey)) {
2811
+ // Nothing in memory says whose thread this is — after a restart, or
2812
+ // once the record was evicted — but the sessions may: the durable
2813
+ // half of the question the admitted path asks, and the same
2814
+ // verdict. Off the intake chain, because a refused message gets no
2815
+ // place in that queue, and the line is an audit, not a turn; `track`
2816
+ // holds it so `stop()` drains it like any other background work.
2817
+ const thread = event.thread_ts;
2818
+ track(followUpWinner({ team, conversation: event.channel, thread }, event.ts).then((winner) => {
2819
+ if (winner === connection.config.agentId) {
2820
+ log(refusal);
2821
+ }
2822
+ }));
2823
+ }
2824
+ return undefined;
1938
2825
  }
1939
2826
  if (!addressed) {
1940
2827
  if (event.thread_ts === undefined) {
@@ -1942,13 +2829,6 @@ export const createSlackChannelAdapter = (options) => {
1942
2829
  // or stands alone, is a follow-up to nothing.
1943
2830
  return undefined;
1944
2831
  }
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
2832
  }
1953
2833
  // One Slack MESSAGE, not one delivery. An app subscribed to both
1954
2834
  // `app_mention` and `message.channels` is told about a mention twice —
@@ -1960,8 +2840,9 @@ export const createSlackChannelAdapter = (options) => {
1960
2840
  // After the handover record and not before it: a redelivery re-records
1961
2841
  // the same agent, which costs nothing, while deduping first would drop
1962
2842
  // the record whenever the other copy of a doubly-delivered mention is
1963
- // the one a connection sees second.
1964
- const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
2843
+ // the one a connection sees second. And before every stand-down below,
2844
+ // because standing down is no longer nothing: an agent that hears a
2845
+ // message it is not answering must hear it once.
1965
2846
  if (alreadySeen(eventKey)) {
1966
2847
  return undefined;
1967
2848
  }
@@ -1980,6 +2861,8 @@ export const createSlackChannelAdapter = (options) => {
1980
2861
  ...(thread ? { thread } : {}),
1981
2862
  ...(threadKey ? { threadKey } : {}),
1982
2863
  settled: true,
2864
+ overhear: false,
2865
+ judge: false,
1983
2866
  };
1984
2867
  if (addressed) {
1985
2868
  // A message that named this agent and others is recorded above under
@@ -1989,9 +2872,33 @@ export const createSlackChannelAdapter = (options) => {
1989
2872
  // reading of a message that asked several.
1990
2873
  return admission;
1991
2874
  }
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.
2875
+ if (named) {
2876
+ // Somebody else was asked. Tagging an agent hands it the question,
2877
+ // and the agent that had it is no longer being asked — two answers
2878
+ // to one message is what a thread with a roster in it must never
2879
+ // produce. But the agent that had it is still in the room, and a
2880
+ // person in that position hears the question asked of their
2881
+ // colleague: it goes into this agent's session, unanswered, so the
2882
+ // next thing it is asked is asked of someone who followed along.
2883
+ return { ...admission, overhear: true };
2884
+ }
2885
+ // An untagged reply. How this agent listens decides what it is: an
2886
+ // agent that answers only mentions hears it and nothing more, and one
2887
+ // that judges hears it and, if still attentive, takes a turn nobody
2888
+ // asked for on it — decided in the intake, since only the session can
2889
+ // say. Neither takes part in the holder rule below: that rule answers
2890
+ // "whose is an untagged reply", and for these two agents the soul has
2891
+ // already answered.
2892
+ const listens = listensOf(connection.config.agentId);
2893
+ if (listens === 'mentions') {
2894
+ return { ...admission, overhear: true };
2895
+ }
2896
+ if (listens === 'judge') {
2897
+ return { ...admission, overhear: true, judge: true };
2898
+ }
2899
+ // The record is what this process has actually seen, in the order it
2900
+ // saw it — right even inside the window before a handover reaches the
2901
+ // store.
1995
2902
  const holder = threadKey === undefined ? undefined : holderAt(threadKey, event.ts);
1996
2903
  if (holder !== undefined && threadKey !== undefined) {
1997
2904
  if (holder !== connection.config.agentId) {
@@ -2004,7 +2911,10 @@ export const createSlackChannelAdapter = (options) => {
2004
2911
  // membership the adapter does not track, and would have to be
2005
2912
  // tracked in the durable path too, since a removed app is still a
2006
2913
  // live connection with the newest session there.
2007
- return undefined;
2914
+ //
2915
+ // Standing down is not leaving: the reply is the holder's, and this
2916
+ // agent hears it.
2917
+ return { ...admission, overhear: true };
2008
2918
  }
2009
2919
  rememberAddressee(threadKey, connection.config.agentId, event.ts);
2010
2920
  return admission;
@@ -2047,42 +2957,127 @@ export const createSlackChannelAdapter = (options) => {
2047
2957
  // reverse order in durable history. The chain is claimed here,
2048
2958
  // synchronously, so a message's place in it is fixed by when Slack
2049
2959
  // 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 () => {
2960
+ const intake = onIntakeChain(sessionId, async () => {
2052
2961
  // The durable half of addressing runs INSIDE the chain, for the same
2053
2962
  // reason: it awaits, and a message that had to ask the sessions must
2054
2963
  // not lose its place to one that did not. A message that turns out
2055
2964
  // not to be this agent's leaves an empty link behind, which is all it
2056
2965
  // should cost.
2966
+ let overhear = admitted.overhear;
2967
+ // Judged, not ruled: a turn nobody asked for, while the agent is
2968
+ // still attentive, and a free overhear past that. Read off the
2969
+ // chain — a session the invitation is still creating reads as not
2970
+ // there, and the message is heard rather than judged, which is the
2971
+ // safe side. A host that cannot say leaves the agent hearing.
2972
+ let judged = false;
2973
+ if (admitted.judge && gateway.sessionRouting) {
2974
+ // The pending count is taken BEFORE the store is read: a message
2975
+ // that settles between the two would be counted by neither the
2976
+ // other way round, and a window that runs one message short in a
2977
+ // race is a bound kept; one that runs a message long is not.
2978
+ const pending = pendingFor(sessionId);
2979
+ let routing;
2980
+ try {
2981
+ routing = await gateway.sessionRouting(sessionId);
2982
+ }
2983
+ catch (error) {
2984
+ warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
2985
+ }
2986
+ if (routing && attentive(routing, event.ts, pending)) {
2987
+ judged = true;
2988
+ overhear = false;
2989
+ }
2990
+ }
2057
2991
  if (!admitted.settled) {
2058
2992
  // One verdict for this message, shared with whichever other agents
2059
2993
  // are asking about it — see `followUpWinner`.
2060
2994
  const winner = await followUpWinner({ team, conversation: event.channel, thread: event.thread_ts ?? '' }, event.ts);
2061
2995
  if (winner !== connection.config.agentId) {
2062
- return undefined;
2996
+ // Not this agent's to answer — and, if it is in the thread,
2997
+ // this agent's to hear, same as the two warm stand-downs.
2998
+ overhear = true;
2063
2999
  }
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) {
3000
+ else if (threadKey) {
3001
+ // Answered from the store rather than from memory: hold it, so
3002
+ // the rest of the thread is ordered by receipt like any other.
2067
3003
  rememberAddressee(threadKey, connection.config.agentId, event.ts);
2068
3004
  }
2069
3005
  }
2070
3006
  const cleaned = await humanizeMentions(connection, event.text ?? '');
2071
- if (cleaned.length === 0) {
3007
+ // Fetched inside the chain, like the lookups below: it is network
3008
+ // I/O, and a message whose screenshot is slow to arrive must not lose
3009
+ // its place to the one typed after it. Only for a message this agent
3010
+ // will answer: an overheard one is appended as text with no turn run
3011
+ // on it, and its attachments reach the transcript by name, as every
3012
+ // attachment did before images.
3013
+ // Nor for one it will judge: the kernel refuses images on a turn
3014
+ // nobody asked for, since they reach the model ahead of the frame
3015
+ // that marks the message as somebody else's, and a judged message
3016
+ // names its attachments the way an overheard one does.
3017
+ const { images, unread } = overhear || judged
3018
+ ? { images: [], unread: event.files ?? [] }
3019
+ : await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
3020
+ // An image with nothing said is still a question ("what's this?"); a
3021
+ // file the turn could not open with nothing said is not one, and gets
3022
+ // no reply.
3023
+ if (cleaned.length === 0 && images.length === 0) {
2072
3024
  return undefined;
2073
3025
  }
2074
- const author = await displayNameFor(connection, userId);
3026
+ const author = await authorFor(connection, userId);
2075
3027
  // 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();
3028
+ // unambiguous. A bare image in a channel still says who sent it.
3029
+ const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
3030
+ const userMessage = `${spoken}${attachmentNote(unread)}`;
3031
+ // Who sent this, judged against the operator's list — per message,
3032
+ // never remembered from the first one in the thread. A DM proves
3033
+ // nothing about who is typing, so a DM from an unlisted member is
3034
+ // `unknown` exactly as a channel mention would be. The same label
3035
+ // whether the agent answers or only hears: the text is in its
3036
+ // transcript either way.
3037
+ const senderTrust = (connection.config.principals ?? []).includes(userId) ? 'user' : 'unknown';
3038
+ const metadata = {
3039
+ channel: 'slack',
3040
+ team,
3041
+ slackChannel: event.channel,
3042
+ slackUser: userId,
3043
+ // Carried so a mid-turn approval question is asked in the thread
3044
+ // the turn belongs to rather than at the top of a busy channel.
3045
+ ...(thread ? { slackThread: thread } : {}),
3046
+ [SENDER_TRUST_METADATA_KEY]: senderTrust,
3047
+ };
3048
+ if (overhear) {
3049
+ // Heard, not answered: no placeholder, no turn. Only into a
3050
+ // conversation this agent is already in — a session under this
3051
+ // thread's key exists exactly when it was invited here — and the
3052
+ // gateway is the one to say whether it is, on the session's chain:
3053
+ // a first mention whose dispatch is queued ahead of this message
3054
+ // has created the session by the time the observe runs, where a
3055
+ // membership read from here would find nothing and drop a
3056
+ // message said moments after the invitation. A host that cannot
3057
+ // take it leaves the agent hearing what it answers, as every
3058
+ // agent did before.
3059
+ if (!gateway.observe) {
3060
+ return undefined;
3061
+ }
3062
+ // Placed on the gateway's chain here, in receipt order, and not
3063
+ // awaited here: a dispatch is placed the same way, and awaiting
3064
+ // an overhear queued behind a long turn would hold the next
3065
+ // message's place in this chain hostage to that turn.
3066
+ return {
3067
+ observed: placeObserve(sessionId, gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata })),
3068
+ };
2082
3069
  }
2083
- catch (error) {
2084
- warn(`could not post to ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
2085
- return undefined;
3070
+ // A turn nobody asked for opens its placeholder on its first text,
3071
+ // if it ever has any — see `ReplyRenderer.lazy`.
3072
+ const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, { lazy: judged, now });
3073
+ if (!judged) {
3074
+ try {
3075
+ await renderer.open();
3076
+ }
3077
+ catch (error) {
3078
+ warn(`could not post to ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
3079
+ return undefined;
3080
+ }
2086
3081
  }
2087
3082
  // The push and the dispatch happen in the same microtask, so queue
2088
3083
  // order matches the gateway's single-flight turn order for this
@@ -2094,35 +3089,29 @@ export const createSlackChannelAdapter = (options) => {
2094
3089
  sessionId,
2095
3090
  agentId: connection.config.agentId,
2096
3091
  userMessage,
3092
+ ...(images.length > 0 ? { images } : {}),
3093
+ ...(judged ? { addressed: false } : {}),
2097
3094
  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
- },
3095
+ metadata,
2112
3096
  });
2113
3097
  return { renderer, turn };
2114
3098
  });
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
3099
  const started = await intake;
2123
3100
  if (!started) {
2124
3101
  return;
2125
3102
  }
3103
+ if ('observed' in started) {
3104
+ // Nothing to render; the one outcome worth a line is the host
3105
+ // refusing it — a turn parked across a restart, most likely — since
3106
+ // a message dropped on the floor is the thing this exists to end.
3107
+ try {
3108
+ await started.observed;
3109
+ }
3110
+ catch (error) {
3111
+ warn(`slack: ${connection.config.agentId} could not overhear a message in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
3112
+ }
3113
+ return;
3114
+ }
2126
3115
  const { renderer, turn } = started;
2127
3116
  const removeFromQueue = () => {
2128
3117
  const current = renderers.get(sessionId);
@@ -2149,10 +3138,41 @@ export const createSlackChannelAdapter = (options) => {
2149
3138
  // the potentially slow final edit, uploads, and overflow posts.
2150
3139
  removeFromQueue();
2151
3140
  if (session) {
2152
- await renderer.finalize(lastAssistantReply(session));
3141
+ // The reply is final here, so this is when the thread's other agents
3142
+ // take their place for it — before the final edit and the overflow
3143
+ // posts, which are network I/O a later message must not overtake.
3144
+ // A DM has no thread, and nobody else in it.
3145
+ const reply = latestTurnReply(session);
3146
+ // A turn nobody asked for that said nothing posts nothing; every
3147
+ // other turn says `(no reply)` where its answer would have gone.
3148
+ const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : lastAssistantReply(session));
3149
+ const heard = thread !== undefined && reply !== undefined
3150
+ ? overhearReply(connection, event.channel, thread, reply, session, finalized.then((outcome) => outcome.published))
3151
+ : undefined;
3152
+ const { spoke, spokeAt } = await finalized;
3153
+ if (spoke && threadKey !== undefined) {
3154
+ // The voice that just answered, recorded where its reply sits in
3155
+ // the thread. For a judging agent this is the only record there
3156
+ // is — it took no part in the holder rule at intake — and without
3157
+ // it a thread-rule colleague that held the thread before would
3158
+ // keep answering beside it. For any other turn the intake wrote
3159
+ // one at the message answered, and this one says where the answer
3160
+ // landed, which is what settles two turns running at once: a
3161
+ // placeholder posted at intake sits above a reply a judge posted
3162
+ // while it was being filled, whichever turn finished first, and
3163
+ // the reader's next untagged message is for the lower of the two.
3164
+ // Late, either way, for a message typed while the turn was still
3165
+ // running, which the other may therefore still take.
3166
+ rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
3167
+ }
3168
+ await heard;
2153
3169
  }
2154
3170
  else {
2155
- await renderer.fail(failure instanceof Error ? failure.message : String(failure));
3171
+ const { spoke, spokeAt } = await renderer.fail(failure instanceof Error ? failure.message : String(failure));
3172
+ if (spoke && threadKey !== undefined) {
3173
+ // A file it posted before breaking is still the last thing said.
3174
+ rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
3175
+ }
2156
3176
  }
2157
3177
  };
2158
3178
  return {
@@ -2344,6 +3364,7 @@ export const createSlackChannelAdapter = (options) => {
2344
3364
  approvalPosts.clear();
2345
3365
  threadAddressee.clear();
2346
3366
  coldVerdicts.clear();
3367
+ pendingObserves.clear();
2347
3368
  botIdentities.clear();
2348
3369
  rendering.clear();
2349
3370
  resolvedWhileRendering.clear();