@stratusagent/channel-slack 0.11.6 → 0.11.8

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
@@ -1,9 +1,9 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { readFile } from 'node:fs/promises';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, open, readFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
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
- import { channelSessionKey, } from '@stratusagent/channels';
5
+ import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, 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
+ import { admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/channels';
7
7
  import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
8
8
  /**
9
9
  * The longest display name the model is shown. Slack caps profile names
@@ -141,6 +141,68 @@ export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signa
141
141
  return { status: response.status, ...typed, body: Buffer.concat(chunks) };
142
142
  };
143
143
  const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
144
+ /**
145
+ * The dispatch's idempotency key for a message's dedupe key: the key itself
146
+ * when it fits the gateway's bound, and a digest of it when it does not —
147
+ * an agent id is not length-bounded (overlong ids that predate validation
148
+ * are kept), and a key over the bound would refuse every message for that
149
+ * agent. Deterministic, so a redelivery after a restart maps to the same.
150
+ */
151
+ const dispatchKeyFor = (eventKey) => eventKey.length <= MAX_IDEMPOTENCY_KEY_LENGTH ? eventKey : `slack-sha256:${createHash('sha256').update(eventKey).digest('hex')}`;
152
+ const NOTHING_REPORTED = { said: false, landed: [] };
153
+ /**
154
+ * How far a file's change time may run past the record of the tool result
155
+ * naming it: the rest of the millisecond the record was stamped in, since
156
+ * the record is whole milliseconds and the file time is finer. No more,
157
+ * because any window is one an overwrite fits inside — two seconds was, in
158
+ * review. A filesystem that rounds file times up, coarser than that, fails
159
+ * closed: the file is warned about and not uploaded, which a repeat can
160
+ * afford and a wrong attachment cannot.
161
+ */
162
+ const PRODUCED_AT_SLACK_MS = 1;
163
+ /**
164
+ * A file a finished turn produced, read for a repeat of it — refused unless
165
+ * it is provably the file the turn left there. The transcript keeps a path,
166
+ * not the bytes, and a repeat can come after a later turn or another
167
+ * process has written the same path; uploading what is there now would
168
+ * answer the old message with a different file, or a sensitive one.
169
+ *
170
+ * The change time, not the modification time, is what is compared: it
171
+ * moves on every write, on a rename into the path, and on `utimes` itself,
172
+ * so neither an older file renamed in nor a backdated one passes. Then the
173
+ * path has to name the same file before and after it is opened — the open
174
+ * handle's file, when it is not a link — and the handle has to be unchanged
175
+ * after the read, so a swap or a write landing between the checks and the
176
+ * read is caught rather than uploaded.
177
+ */
178
+ const readUnchangedSince = async (filePath, producedAt) => {
179
+ // Exclusive: a change time at or past it was a change after the record.
180
+ const writtenBefore = producedAt + PRODUCED_AT_SLACK_MS;
181
+ const changed = () => new Error('it was changed after the turn that produced it, so it may hold another file now; it was not uploaded.');
182
+ const named = await lstat(filePath);
183
+ if (named.ctimeMs >= writtenBefore) {
184
+ throw changed();
185
+ }
186
+ const handle = await open(filePath, 'r');
187
+ try {
188
+ const opened = await handle.stat();
189
+ const stillNamed = await lstat(filePath);
190
+ const samePath = stillNamed.ino === named.ino && stillNamed.dev === named.dev && stillNamed.ctimeMs === named.ctimeMs;
191
+ const sameFile = named.isSymbolicLink() || (opened.ino === named.ino && opened.dev === named.dev);
192
+ if (!samePath || !sameFile || opened.ctimeMs >= writtenBefore) {
193
+ throw changed();
194
+ }
195
+ const data = await handle.readFile();
196
+ const after = await handle.stat();
197
+ if (after.ctimeMs !== opened.ctimeMs || after.size !== data.length) {
198
+ throw changed();
199
+ }
200
+ return data;
201
+ }
202
+ finally {
203
+ await handle.close();
204
+ }
205
+ };
144
206
  /**
145
207
  * A clock reading as a Slack timestamp, for a file whose upload answered
146
208
  * with no share ts. Slack timestamps are epoch seconds with six decimals,
@@ -205,6 +267,15 @@ class ReplyRenderer {
205
267
  * writes into the fresh one rather than over the reply it was given to.
206
268
  */
207
269
  handover = Promise.resolve();
270
+ /**
271
+ * The report of a turn nobody was rendering, made ahead of this renderer
272
+ * while it waited — through its placeholder or as messages of their own
273
+ * — resolving to what of that outcome reached the thread. Set when the
274
+ * report is claimed, before the turn it reports lets this renderer's
275
+ * dispatch go, so a repeat resolving after it can tell its own turn was
276
+ * that one, and post only what the report did not.
277
+ */
278
+ outcomeReportedAhead;
208
279
  /**
209
280
  * Bumped by a handover. An edit scheduled before it carries the text of
210
281
  * the turn that was handed over — the recovery's stream, routed to this
@@ -280,6 +351,13 @@ class ReplyRenderer {
280
351
  /** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
281
352
  runningTool;
282
353
  web;
354
+ /**
355
+ * The one person in the conversation when this turn is a DM: whoever sent
356
+ * the message. Undefined in a channel, where who can see a thread is
357
+ * Slack's membership to answer, not this turn's.
358
+ */
359
+ dmWith;
360
+ /** The conversation, and the thread in it, this turn replies into. */
283
361
  channel;
284
362
  threadTs;
285
363
  editIntervalMs;
@@ -297,6 +375,7 @@ class ReplyRenderer {
297
375
  this.statusThread = options.statusThread ?? threadTs;
298
376
  this.statusWarned = options.statusWarned ?? { value: false };
299
377
  this.after = options.after ?? Promise.resolve();
378
+ this.dmWith = options.dmWith;
300
379
  }
301
380
  /**
302
381
  * Show Slack's loading status for this turn, and keep it up until the
@@ -484,11 +563,21 @@ class ReplyRenderer {
484
563
  }
485
564
  }
486
565
  }
566
+ /**
567
+ * Upload files a turn produced that no event delivered: a repeat of a
568
+ * finished turn has only the session to read them from (`turnFilesFor`),
569
+ * and a path written since then is refused (see `readUnchangedSince`).
570
+ */
571
+ queueFiles(files) {
572
+ for (const file of files) {
573
+ this.queueUpload(file.path, Date.parse(file.producedAt));
574
+ }
575
+ }
487
576
  // Tool results that reference local output files (a screenshot, a
488
577
  // generated report) become real attachments in the conversation — the
489
578
  // channel contract's upload operation. Uploads chain so they land in
490
579
  // order and finalize() waits for them.
491
- queueUpload(filePath) {
580
+ queueUpload(filePath, producedAt) {
492
581
  // Behind a handover in progress, for the same reason edits and the
493
582
  // finalize are: the turn being handed the placeholder has its own
494
583
  // attachments to put in the thread first, and an upload is a message
@@ -506,7 +595,7 @@ class ReplyRenderer {
506
595
  // not a path, and a missing file surfaces as this upload's own
507
596
  // failure instead of an unhandled stream error.
508
597
  .then(async () => {
509
- const data = await readFile(filePath);
598
+ const data = producedAt === undefined ? await readFile(filePath) : await readUnchangedSince(filePath, producedAt);
510
599
  // Where the file will sit is Slack's to say — the ts of the
511
600
  // message it becomes, in the shares the upload answers with. When
512
601
  // the answer carries none, the clock stands in, read BEFORE the
@@ -704,9 +793,12 @@ class ReplyRenderer {
704
793
  this.placeText(this.ref?.ts);
705
794
  }
706
795
  }
707
- // A reply posted clears the status itself; one Slack refused would
708
- // leave "is thinking…" standing over a turn that has ended.
709
- if (hadStatus && !landed) {
796
+ // A reply posted into the status's thread clears the status itself;
797
+ // one Slack refused would leave "is thinking…" standing over a turn
798
+ // that has ended. So would one posted anywhere else: a top-level DM
799
+ // reply keys its status to the message it answers (`statusThread`),
800
+ // but posts beside it rather than under it, and Slack never clears it.
801
+ if (hadStatus && (!landed || this.threadTs !== this.statusThread)) {
710
802
  this.setStatus('');
711
803
  // Awaited: the turn queued behind this one re-shows its own status
712
804
  // once this renderer is done, and a clear that reached Slack after
@@ -757,6 +849,44 @@ class ReplyRenderer {
757
849
  this.textAt = undefined;
758
850
  return true;
759
851
  }
852
+ /**
853
+ * Stand down without saying anything: the message this renderer was
854
+ * opened for repeated one whose turn another delivery in this process
855
+ * is still waiting on, and that delivery posts the answer. Whatever this
856
+ * renderer put up — a placeholder, a loading status — comes down, so a
857
+ * redelivery leaves no trace.
858
+ */
859
+ async withdraw() {
860
+ try {
861
+ await this.handover;
862
+ const hadStatus = this.statusTimer !== undefined;
863
+ this.stopLoading();
864
+ this.finalized = true;
865
+ // The barrier `finalizeInOrder` takes, for the same reason: the turn
866
+ // queued behind this one waits on `posted`, and settling it before
867
+ // the reply ahead has posted would let that turn's reply overtake it.
868
+ if (!this.streaming) {
869
+ await this.after;
870
+ await this.statusChain;
871
+ }
872
+ if (this.pendingEdit) {
873
+ clearTimeout(this.pendingEdit);
874
+ this.pendingEdit = undefined;
875
+ }
876
+ await this.editChain;
877
+ // A repeat can queue files the report ahead of it could not post
878
+ // and then stand down: they land before it settles.
879
+ await this.uploadChain;
880
+ await this.retract();
881
+ if (hadStatus) {
882
+ this.setStatus('');
883
+ await this.statusChain;
884
+ }
885
+ }
886
+ finally {
887
+ this.settle();
888
+ }
889
+ }
760
890
  async fail(message) {
761
891
  // The same wait `finalize` takes, for the same reason: a lazy turn's
762
892
  // first text may be opening its placeholder right now, and a failure
@@ -986,9 +1116,6 @@ const messageText = (text) => truncateForSlack(toSlackMrkdwn(text));
986
1116
  * no rewrite is ever asked to span a boundary.
987
1117
  */
988
1118
  const messageChunks = (text) => splitForSlack(toSlackMrkdwn(text));
989
- // One rule with the gateway's `sessionRouting`, which posts the same
990
- // message for a turn this adapter did not start.
991
- const lastAssistantReply = (session) => latestTurnReply(session) ?? NO_REPLY_TEXT;
992
1119
  const APPROVAL_ACTIONS = {
993
1120
  stratus_approve_once: 'once',
994
1121
  stratus_approve_always: 'always',
@@ -1082,21 +1209,37 @@ const HUMAN_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broa
1082
1209
  const isPersonSpeaking = (event) => event.subtype === undefined || HUMAN_MESSAGE_SUBTYPES.has(event.subtype);
1083
1210
  /** At most this many attachment names are listed; the rest are counted. */
1084
1211
  const MAX_LISTED_ATTACHMENTS = 5;
1212
+ const UNREAD_REASON_TEXT = {
1213
+ unsupported: 'not a kind of file that can be read here',
1214
+ 'too-large': 'too large to read here',
1215
+ failed: 'could not be downloaded',
1216
+ 'timed-out': 'not downloaded in time',
1217
+ unreadable: 'not readable as text or as an image',
1218
+ 'not-opened': 'not opened, since this message was not addressed to you',
1219
+ };
1085
1220
  /**
1086
- * What a message's attachments are called, for a turn that cannot open
1087
- * them. Without this the agent is handed "here's the log" and no log, and
1088
- * answers as though it had read something — the note is what lets it say
1089
- * the true thing instead. Images the model was actually shown are not
1090
- * listed here: they travel on the dispatch, name included.
1221
+ * What a message's attachments are called, and why each was not read, for
1222
+ * a turn that cannot open them. Without this the agent is handed "here's
1223
+ * the log" and no log, and answers as though it had read something; the
1224
+ * note is what lets it say the true thing instead. Images the model was
1225
+ * actually shown are not listed here: they travel on the dispatch, name
1226
+ * included. A file with no recorded reason is one no reader was handed,
1227
+ * which is `unsupported`.
1091
1228
  */
1092
- const attachmentNote = (files) => {
1229
+ const attachmentNote = (files, reasons) => {
1093
1230
  if (files.length === 0) {
1094
1231
  return '';
1095
1232
  }
1096
- const names = files.slice(0, MAX_LISTED_ATTACHMENTS).map((file) => file.name ?? file.title ?? 'an unnamed file');
1097
- const rest = files.length - names.length;
1098
- const listed = rest > 0 ? `${names.join(', ')}, and ${rest} more` : names.join(', ');
1099
- return `\n[Attached: ${listed}. Attachment contents cannot be read here — say so rather than guessing at them.]`;
1233
+ const listed = files.slice(0, MAX_LISTED_ATTACHMENTS);
1234
+ const groups = new Map();
1235
+ for (const file of listed) {
1236
+ const reason = reasons.get(file) ?? 'unsupported';
1237
+ groups.set(reason, [...(groups.get(reason) ?? []), file.name ?? file.title ?? 'an unnamed file']);
1238
+ }
1239
+ const parts = [...groups].map(([reason, names]) => `${names.join(', ')} (${UNREAD_REASON_TEXT[reason]})`);
1240
+ const rest = files.length - listed.length;
1241
+ const all = rest > 0 ? `${parts.join('; ')}; and ${rest} more` : parts.join('; ');
1242
+ return `\n[Attached, name only: ${all}. You have only the names, so say so rather than guessing at what is in them.]`;
1100
1243
  };
1101
1244
  /** What Slack calls a file, in a warning about it. */
1102
1245
  const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed file';
@@ -1111,13 +1254,19 @@ const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed fi
1111
1254
  * fix — the warning here names the scope instead, and the file falls back
1112
1255
  * to the by-name note like any other attachment the turn cannot open.
1113
1256
  */
1114
- const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn, deadline = AbortSignal.timeout(timeoutMs)) => {
1257
+ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn, reasons, deadline = AbortSignal.timeout(timeoutMs)) => {
1115
1258
  // Decided from the last file back, the way the replay window is spent:
1116
1259
  // when a message's images do not all fit, the ones kept are the ones it
1117
1260
  // listed last, so intake and replay agree about which survive. Delivered
1118
1261
  // in the message's own order, because that is how the person sees them.
1119
1262
  const kept = new Map();
1120
1263
  const dropped = new Set();
1264
+ // Recorded beside the drop, so the note says why and never guesses. A
1265
+ // non-image goes on to the text reader with no reason: it decides.
1266
+ const drop = (position, reason) => {
1267
+ dropped.add(position);
1268
+ reasons.set(files[position], reason);
1269
+ };
1121
1270
  // Decoded bytes accepted so far. Checked against Slack's reported size
1122
1271
  // before a download and the real length after it, because the message
1123
1272
  // as a whole has a budget the per-image cap alone cannot keep.
@@ -1142,19 +1291,23 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1142
1291
  for (let position = files.length - 1; position >= 0; position -= 1) {
1143
1292
  const file = files[position];
1144
1293
  const url = file.url_private_download ?? file.url_private;
1145
- if (!isImageAttachmentMediaType(file.mimetype) || url === undefined) {
1294
+ if (!isImageAttachmentMediaType(file.mimetype)) {
1146
1295
  dropped.add(position);
1147
1296
  continue;
1148
1297
  }
1298
+ if (url === undefined) {
1299
+ drop(position, 'failed');
1300
+ continue;
1301
+ }
1149
1302
  if (windowClosed) {
1150
1303
  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.`);
1151
- dropped.add(position);
1304
+ drop(position, 'too-large');
1152
1305
  continue;
1153
1306
  }
1154
1307
  const tooBig = file.size === undefined ? undefined : overLimit(fileLabel(file), file.size);
1155
1308
  if (tooBig !== undefined) {
1156
1309
  warn(tooBig);
1157
- dropped.add(position);
1310
+ drop(position, 'too-large');
1158
1311
  continue;
1159
1312
  }
1160
1313
  // What this image may weigh: the per-image cap, or what is left of the
@@ -1163,7 +1316,7 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1163
1316
  const maxBytes = Math.min(IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES - total);
1164
1317
  if (deadline.aborted) {
1165
1318
  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.`);
1166
- dropped.add(position);
1319
+ drop(position, 'timed-out');
1167
1320
  continue;
1168
1321
  }
1169
1322
  let download;
@@ -1175,7 +1328,7 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1175
1328
  ? `this message's downloads took longer than ${timeoutMs}ms`
1176
1329
  : (error instanceof Error ? error.message : String(error));
1177
1330
  warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
1178
- dropped.add(position);
1331
+ drop(position, error instanceof Error && error.name === 'TimeoutError' ? 'timed-out' : 'failed');
1179
1332
  continue;
1180
1333
  }
1181
1334
  // Refused on what Slack sends when it will not serve the file — a
@@ -1185,7 +1338,7 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1185
1338
  const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
1186
1339
  if (download.status !== 200 || contentType.startsWith('text/')) {
1187
1340
  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.`);
1188
- dropped.add(position);
1341
+ drop(position, 'failed');
1189
1342
  continue;
1190
1343
  }
1191
1344
  if (download.truncated === true) {
@@ -1196,13 +1349,13 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1196
1349
  windowClosed = true;
1197
1350
  }
1198
1351
  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.`);
1199
- dropped.add(position);
1352
+ drop(position, 'too-large');
1200
1353
  continue;
1201
1354
  }
1202
1355
  const stillTooBig = overLimit(fileLabel(file), download.body.length);
1203
1356
  if (stillTooBig !== undefined) {
1204
1357
  warn(stillTooBig);
1205
- dropped.add(position);
1358
+ drop(position, 'too-large');
1206
1359
  continue;
1207
1360
  }
1208
1361
  // Read from the bytes, not from Slack's metadata: an image the model
@@ -1211,12 +1364,12 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1211
1364
  const dimensions = imageDimensions(download.body, file.mimetype);
1212
1365
  if (dimensions === undefined) {
1213
1366
  warn(`slack: ${fileLabel(file)} is not a complete ${file.mimetype} — its header or trailer is missing; the turn is told it cannot be read.`);
1214
- dropped.add(position);
1367
+ drop(position, 'unreadable');
1215
1368
  continue;
1216
1369
  }
1217
1370
  if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
1218
1371
  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.`);
1219
- dropped.add(position);
1372
+ drop(position, 'too-large');
1220
1373
  continue;
1221
1374
  }
1222
1375
  total += download.body.length;
@@ -1285,25 +1438,37 @@ const isTextAttachment = (file) => {
1285
1438
  * caps, or comes back as a page stays in `unread` and is named in the
1286
1439
  * note, exactly as before; the warning says why.
1287
1440
  */
1288
- const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn) => {
1441
+ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn, reasons) => {
1289
1442
  const texts = [];
1290
1443
  const unread = [];
1444
+ const skip = (file, reason) => {
1445
+ unread.push(file);
1446
+ reasons.set(file, reason);
1447
+ };
1291
1448
  let total = 0;
1292
1449
  for (const file of files) {
1293
1450
  const url = file.url_private_download ?? file.url_private;
1294
- if (!isTextAttachment(file) || url === undefined) {
1451
+ if (!isTextAttachment(file)) {
1452
+ // An image the image reader already refused keeps its own reason.
1295
1453
  unread.push(file);
1454
+ if (!reasons.has(file)) {
1455
+ reasons.set(file, 'unsupported');
1456
+ }
1457
+ continue;
1458
+ }
1459
+ if (url === undefined) {
1460
+ skip(file, 'failed');
1296
1461
  continue;
1297
1462
  }
1298
1463
  const maxBytes = Math.min(TEXT_ATTACHMENT_MAX_BYTES, TEXT_ATTACHMENTS_MAX_TOTAL_BYTES - total);
1299
1464
  if (file.size !== undefined && file.size > maxBytes) {
1300
1465
  warn(`slack: ${fileLabel(file)} is ${file.size} bytes, over the ${maxBytes} bytes of text this message could still take; the turn is told it cannot be read. Paste the part that matters, or split the file.`);
1301
- unread.push(file);
1466
+ skip(file, 'too-large');
1302
1467
  continue;
1303
1468
  }
1304
1469
  if (deadline.aborted) {
1305
1470
  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.`);
1306
- unread.push(file);
1471
+ skip(file, 'timed-out');
1307
1472
  continue;
1308
1473
  }
1309
1474
  let download;
@@ -1315,7 +1480,7 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
1315
1480
  ? `this message's downloads took longer than ${timeoutMs}ms`
1316
1481
  : (error instanceof Error ? error.message : String(error));
1317
1482
  warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
1318
- unread.push(file);
1483
+ skip(file, error instanceof Error && error.name === 'TimeoutError' ? 'timed-out' : 'failed');
1319
1484
  continue;
1320
1485
  }
1321
1486
  // HTML is never a file read here (see isTextAttachment), so an HTML
@@ -1324,12 +1489,12 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
1324
1489
  const head = download.body.subarray(0, 64).toString('utf8').trimStart().toLowerCase();
1325
1490
  if (download.status !== 200 || contentType === 'text/html' || head.startsWith('<!doctype html') || head.startsWith('<html')) {
1326
1491
  warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than the file. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
1327
- unread.push(file);
1492
+ skip(file, 'failed');
1328
1493
  continue;
1329
1494
  }
1330
1495
  if (download.truncated === true || download.body.length > maxBytes) {
1331
1496
  warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes of text this message could still take; the download was abandoned and the turn is told it cannot be read.`);
1332
- unread.push(file);
1497
+ skip(file, 'too-large');
1333
1498
  continue;
1334
1499
  }
1335
1500
  let text;
@@ -1338,7 +1503,7 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
1338
1503
  }
1339
1504
  catch {
1340
1505
  warn(`slack: ${fileLabel(file)} is not UTF-8 text; the turn is told it cannot be read.`);
1341
- unread.push(file);
1506
+ skip(file, 'unreadable');
1342
1507
  continue;
1343
1508
  }
1344
1509
  total += download.body.length;
@@ -1454,6 +1619,87 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
1454
1619
  },
1455
1620
  ];
1456
1621
  };
1622
+ const CREDENTIAL_ADD_ACTION = 'stratus_credential_add';
1623
+ const CREDENTIAL_VIEW = 'stratus_credential';
1624
+ const CREDENTIAL_BLOCK = 'credential_value';
1625
+ const CREDENTIAL_INPUT = 'value';
1626
+ // Under Slack's three-second acknowledgement window, with room for the ack
1627
+ // itself to travel.
1628
+ const CREDENTIAL_ACK_MS = 2500;
1629
+ // A reason is the agent's own text, shown to a person deciding whether to
1630
+ // hand over a key; long enough to explain, short enough to read.
1631
+ const CREDENTIAL_REASON_LIMIT = 300;
1632
+ const credentialWhose = (agentName, scope) => scope === 'agent'
1633
+ ? `for ${agentName} only`
1634
+ : `stored for the whole fleet, granted to ${agentName} (other agents need it in their own soul)`;
1635
+ /**
1636
+ * The request as it appears in the conversation. What it says about the
1637
+ * value is the promise the form keeps: straight to the credential store,
1638
+ * never into the thread, and never over a key already there.
1639
+ */
1640
+ const credentialRequestBlocks = (agentName, name, scope, reason, requestId) => {
1641
+ const shortReason = reason !== undefined && reason.length > CREDENTIAL_REASON_LIMIT
1642
+ ? `${reason.slice(0, CREDENTIAL_REASON_LIMIT)}…`
1643
+ : reason;
1644
+ return [
1645
+ {
1646
+ type: 'section',
1647
+ text: {
1648
+ type: 'mrkdwn',
1649
+ text: `*${escapeSlackText(agentName)}* is asking for a credential: \`${escapeSlackText(name)}\`, ${credentialWhose(escapeSlackText(agentName), scope)}.`,
1650
+ },
1651
+ },
1652
+ ...(shortReason !== undefined
1653
+ ? [{ type: 'section', text: { type: 'mrkdwn', text: `_${escapeSlackText(agentName)} says:_ ${escapeSlackText(shortReason)}` } }]
1654
+ : []),
1655
+ {
1656
+ type: 'context',
1657
+ elements: [{
1658
+ type: 'mrkdwn',
1659
+ text: 'Only an approver can add it. The value goes straight to the credential store, never into this conversation, and a key already stored is never replaced from here.',
1660
+ }],
1661
+ },
1662
+ {
1663
+ type: 'actions',
1664
+ elements: [{
1665
+ type: 'button',
1666
+ action_id: CREDENTIAL_ADD_ACTION,
1667
+ style: 'primary',
1668
+ value: requestId,
1669
+ text: { type: 'plain_text', text: 'Add credential' },
1670
+ }],
1671
+ },
1672
+ ];
1673
+ };
1674
+ /**
1675
+ * The form. `private_metadata` carries only the request id: where the key
1676
+ * goes, and for whom, is the gateway's record of the request, never
1677
+ * something a submission can say about itself.
1678
+ */
1679
+ const credentialModal = (post, requestId) => ({
1680
+ type: 'modal',
1681
+ callback_id: CREDENTIAL_VIEW,
1682
+ private_metadata: requestId,
1683
+ title: { type: 'plain_text', text: 'Add a credential' },
1684
+ submit: { type: 'plain_text', text: 'Add' },
1685
+ close: { type: 'plain_text', text: 'Cancel' },
1686
+ blocks: [
1687
+ {
1688
+ type: 'section',
1689
+ text: {
1690
+ type: 'mrkdwn',
1691
+ text: `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
1692
+ + `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation.`,
1693
+ },
1694
+ },
1695
+ {
1696
+ type: 'input',
1697
+ block_id: CREDENTIAL_BLOCK,
1698
+ label: { type: 'plain_text', text: `Value for ${post.name}` },
1699
+ element: { type: 'plain_text_input', action_id: CREDENTIAL_INPUT },
1700
+ },
1701
+ ],
1702
+ });
1457
1703
  /**
1458
1704
  * The Slack channel adapter. One Slack app per agent — Slack has no way to
1459
1705
  * give a single bot several identities with real avatars, presence, and
@@ -1588,6 +1834,12 @@ export const createSlackChannelAdapter = (options) => {
1588
1834
  // Rendered approval requests, keyed by the gateway's request id — the
1589
1835
  // same id the buttons carry back.
1590
1836
  const approvalPosts = new Map();
1837
+ // Credential requests whose post is in flight: Slack can show the message,
1838
+ // and take a click on it, before postMessage resolves here. The same
1839
+ // window `rendering` covers for approvals, and bounded the same way.
1840
+ const credentialsPosting = new Set();
1841
+ // Credential requests with a live button, keyed by the gateway's request id.
1842
+ const credentialPosts = new Map();
1591
1843
  // Requests whose message is mid-post. A request can settle — expire, or
1592
1844
  // its turn be cancelled — while the postMessage that announces it is
1593
1845
  // still in flight, and the retraction would then find nothing to retract
@@ -1682,6 +1934,58 @@ export const createSlackChannelAdapter = (options) => {
1682
1934
  }
1683
1935
  return boundedDisplayName(await displayNameFor(connection, userId));
1684
1936
  };
1937
+ /**
1938
+ * What kind of room a conversation is and how many are in it, for the
1939
+ * turn's `conversation` metadata: an agent that did not know told
1940
+ * someone in a DM they were "talking on the terminal", and answers the same
1941
+ * way whether one person or a thousand will read it. Never the channel's
1942
+ * name, which whoever made the channel chose (`ConversationContext`).
1943
+ *
1944
+ * Looked up on every turn outside a DM, and never cached: who can read a
1945
+ * channel is the point, and a cached answer described a channel made
1946
+ * public, or shared with another workspace, as it was before. Lookups
1947
+ * for one conversation that overlap share one call. A lookup that fails
1948
+ * (an app missing the read scopes) leaves the kind the event gave, or
1949
+ * nothing: the prompt then says only that the conversation is in Slack,
1950
+ * as it always did.
1951
+ */
1952
+ const roomLookups = new Map();
1953
+ const KIND_BY_CHANNEL_TYPE = { im: 'direct', mpim: 'group', group: 'private', channel: 'public' };
1954
+ const roomFor = async (connection, channel, channelType) => {
1955
+ const fromEvent = channelType !== undefined ? KIND_BY_CHANNEL_TYPE[channelType] : undefined;
1956
+ // A DM is two people, the app and the sender: nothing to look up.
1957
+ if (fromEvent === 'direct') {
1958
+ return { kind: 'direct' };
1959
+ }
1960
+ const key = `${connection.config.agentId}:${channel}`;
1961
+ let lookup = roomLookups.get(key);
1962
+ if (!lookup) {
1963
+ lookup = lookupRoom(connection, channel).finally(() => roomLookups.delete(key));
1964
+ roomLookups.set(key, lookup);
1965
+ }
1966
+ return (await lookup) ?? (fromEvent !== undefined ? { kind: fromEvent } : undefined);
1967
+ };
1968
+ const lookupRoom = async (connection, channel) => {
1969
+ try {
1970
+ const info = (await connection.web.conversations.info({ channel, include_num_members: true })).channel;
1971
+ if (info === undefined) {
1972
+ return undefined;
1973
+ }
1974
+ const kind = info.is_im === true
1975
+ ? 'direct'
1976
+ : info.is_mpim === true
1977
+ ? 'group'
1978
+ : info.is_private === true ? 'private' : 'public';
1979
+ return {
1980
+ kind,
1981
+ ...(kind !== 'direct' && typeof info.num_members === 'number' ? { members: info.num_members } : {}),
1982
+ ...(kind !== 'direct' && (info.is_ext_shared === true || info.is_org_shared === true) ? { shared: true } : {}),
1983
+ };
1984
+ }
1985
+ catch {
1986
+ return undefined;
1987
+ }
1988
+ };
1685
1989
  // Mentions arrive as <@U123> markup; the model should read names.
1686
1990
  /**
1687
1991
  * `<@U…>` mentions become `@name` — for principals only. A display name
@@ -1708,17 +2012,6 @@ export const createSlackChannelAdapter = (options) => {
1708
2012
  const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
1709
2013
  /** An agent's configuration, whether or not its socket came up. */
1710
2014
  const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
1711
- /**
1712
- * Whether an agent takes a message from this sender at all — judged on
1713
- * its configuration, not on a live connection: an agent whose socket
1714
- * failed to start is still recognizable in a mention, and its door is
1715
- * still its own. An agent this adapter was never configured for is
1716
- * nobody's to refuse here, so it admits — the answer from before the
1717
- * door existed.
1718
- */
1719
- const admitsSender = (config, userId) => config === undefined
1720
- || config.admit !== 'principals'
1721
- || (config.principals ?? []).includes(userId);
1722
2015
  /**
1723
2016
  * How an agent listens, from its soul — read per message, so a roster
1724
2017
  * reload takes effect on the next one. `thread` when the soul says
@@ -2145,7 +2438,7 @@ export const createSlackChannelAdapter = (options) => {
2145
2438
  });
2146
2439
  }
2147
2440
  catch (error) {
2148
- warn(`slack: could not retire an orphaned approval request: ${error instanceof Error ? error.message : String(error)}`);
2441
+ warn(`slack: could not retire an orphaned request: ${error instanceof Error ? error.message : String(error)}`);
2149
2442
  }
2150
2443
  };
2151
2444
  const retractApprovalRequest = async (event) => {
@@ -2260,6 +2553,11 @@ export const createSlackChannelAdapter = (options) => {
2260
2553
  };
2261
2554
  /** Post the files an unrendered turn produced into its thread, in order. */
2262
2555
  const uploadUnrenderedFiles = async (connection, channel, thread, paths) => {
2556
+ // Which files landed: a redelivery waiting behind this report posts
2557
+ // the rest (`outcomeReportedAhead`), since a file that did not land is
2558
+ // one only it can still post — and only the rest, or it would post
2559
+ // twice the ones that did.
2560
+ const landed = [];
2263
2561
  for (const filePath of paths) {
2264
2562
  try {
2265
2563
  const data = await readFile(filePath);
@@ -2269,16 +2567,18 @@ export const createSlackChannelAdapter = (options) => {
2269
2567
  filename: path.basename(filePath),
2270
2568
  ...(thread ? { thread_ts: thread } : {}),
2271
2569
  });
2570
+ landed.push(filePath);
2272
2571
  }
2273
2572
  catch (error) {
2274
2573
  warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
2275
2574
  }
2276
2575
  }
2576
+ return landed;
2277
2577
  };
2278
2578
  const reportUnrenderedFailure = async (event, behind, files) => {
2279
2579
  const gateway = gatewayRef;
2280
2580
  if (!gateway?.sessionRouting) {
2281
- return;
2581
+ return NOTHING_REPORTED;
2282
2582
  }
2283
2583
  let routing;
2284
2584
  try {
@@ -2289,17 +2589,17 @@ export const createSlackChannelAdapter = (options) => {
2289
2589
  // the turn is already failed — there is nothing here to salvage
2290
2590
  // beyond saying why the thread stayed quiet.
2291
2591
  warn(`slack: could not read the routing for a failed turn: ${error instanceof Error ? error.message : String(error)}`);
2292
- return;
2592
+ return NOTHING_REPORTED;
2293
2593
  }
2294
2594
  const metadata = routing?.metadata;
2295
2595
  // Another surface's session, or one with no conversation to speak
2296
2596
  // into. Not this adapter's to answer either way.
2297
2597
  if (!routing || metadata?.channel !== 'slack' || typeof metadata.slackChannel !== 'string') {
2298
- return;
2598
+ return NOTHING_REPORTED;
2299
2599
  }
2300
2600
  const connection = connectionFor(routing.agentId);
2301
2601
  if (!connection) {
2302
- return;
2602
+ return NOTHING_REPORTED;
2303
2603
  }
2304
2604
  const channel = metadata.slackChannel;
2305
2605
  const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
@@ -2320,10 +2620,13 @@ export const createSlackChannelAdapter = (options) => {
2320
2620
  // interruption it existed to avoid; a line left standing happens
2321
2621
  // only to a turn that had already chosen to speak.
2322
2622
  warn(`slack: a turn nobody asked for failed before saying anything: ${event.error}`);
2323
- await uploadUnrenderedFiles(connection, channel, thread, files);
2324
- return;
2623
+ return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
2325
2624
  }
2326
- await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), () => uploadUnrenderedFiles(connection, channel, thread, files));
2625
+ let landed = [];
2626
+ const said = await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), async () => {
2627
+ landed = await uploadUnrenderedFiles(connection, channel, thread, files);
2628
+ });
2629
+ return { said, landed };
2327
2630
  };
2328
2631
  /**
2329
2632
  * The other half of `reportUnrenderedFailure`: a turn nobody here was
@@ -2337,7 +2640,7 @@ export const createSlackChannelAdapter = (options) => {
2337
2640
  const reportUnrenderedReply = async (event, behind, files) => {
2338
2641
  const gateway = gatewayRef;
2339
2642
  if (!gateway?.sessionRouting) {
2340
- return;
2643
+ return NOTHING_REPORTED;
2341
2644
  }
2342
2645
  let routing;
2343
2646
  try {
@@ -2345,15 +2648,15 @@ export const createSlackChannelAdapter = (options) => {
2345
2648
  }
2346
2649
  catch (error) {
2347
2650
  warn(`slack: could not read the routing for a finished turn: ${error instanceof Error ? error.message : String(error)}`);
2348
- return;
2651
+ return NOTHING_REPORTED;
2349
2652
  }
2350
2653
  const metadata = routing?.metadata;
2351
2654
  if (!routing || metadata?.channel !== 'slack' || typeof metadata.slackChannel !== 'string') {
2352
- return;
2655
+ return NOTHING_REPORTED;
2353
2656
  }
2354
2657
  const connection = connectionFor(routing.agentId);
2355
2658
  if (!connection) {
2356
- return;
2659
+ return NOTHING_REPORTED;
2357
2660
  }
2358
2661
  const channel = metadata.slackChannel;
2359
2662
  const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
@@ -2366,10 +2669,16 @@ export const createSlackChannelAdapter = (options) => {
2366
2669
  // order to keep and nothing to say, so the files go on their own.
2367
2670
  const chunks = routing.reply ? messageChunks(routing.reply) : [];
2368
2671
  const posting = chunks.length > 0 ? chunks : (behind && files.length > 0 ? [NO_REPLY_TEXT] : []);
2369
- const uploads = () => uploadUnrenderedFiles(connection, channel, thread, files);
2672
+ let landed = [];
2673
+ const uploads = async () => {
2674
+ landed = await uploadUnrenderedFiles(connection, channel, thread, files);
2675
+ };
2370
2676
  if (posting.length === 0) {
2371
2677
  await uploads();
2372
- return;
2678
+ // Nothing was said. A turn nobody asked for chose that; an addressed
2679
+ // one is owed `(no reply)`, which a rendered turn would have posted,
2680
+ // so a redelivery waiting behind this report is left to post it.
2681
+ return { said: routing.unaddressed === true, landed };
2373
2682
  }
2374
2683
  // A reply the thread's other agents hear like any other, taking their
2375
2684
  // place for it before the post, as `handleInbound` does.
@@ -2377,11 +2686,288 @@ export const createSlackChannelAdapter = (options) => {
2377
2686
  const heard = routing.reply !== undefined && thread !== undefined
2378
2687
  ? overhearReply(connection, channel, thread, routing.reply, routing, published)
2379
2688
  : undefined;
2380
- await published;
2689
+ const delivered = await published;
2381
2690
  await heard;
2382
2691
  log(`slack: posted the reply of a turn finished after a restart to ${channel}`);
2692
+ return { said: delivered, landed };
2693
+ };
2694
+ /**
2695
+ * Whether an approver can see a conversation: any public channel, or a
2696
+ * private one with an approver among its members. Rejects, with the
2697
+ * reason, when Slack will not say, so the caller refuses rather than
2698
+ * guesses.
2699
+ */
2700
+ const approverCanSee = async (connection, channel, approvers) => {
2701
+ try {
2702
+ const info = (await connection.web.conversations.info({ channel })).channel;
2703
+ if (info?.is_private !== true && info?.is_mpim !== true) {
2704
+ return true;
2705
+ }
2706
+ let cursor;
2707
+ do {
2708
+ const page = await connection.web.conversations.members({ channel, limit: 200, ...(cursor ? { cursor } : {}) });
2709
+ if ((page.members ?? []).some((member) => approvers.has(member))) {
2710
+ return true;
2711
+ }
2712
+ cursor = page.response_metadata?.next_cursor || undefined;
2713
+ } while (cursor !== undefined);
2714
+ return false;
2715
+ }
2716
+ catch (error) {
2717
+ throw new Error(`Slack would not say who can see this conversation (${error instanceof Error ? error.message : String(error)}), so the form was not posted. `
2718
+ + 'The app needs the conversations read scopes (channels:read, groups:read, mpim:read); see the app manifest.');
2719
+ }
2720
+ };
2721
+ /**
2722
+ * The channel contract's `requestCredential`: posts the form and resolves
2723
+ * only once it is up with a button an approver can press. Every refusal
2724
+ * rejects with a sentence for the agent, which the gateway hands it in
2725
+ * place of "your operator was asked": a request nobody can answer would
2726
+ * otherwise sit pending while the agent says it is waiting on someone.
2727
+ */
2728
+ const requestCredential = async (request) => {
2729
+ const gateway = gatewayRef;
2730
+ const connection = connectionFor(request.agentId);
2731
+ if (!gateway || !connection) {
2732
+ throw new Error(configuredAgents.has(request.agentId)
2733
+ ? `${request.agentId}'s Slack app is not connected right now.`
2734
+ : `${request.agentId} has no Slack app.`);
2735
+ }
2736
+ // Only in the conversation the agent asked in, and only for a turn this
2737
+ // adapter dispatched from a Slack message: where to post comes from
2738
+ // that turn's renderer, never from the session's metadata, which an
2739
+ // HTTP caller can write (`channel: slack`, any `slackChannel`) to put a
2740
+ // request nobody made in that conversation in front of its approvers.
2741
+ // Unlike an approval there is no fallback channel: the person who can
2742
+ // answer is the one the agent is talking to.
2743
+ const active = gateway.activeTurnId?.(request.sessionId);
2744
+ const turn = active === undefined
2745
+ ? undefined
2746
+ : (renderers.get(request.sessionId) ?? []).find((renderer) => renderer.turnId === active);
2747
+ if (!turn) {
2748
+ throw new Error('This turn did not come from a Slack message, so there is nobody in Slack to show the form to.');
2749
+ }
2750
+ const channel = turn.channel;
2751
+ const thread = turn.threadTs;
2752
+ const unseen = `Nobody who can add it can see this conversation`;
2753
+ const approvers = new Set((connection.config.approvers ?? []).filter((id) => id.length > 0));
2754
+ const agentName = gateway.agents().find((agent) => agent.id === request.agentId)?.name ?? request.agentId;
2755
+ if (approvers.size === 0 || gateway.provideCredential === undefined) {
2756
+ throw new Error(`Nobody can add it from Slack, because no approver is configured for ${agentName} (approvals.slackApprovers).`);
2757
+ }
2758
+ // A DM has one person in it besides the app. Posted there for someone
2759
+ // who is not an approver, the form is a button only they can see and
2760
+ // only approvers can press, which is a request nobody can answer.
2761
+ const elsewhere = 'Ask in a channel an approver is in, or ask one of them directly.';
2762
+ if (turn.dmWith !== undefined && !approvers.has(turn.dmWith)) {
2763
+ throw new Error(`${unseen}: it is a direct message with someone who is not an approver for ${agentName}. ${elsewhere}`);
2764
+ }
2765
+ // A private channel or group DM shows the button only to its members,
2766
+ // so one with no approver in it is the DM case again with more people.
2767
+ // A public channel is open to anyone in the workspace, approvers
2768
+ // included, and is not checked. Unknown is refused, not assumed fine:
2769
+ // a form posted where nobody can answer it waits forever.
2770
+ if (turn.dmWith === undefined && !(await approverCanSee(connection, channel, approvers))) {
2771
+ throw new Error(`${unseen}: none of ${agentName}'s approvers is a member of this private conversation. ${elsewhere}`);
2772
+ }
2773
+ let posted;
2774
+ credentialsPosting.add(request.requestId);
2775
+ try {
2776
+ posted = await connection.web.chat.postMessage({
2777
+ channel,
2778
+ text: escapeSlackText(`${agentName} is asking for a credential: ${request.name}, ${credentialWhose(agentName, request.scope)}.`),
2779
+ ...(thread ? { thread_ts: thread } : {}),
2780
+ blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId),
2781
+ });
2782
+ }
2783
+ catch (error) {
2784
+ credentialsPosting.delete(request.requestId);
2785
+ warn(`slack: could not post a credential request for ${request.agentId} to ${channel} (${error instanceof Error ? error.message : String(error)})`);
2786
+ throw new Error(`Slack refused the post (${error instanceof Error ? error.message : String(error)}).`);
2787
+ }
2788
+ credentialsPosting.delete(request.requestId);
2789
+ if (!posted.ts) {
2790
+ throw new Error('Slack accepted the post but returned no message to attach the form to.');
2791
+ }
2792
+ credentialPosts.set(request.requestId, {
2793
+ connection,
2794
+ channel: posted.channel ?? channel,
2795
+ ...(thread !== undefined ? { thread } : {}),
2796
+ ts: posted.ts,
2797
+ agentId: request.agentId,
2798
+ agentName,
2799
+ name: request.name,
2800
+ scope: request.scope,
2801
+ approvers,
2802
+ });
2803
+ };
2804
+ const settleCredentialPost = async (event) => {
2805
+ const post = credentialPosts.get(event.requestId);
2806
+ if (!post) {
2807
+ return;
2808
+ }
2809
+ credentialPosts.delete(event.requestId);
2810
+ const whose = event.scope === 'agent'
2811
+ ? `for ${escapeSlackText(post.agentName)} only`
2812
+ : `as a shared credential, granted to ${escapeSlackText(post.agentName)}`;
2813
+ const by = event.actor !== undefined && post.approvers.has(event.actor) ? `, by <@${event.actor}>` : '';
2814
+ const text = event.grantError === undefined
2815
+ ? `Added \`${escapeSlackText(post.name)}\` ${whose}${by}. ${escapeSlackText(post.agentName)} can use it from its next reply; the value is in the credential store, not here.`
2816
+ : `Stored \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}, `
2817
+ + `but it could not be added to ${escapeSlackText(post.agentName)}'s soul (${escapeSlackText(event.grantError)}), so ${escapeSlackText(post.agentName)} cannot use it yet. `
2818
+ + 'Add the name to the credentials list in its soul on the machine.';
2819
+ try {
2820
+ await post.connection.web.chat.update({ channel: post.channel, ts: post.ts, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] });
2821
+ }
2822
+ catch (error) {
2823
+ warn(`slack: could not update the credential request for ${post.agentId} (${error instanceof Error ? error.message : String(error)})`);
2824
+ }
2825
+ };
2826
+ /**
2827
+ * Takes down a request no answer can land on any more, so its button stops
2828
+ * inviting submissions that can only be refused the same way. A request
2829
+ * `credential.provided` already settled is left to what that wrote, which
2830
+ * says more (a key stored but not granted).
2831
+ */
2832
+ const retireCredentialPost = async (requestId, post, reason) => {
2833
+ if (credentialPosts.get(requestId) !== post) {
2834
+ return;
2835
+ }
2836
+ credentialPosts.delete(requestId);
2837
+ const text = `\`${escapeSlackText(post.name)}\` was not added: ${escapeSlackText(reason)}`;
2838
+ try {
2839
+ await post.connection.web.chat.update({ channel: post.channel, ts: post.ts, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] });
2840
+ }
2841
+ catch (error) {
2842
+ warn(`slack: could not retire the credential request for ${post.agentId} (${error instanceof Error ? error.message : String(error)})`);
2843
+ }
2844
+ };
2845
+ const handleCredentialClick = async (connection, args, requestId) => {
2846
+ await args.ack();
2847
+ const post = credentialPosts.get(requestId);
2848
+ if (!post && credentialsPosting.has(requestId)) {
2849
+ // Live, with the index not caught up yet: retiring it here would
2850
+ // strip the button from a request about to be registered as pending.
2851
+ await tellClicker(connection, args, 'That credential request is still being posted. Try the button again in a moment.');
2852
+ return;
2853
+ }
2854
+ if (!post || post.connection !== connection) {
2855
+ await tellClicker(connection, args, 'That credential request is no longer pending. Ask the agent to request it again.');
2856
+ // A daemon restart forgets every request, and the buttons it posted
2857
+ // stay live for everyone else in the thread: take this one down the
2858
+ // way an orphaned approval is, from the click's own copy.
2859
+ if (!post) {
2860
+ await retireOrphanedPrompt(connection, args);
2861
+ }
2862
+ return;
2863
+ }
2864
+ // By actor, as approvals are: the request sits in a thread anyone in
2865
+ // it can read, and a key is a bigger thing to hand over than a click.
2866
+ const clicker = args.body?.user?.id;
2867
+ if (!clicker || !post.approvers.has(clicker)) {
2868
+ await tellClicker(connection, args, `You are not an approver for ${post.agentName}, so you cannot add its credentials.`);
2869
+ return;
2870
+ }
2871
+ const triggerId = args.body?.trigger_id;
2872
+ if (!connection.web.views || !triggerId) {
2873
+ await tellClicker(connection, args, 'Slack did not hand over a way to open the form. Try the button again.');
2874
+ return;
2875
+ }
2876
+ try {
2877
+ await connection.web.views.open({ trigger_id: triggerId, view: credentialModal(post, requestId) });
2878
+ }
2879
+ catch (error) {
2880
+ warn(`slack: could not open the credential form for ${post.agentId} (${error instanceof Error ? error.message : String(error)})`);
2881
+ await tellClicker(connection, args, 'The form could not be opened. Try the button again.');
2882
+ }
2883
+ };
2884
+ /**
2885
+ * The form's answer. Acked last rather than first, because the ack is how
2886
+ * a refusal reaches the form: `errors` keeps it open with the reason
2887
+ * beside the field, so a person told "already stored" or "no longer
2888
+ * pending" learns it where they typed. The value is read here, handed to
2889
+ * the gateway, and touched nowhere else: not logged, not posted.
2890
+ */
2891
+ const handleCredentialSubmission = async (connection, args) => {
2892
+ const refuse = (message) => args.ack({ response_action: 'errors', errors: { [CREDENTIAL_BLOCK]: message } });
2893
+ const view = args.body?.view;
2894
+ const requestId = view?.private_metadata ?? '';
2895
+ const post = credentialPosts.get(requestId);
2896
+ const gateway = gatewayRef;
2897
+ if (!post || post.connection !== connection || !gateway?.provideCredential) {
2898
+ await refuse('That credential request is no longer pending. Ask the agent to request it again.');
2899
+ return;
2900
+ }
2901
+ // Checked again on the answer, not only on the click: the form is a
2902
+ // separate interaction, and the click is not what stores anything.
2903
+ const submitter = args.body?.user?.id;
2904
+ if (!submitter || !post.approvers.has(submitter)) {
2905
+ await refuse(`You are not an approver for ${post.agentName}, so you cannot add its credentials.`);
2906
+ return;
2907
+ }
2908
+ const value = view?.state?.values?.[CREDENTIAL_BLOCK]?.[CREDENTIAL_INPUT]?.value ?? '';
2909
+ const answer = gateway.provideCredential({ requestId, value, actor: submitter }).catch((error) => ({
2910
+ ok: false,
2911
+ message: `It could not be stored: ${error instanceof Error ? error.message : String(error)}`,
2912
+ }));
2913
+ // Slack wants the submission acked within three seconds, and storing
2914
+ // is two locked file writes and every bus subscriber: usually far
2915
+ // inside that, not always. An answer in time keeps the form open with
2916
+ // a refusal beside the field; a late one closes the form first and
2917
+ // tells the submitter privately, rather than letting Slack time out
2918
+ // and retry a submission the first attempt may already have stored.
2919
+ let timer;
2920
+ const deadline = new Promise((resolve) => {
2921
+ timer = setTimeout(() => resolve('late'), CREDENTIAL_ACK_MS);
2922
+ });
2923
+ const first = await Promise.race([answer, deadline]);
2924
+ clearTimeout(timer);
2925
+ if (first !== 'late') {
2926
+ // A plain ack closes the form. The message is rewritten from
2927
+ // `credential.provided`, the one path every way of answering goes
2928
+ // through.
2929
+ await (first.ok ? args.ack() : refuse(first.message));
2930
+ if (!first.ok && first.retired === true) {
2931
+ track(retireCredentialPost(requestId, post, first.message));
2932
+ }
2933
+ return;
2934
+ }
2935
+ await args.ack();
2936
+ track(answer.then(async (result) => {
2937
+ if (result.ok) {
2938
+ return;
2939
+ }
2940
+ if (result.retired === true) {
2941
+ await retireCredentialPost(requestId, post, result.message);
2942
+ }
2943
+ try {
2944
+ await connection.web.chat.postEphemeral({
2945
+ channel: post.channel,
2946
+ user: submitter,
2947
+ text: `${post.name} was not added: ${result.message}`,
2948
+ ...(post.thread !== undefined ? { thread_ts: post.thread } : {}),
2949
+ });
2950
+ }
2951
+ catch (error) {
2952
+ warn(`slack: could not tell ${submitter} a credential was not added (${error instanceof Error ? error.message : String(error)})`);
2953
+ }
2954
+ }));
2383
2955
  };
2384
2956
  const handleInteractive = async (connection, args) => {
2957
+ if (args.body?.type === 'view_submission') {
2958
+ if (args.body.view?.callback_id === CREDENTIAL_VIEW) {
2959
+ await handleCredentialSubmission(connection, args);
2960
+ }
2961
+ else {
2962
+ await args.ack();
2963
+ }
2964
+ return;
2965
+ }
2966
+ const first = args.body?.actions?.[0];
2967
+ if (first?.action_id === CREDENTIAL_ADD_ACTION && first.value) {
2968
+ await handleCredentialClick(connection, args, first.value);
2969
+ return;
2970
+ }
2385
2971
  // Ack first and unconditionally: Slack retries an unacked interaction,
2386
2972
  // and a redelivered click on a request that is no longer pending would
2387
2973
  // read as a second, rejected decision.
@@ -2602,11 +3188,40 @@ export const createSlackChannelAdapter = (options) => {
2602
3188
  * a reply nobody answers. The agent alone in its thread, which is nearly
2603
3189
  * every thread, is answered without the question arising.
2604
3190
  */
2605
- const resolveFollowUpWinner = async (parts) => {
3191
+ const resolveFollowUpWinner = async (parts, ts) => {
2606
3192
  const gateway = gatewayRef;
2607
3193
  if (!gateway?.sessionRouting || !parts.thread) {
2608
3194
  return undefined;
2609
3195
  }
3196
+ // A message an agent already accepted is that agent's, whoever has spoken
3197
+ // since. Nothing in memory says so after a restart, and the order below
3198
+ // would hand a redelivery to the newer speaker — whose session holds no
3199
+ // key for it, so it would run the message as new. Asked of every
3200
+ // configured agent, whatever it listens to now and whether or not it is
3201
+ // connected, unlike the order below: what counts is that it took the
3202
+ // message as addressed to it (a judging agent's turn nobody asked for
3203
+ // does not), an operator may have changed how it listens since, and an
3204
+ // owner whose app failed to come back after the restart still started
3205
+ // that turn — the live agents stand down for it rather than run it again.
3206
+ // Its workspace is the one it last authenticated in, when known.
3207
+ if (gateway.holdsMessage) {
3208
+ for (const agentId of configuredAgents) {
3209
+ const teamId = botIdentities.get(agentId)?.teamId;
3210
+ if (teamId !== undefined && teamId !== parts.team) {
3211
+ continue;
3212
+ }
3213
+ const sessionId = channelSessionKey({
3214
+ channel: 'slack',
3215
+ agentId,
3216
+ team: parts.team,
3217
+ conversation: parts.conversation,
3218
+ thread: parts.thread,
3219
+ });
3220
+ if (await gateway.holdsMessage(sessionId, dispatchKeyFor(`${agentId}:${parts.conversation}:${ts}`))) {
3221
+ return agentId;
3222
+ }
3223
+ }
3224
+ }
2610
3225
  const engaged = [];
2611
3226
  // Live connections in this message's OWN workspace. Live, because an
2612
3227
  // agent whose app failed to connect cannot answer anything, and leaving
@@ -2685,7 +3300,7 @@ export const createSlackChannelAdapter = (options) => {
2685
3300
  if (existing) {
2686
3301
  return existing.verdict;
2687
3302
  }
2688
- const entry = { verdict: resolveFollowUpWinner(parts), settled: false };
3303
+ const entry = { verdict: resolveFollowUpWinner(parts, ts), settled: false };
2689
3304
  coldVerdicts.set(key, entry);
2690
3305
  void entry.verdict.then(() => {
2691
3306
  entry.settled = true;
@@ -2774,6 +3389,10 @@ export const createSlackChannelAdapter = (options) => {
2774
3389
  // answer it and hold the thread. One naming only agents that refuse
2775
3390
  // the sender is still theirs and not this agent's to answer — so the
2776
3391
  // refusing agent stays `named`, and only the handover is withheld.
3392
+ // Judged on configuration, not on a live connection: an agent whose
3393
+ // socket failed to start is still recognizable in a mention, and its
3394
+ // door is still its own. One this adapter was never configured for has
3395
+ // no policy, and nobody's to refuse here, so it admits.
2777
3396
  const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
2778
3397
  const named = handoverTo ?? agentNamedIn(text, team);
2779
3398
  // The key one Slack MESSAGE is known by, whichever delivery carried
@@ -2864,6 +3483,7 @@ export const createSlackChannelAdapter = (options) => {
2864
3483
  event,
2865
3484
  isDm,
2866
3485
  team,
3486
+ eventKey,
2867
3487
  userId: event.user,
2868
3488
  sessionId: channelSessionKey({
2869
3489
  channel: 'slack',
@@ -2963,7 +3583,9 @@ export const createSlackChannelAdapter = (options) => {
2963
3583
  if (!gateway) {
2964
3584
  return;
2965
3585
  }
2966
- const { event, isDm, team, userId, thread, sessionId, threadKey } = admitted;
3586
+ const { event, isDm, team, userId, thread, sessionId, threadKey, eventKey } = admitted;
3587
+ // What the gateway knows this message by (see `dispatchKeyFor`).
3588
+ const dispatchKey = dispatchKeyFor(eventKey);
2967
3589
  // Everything up to (and including) the dispatch call is serialized per
2968
3590
  // session in Slack receipt order: the user lookups and placeholder
2969
3591
  // post below await network I/O, and without this chain a later message
@@ -3030,12 +3652,18 @@ export const createSlackChannelAdapter = (options) => {
3030
3652
  // names its attachments the way an overheard one does.
3031
3653
  // Images and text files share the message's one download deadline.
3032
3654
  const deadline = AbortSignal.timeout(fileDownloadTimeoutMs);
3655
+ const reasons = new Map();
3656
+ if (overhear || judged) {
3657
+ for (const file of event.files ?? []) {
3658
+ reasons.set(file, 'not-opened');
3659
+ }
3660
+ }
3033
3661
  const { images, unread: notImages } = overhear || judged
3034
3662
  ? { images: [], unread: event.files ?? [] }
3035
- : await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, deadline);
3663
+ : await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, reasons, deadline);
3036
3664
  const { texts, unread } = overhear || judged
3037
3665
  ? { texts: [], unread: notImages }
3038
- : await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn);
3666
+ : await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn, reasons);
3039
3667
  // An image or a document with nothing said is still a question
3040
3668
  // ("what's this?"); a file the turn could not open with nothing said
3041
3669
  // is not one, and gets no reply.
@@ -3046,14 +3674,30 @@ export const createSlackChannelAdapter = (options) => {
3046
3674
  // In shared channels the model should know who is speaking; a DM is
3047
3675
  // unambiguous. A bare image in a channel still says who sent it.
3048
3676
  const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
3049
- const userMessage = `${spoken}${textAttachmentBlocks(texts)}${attachmentNote(unread)}`;
3677
+ const userMessage = `${spoken}${textAttachmentBlocks(texts)}${attachmentNote(unread, reasons)}`;
3050
3678
  // Who sent this, judged against the operator's list — per message,
3051
3679
  // never remembered from the first one in the thread. A DM proves
3052
3680
  // nothing about who is typing, so a DM from an unlisted member is
3053
3681
  // `unknown` exactly as a channel mention would be. The same label
3054
3682
  // whether the agent answers or only hears: the text is in its
3055
3683
  // transcript either way.
3056
- const senderTrust = (connection.config.principals ?? []).includes(userId) ? 'user' : 'unknown';
3684
+ const senderTrust = senderTrustFor(connection.config, userId);
3685
+ // Who can read this, so the agent writes for them, and a thread says
3686
+ // it is one. A DM names the other person only when they are one of
3687
+ // the operator's principals: this lands in the system prompt, and
3688
+ // anyone else's display name is text they chose, which a pattern
3689
+ // cannot tell from an instruction phrased as a name.
3690
+ const room = await roomFor(connection, event.channel, isDm ? 'im' : event.channel_type);
3691
+ const conversation = room === undefined
3692
+ ? undefined
3693
+ : {
3694
+ ...room,
3695
+ ...(room.kind === 'direct' && senderTrust === 'user' ? { with: author } : {}),
3696
+ ...(thread !== undefined && room.kind !== 'direct' ? { thread: true } : {}),
3697
+ // People outside this workspace read a Slack Connect channel, and
3698
+ // the event says so itself, even when the lookup failed.
3699
+ ...(room.kind !== 'direct' && args.body?.is_ext_shared_channel === true ? { shared: true } : {}),
3700
+ };
3057
3701
  const metadata = {
3058
3702
  channel: 'slack',
3059
3703
  team,
@@ -3062,6 +3706,7 @@ export const createSlackChannelAdapter = (options) => {
3062
3706
  // Carried so a mid-turn approval question is asked in the thread
3063
3707
  // the turn belongs to rather than at the top of a busy channel.
3064
3708
  ...(thread ? { slackThread: thread } : {}),
3709
+ ...(conversation !== undefined ? { [CONVERSATION_METADATA_KEY]: conversation } : {}),
3065
3710
  [SENDER_TRUST_METADATA_KEY]: senderTrust,
3066
3711
  };
3067
3712
  if (overhear) {
@@ -3099,6 +3744,7 @@ export const createSlackChannelAdapter = (options) => {
3099
3744
  statusThread: thread ?? event.ts,
3100
3745
  statusWarned: connection.statusWarned,
3101
3746
  ...(ahead ? { after: ahead } : {}),
3747
+ ...(isDm ? { dmWith: userId } : {}),
3102
3748
  });
3103
3749
  if (!streaming) {
3104
3750
  replyOrder.set(sessionId, renderer.posted);
@@ -3129,6 +3775,7 @@ export const createSlackChannelAdapter = (options) => {
3129
3775
  // stopped until the reply ahead of it posted. In a channel thread the
3130
3776
  // key is shared, and the running turn's status stays up instead.
3131
3777
  renderer.showLoading(queue.length === 1 || queue[0]?.statusThread !== renderer.statusThread);
3778
+ let repeat;
3132
3779
  const turn = gateway.dispatch({
3133
3780
  sessionId,
3134
3781
  agentId: connection.config.agentId,
@@ -3137,8 +3784,15 @@ export const createSlackChannelAdapter = (options) => {
3137
3784
  ...(judged ? { addressed: false } : {}),
3138
3785
  turnId: renderer.turnId,
3139
3786
  metadata,
3787
+ // A redelivery this process no longer remembers (it restarted, or
3788
+ // the dedupe evicted it) is still the message it was: the gateway
3789
+ // runs no second turn for it, and says so through `onRepeat`.
3790
+ idempotencyKey: dispatchKey,
3791
+ onRepeat: (kind) => {
3792
+ repeat = kind;
3793
+ },
3140
3794
  });
3141
- return { renderer, turn };
3795
+ return { renderer, turn, repeat: () => repeat };
3142
3796
  });
3143
3797
  const started = await intake;
3144
3798
  if (!started) {
@@ -3156,7 +3810,7 @@ export const createSlackChannelAdapter = (options) => {
3156
3810
  }
3157
3811
  return;
3158
3812
  }
3159
- const { renderer, turn } = started;
3813
+ const { renderer, turn, repeat } = started;
3160
3814
  const removeFromQueue = () => {
3161
3815
  const current = renderers.get(sessionId);
3162
3816
  if (current) {
@@ -3181,15 +3835,73 @@ export const createSlackChannelAdapter = (options) => {
3181
3835
  // events must reach ITS renderer, so this one leaves the queue BEFORE
3182
3836
  // the potentially slow final edit, uploads, and overflow posts.
3183
3837
  removeFromQueue();
3838
+ // The turn is a live earlier delivery's, and that delivery's renderer
3839
+ // posts its answer, or its failure: this one takes down whatever it put
3840
+ // up and says nothing.
3841
+ if (repeat() === 'live') {
3842
+ await renderer.withdraw();
3843
+ renderers.get(sessionId)?.[0]?.refreshLoading();
3844
+ return;
3845
+ }
3846
+ // A finished one's outcome is posted here, because nothing says the
3847
+ // delivery that ran it lived to post it, and no event will carry its
3848
+ // files: they are read from the session, like its reply below.
3849
+ //
3850
+ // Less what the start-up sweep already said, when it finished this
3851
+ // message's turn while the redelivery waited behind it: that outcome
3852
+ // was reported ahead of this renderer as a turn nobody was rendering,
3853
+ // and posting it again would say it twice. It was this message's turn
3854
+ // if it is the session's latest — anything finished after it would be
3855
+ // the latest instead — and the report says, part by part, what reached
3856
+ // the thread: the files it landed are not posted again, and the text
3857
+ // only if it was not said in full.
3858
+ const finished = repeat() === 'finished';
3859
+ const reportedAhead = session && finished && renderer.outcomeReportedAhead !== undefined && isLatestTurn(session, dispatchKey)
3860
+ ? await renderer.outcomeReportedAhead
3861
+ : undefined;
3862
+ if (session && finished) {
3863
+ // Counted, not a set: a turn can produce one path twice, and each
3864
+ // result is an upload of its own, so one landing does not cover both.
3865
+ const landed = new Map();
3866
+ for (const filePath of reportedAhead?.landed ?? []) {
3867
+ landed.set(filePath, (landed.get(filePath) ?? 0) + 1);
3868
+ }
3869
+ renderer.queueFiles(turnFilesFor(session, dispatchKey).filter((file) => {
3870
+ const left = landed.get(file.path) ?? 0;
3871
+ if (left > 0) {
3872
+ landed.set(file.path, left - 1);
3873
+ return false;
3874
+ }
3875
+ return true;
3876
+ }));
3877
+ }
3878
+ if (reportedAhead?.said === true) {
3879
+ await renderer.withdraw();
3880
+ renderers.get(sessionId)?.[0]?.refreshLoading();
3881
+ return;
3882
+ }
3883
+ // A dispatch whose turn fails rejects, but one that repeats a turn that
3884
+ // already failed — or continues a crashed one the gateway could only
3885
+ // fail — resolves with the failed session. Its failure is posted as the
3886
+ // original delivery's would have been, not as a reply that says nothing.
3887
+ const failedWith = session ? turnFailureFor(session, dispatchKey) : undefined;
3888
+ if (failedWith !== undefined) {
3889
+ failure = new Error(failedWith);
3890
+ session = undefined;
3891
+ }
3184
3892
  if (session) {
3185
3893
  // The reply is final here, so this is when the thread's other agents
3186
3894
  // take their place for it — before the final edit and the overflow
3187
3895
  // posts, which are network I/O a later message must not overtake.
3188
3896
  // A DM has no thread, and nobody else in it.
3189
- const reply = latestTurnReply(session);
3897
+ // A finished repeat's session may have moved on since; its own turn
3898
+ // is found by the key.
3899
+ const reply = finished ? turnReplyFor(session, dispatchKey) : latestTurnReply(session);
3190
3900
  // A turn nobody asked for that said nothing posts nothing; every
3191
- // other turn says `(no reply)` where its answer would have gone.
3192
- const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : lastAssistantReply(session));
3901
+ // other turn says `(no reply)` where its answer would have gone — one
3902
+ // rule with the gateway's `sessionRouting`, which posts the same
3903
+ // message for a turn this adapter did not start.
3904
+ const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : reply ?? NO_REPLY_TEXT);
3193
3905
  const heard = thread !== undefined && reply !== undefined
3194
3906
  ? overhearReply(connection, event.channel, thread, reply, session, finalized.then((outcome) => outcome.published))
3195
3907
  : undefined;
@@ -3226,6 +3938,7 @@ export const createSlackChannelAdapter = (options) => {
3226
3938
  return {
3227
3939
  name: 'slack',
3228
3940
  resolveOutbound,
3941
+ requestCredential,
3229
3942
  async start(gateway) {
3230
3943
  gatewayRef = gateway;
3231
3944
  unsubscribe = gateway.bus.subscribe((event) => {
@@ -3236,6 +3949,10 @@ export const createSlackChannelAdapter = (options) => {
3236
3949
  track(renderApprovalRequest(event));
3237
3950
  return;
3238
3951
  }
3952
+ if (event.type === 'credential.provided') {
3953
+ track(settleCredentialPost(event));
3954
+ return;
3955
+ }
3239
3956
  if (event.type === 'tool.approval-resolved') {
3240
3957
  // The question was posted into the turn's thread, which took the
3241
3958
  // loading status down with it; the turn works on once answered.
@@ -3310,7 +4027,11 @@ export const createSlackChannelAdapter = (options) => {
3310
4027
  // connection — never leaves the queued reply stalled behind it.
3311
4028
  if (event.type === 'session.failed' && !rendered) {
3312
4029
  const claim = head?.reserveHandover();
3313
- track(reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => {
4030
+ const report = reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId));
4031
+ if (head) {
4032
+ head.outcomeReportedAhead = report.catch(() => NOTHING_REPORTED);
4033
+ }
4034
+ track(report.then(() => { }).finally(() => {
3314
4035
  claim?.release();
3315
4036
  head?.refreshLoading();
3316
4037
  }));
@@ -3318,7 +4039,11 @@ export const createSlackChannelAdapter = (options) => {
3318
4039
  }
3319
4040
  if (event.type === 'session.completed' && !rendered) {
3320
4041
  const claim = head?.reserveHandover();
3321
- track(reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => {
4042
+ const report = reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId));
4043
+ if (head) {
4044
+ head.outcomeReportedAhead = report.catch(() => NOTHING_REPORTED);
4045
+ }
4046
+ track(report.then(() => { }).finally(() => {
3322
4047
  claim?.release();
3323
4048
  head?.refreshLoading();
3324
4049
  }));
@@ -3367,19 +4092,24 @@ export const createSlackChannelAdapter = (options) => {
3367
4092
  for (const connection of authenticated) {
3368
4093
  const { config, socket } = connection;
3369
4094
  try {
4095
+ // Returns the work it started: the SDK ignores it, and a test's fake
4096
+ // socket waits on it rather than guessing how long it takes.
3370
4097
  const onEvent = (args) => {
3371
4098
  const handled = handleInbound(connection, args).catch((error) => {
3372
4099
  warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
3373
4100
  });
3374
4101
  inflight.add(handled);
3375
4102
  void handled.finally(() => inflight.delete(handled));
4103
+ return handled;
3376
4104
  };
3377
4105
  socket.on('app_mention', onEvent);
3378
4106
  socket.on('message', onEvent);
3379
4107
  socket.on('interactive', (args) => {
3380
- track(handleInteractive(connection, args).catch((error) => {
4108
+ const handled = handleInteractive(connection, args).catch((error) => {
3381
4109
  warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
3382
- }));
4110
+ });
4111
+ track(handled);
4112
+ return handled;
3383
4113
  });
3384
4114
  await socket.start();
3385
4115
  connections.push(connection);
@@ -3425,6 +4155,7 @@ export const createSlackChannelAdapter = (options) => {
3425
4155
  botIdentities.clear();
3426
4156
  rendering.clear();
3427
4157
  resolvedWhileRendering.clear();
4158
+ credentialsPosting.clear();
3428
4159
  },
3429
4160
  };
3430
4161
  };