@stratusagent/channel-slack 0.14.0 → 0.14.2

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
@@ -3,8 +3,9 @@ import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
- 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, HostRefusalError, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/core';
6
+ 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_REPLAY_COUNT, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, HostRefusalError, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/core';
7
7
  import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
8
+ import { textingParts } from "./texts.js";
8
9
  /**
9
10
  * The longest display name the model is shown. Slack caps profile names
10
11
  * well under this; the bound is for a profile that is not a name.
@@ -110,6 +111,72 @@ const STATUS_REFRESH_MS = 60_000;
110
111
  * long.
111
112
  */
112
113
  const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
114
+ /**
115
+ * How long `stop()` waits on a socket that is still starting (card 935).
116
+ * Well inside the gateway's shutdown window, so a socket stuck connecting
117
+ * is reported by name rather than as the whole window running out.
118
+ */
119
+ const DEFAULT_STOP_WAIT_MS = 10_000;
120
+ /**
121
+ * How long an approval prompt waits for the words its turn wrote before the
122
+ * call to land first (card 950). Long enough for an ordinary post, short
123
+ * enough that a slow or refusing Slack never keeps the question from an
124
+ * approver: the turn is parked on that answer.
125
+ */
126
+ const DEFAULT_APPROVAL_PROMPT_WAIT_MS = 5_000;
127
+ /**
128
+ * How long a task works before its progress message opens (card 949): long
129
+ * enough that a quick answer, a lookup or two, is just its reply; short
130
+ * enough that a task taking minutes shows what it is doing while it does.
131
+ */
132
+ const DEFAULT_PROGRESS_AFTER_MS = 10_000;
133
+ /** Room kept for a progress message's first line and its `…` when its notes are trimmed. */
134
+ const PROGRESS_HEADER_ROOM = 200;
135
+ /**
136
+ * The most a progress message holds under its first line. Words longer than
137
+ * this are content, not a step — an analysis written before a tool call —
138
+ * and are posted whole as messages of their own, as before card 949, rather
139
+ * than cut to fit and lost.
140
+ */
141
+ const PROGRESS_NOTE_ROOM = SLACK_MAX_MESSAGE_CHARS - PROGRESS_HEADER_ROOM;
142
+ const stepCount = (steps) => `${steps} ${steps === 1 ? 'step' : 'steps'}`;
143
+ /** A tool's name as code, which no backtick of its own can close early. */
144
+ const toolCode = (toolName) => `\`${escapeSlackText(toolName.replace(/`/g, ''))}\``;
145
+ /**
146
+ * The first line of a progress message: what the turn is doing, from the
147
+ * tool calls it made and is making. Words the model wrote are under it, as
148
+ * notes; this line is the part that cannot claim more than happened.
149
+ */
150
+ const progressHeader = (state) => {
151
+ switch (state.end) {
152
+ case 'done':
153
+ return `✓ Done · ${stepCount(state.steps)}`;
154
+ case 'stopped':
155
+ return `■ Stopped after ${stepCount(state.steps)}`;
156
+ case 'failed':
157
+ return `✗ Failed after ${stepCount(state.steps)}`;
158
+ default:
159
+ break;
160
+ }
161
+ if (state.waitingOn !== undefined) {
162
+ return `⏸ Waiting for approval to run ${toolCode(state.waitingOn)}`;
163
+ }
164
+ const latest = [...state.running.values()].at(-1);
165
+ if (latest !== undefined) {
166
+ return `⚙ Working · step ${state.steps}: ${toolCode(latest)}`;
167
+ }
168
+ return `⚙ Working · ${stepCount(state.steps)} done`;
169
+ };
170
+ /** Resolves when `work` settles or after `ms`, whichever is first; never rejects. */
171
+ const settledWithin = (work, ms) => new Promise((resolve) => {
172
+ const timer = setTimeout(resolve, ms);
173
+ timer.unref?.();
174
+ const done = () => {
175
+ clearTimeout(timer);
176
+ resolve();
177
+ };
178
+ work.then(done, done);
179
+ });
113
180
  /** What a turn that produced no text puts in its message, wherever it is posted from. */
114
181
  const NO_REPLY_TEXT = '(no reply)';
115
182
  /**
@@ -266,6 +333,40 @@ export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signa
266
333
  return { status: response.status, ...typed, body: Buffer.concat(chunks) };
267
334
  };
268
335
  const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
336
+ /**
337
+ * The one origin a file URL may name for the bot token to go with it.
338
+ * `url_private` does not by itself mean Slack hosts the file: for a remote
339
+ * file (an app's pointer to a file it hosts), Slack returns the app's own
340
+ * URL there, on whatever host the app chose, so any app with
341
+ * `remote_files:write` could collect the token of every agent that opened
342
+ * its file. Slack's rule is never to send Slack credentials to such a URL,
343
+ * and to compare the parsed host exactly rather than a prefix
344
+ * (docs.slack.dev, Working with files: Downloading files). Previews and
345
+ * downloads both live here. A redirect elsewhere is not a leak either:
346
+ * fetch drops `authorization` on a cross-origin redirect.
347
+ */
348
+ const SLACK_FILE_ORIGIN = 'https://files.slack.com';
349
+ /**
350
+ * A fetcher that is only ever handed URLs on Slack's file host. Anything
351
+ * else is refused before a request is made, with a reason that names the
352
+ * origin, and the callers' own handling turns that into a name-only file
353
+ * and an operator warning, as for any download that failed.
354
+ */
355
+ const onlySlackFileHost = (fetchFile) => (url, botToken, signal, maxBytes) => {
356
+ let origin;
357
+ try {
358
+ const parsed = new URL(url);
359
+ // Credentials in the URL would be sent with the token: not Slack's.
360
+ origin = parsed.username === '' && parsed.password === '' ? parsed.origin : `${parsed.protocol}//(credentials)@${parsed.host}`;
361
+ }
362
+ catch {
363
+ origin = undefined;
364
+ }
365
+ if (origin !== SLACK_FILE_ORIGIN) {
366
+ return Promise.reject(new Error(`${origin ?? 'its link'} is not Slack's file host (${SLACK_FILE_ORIGIN}), so the bot token is not sent there`));
367
+ }
368
+ return fetchFile(url, botToken, signal, maxBytes);
369
+ };
269
370
  /**
270
371
  * The dispatch's idempotency key for a message's dedupe key: the key itself
271
372
  * when it fits the gateway's bound, and a digest of it when it does not —
@@ -407,6 +508,40 @@ class ReplyRenderer {
407
508
  * once that is known, so the thread's other agents hear it in its place.
408
509
  */
409
510
  onInterim;
511
+ /**
512
+ * Card 949, `final` mode: a task says what it is about to do in a
513
+ * message of its own (its first words before a tool), then keeps ONE
514
+ * progress message up to date — a first line built from its tool events,
515
+ * and under it whatever else it wrote between tools — and posts its reply
516
+ * as a message of its own. Narration that used to be a post per step is
517
+ * an edit, so a long task is three messages, not twenty.
518
+ *
519
+ * Whether this turn's first words have gone out, so later words are notes.
520
+ */
521
+ firstWordsSaid = false;
522
+ /** Whether the progress message has been asked for; it may not have landed yet. */
523
+ progressOpen = false;
524
+ /** The progress message, once Slack took it. */
525
+ progressRef;
526
+ /** What the first line says, from this turn's own tool events only. */
527
+ progress = { steps: 0, running: new Map(), waitingOn: undefined, end: undefined };
528
+ /**
529
+ * The notes under that line, as sent: converted, oldest dropped past one
530
+ * message's room. `shown` once a write that held the note has landed: a
531
+ * note trimmed while its write was still queued never reached anyone.
532
+ */
533
+ progressNotes = [];
534
+ /** Whether older notes were dropped for room, so the message says so. */
535
+ progressNotesCut = false;
536
+ /** Whether the last thing this turn said is a note rather than a message of its own. */
537
+ lastSaidWasNote = false;
538
+ progressTimer;
539
+ progressOpenTimer;
540
+ lastProgressAt = 0;
541
+ turnStartedAt = 0;
542
+ /** How the turn ended, for the progress message: a stop or a failure says so before it finalizes. */
543
+ ending = 'done';
544
+ progressAfterMs;
410
545
  /** The lowest trust this turn's session was lowered to while it ran (`session.tainted`). */
411
546
  taintedTo;
412
547
  turnBreakPending = false;
@@ -473,6 +608,12 @@ class ReplyRenderer {
473
608
  * could not be opened.
474
609
  */
475
610
  streaming;
611
+ /**
612
+ * Whether the reply, and what the turn says before a tool, are posted a
613
+ * paragraph to a message (`texts` mode). Never with `streaming`: there
614
+ * is one placeholder to stream into.
615
+ */
616
+ texts;
476
617
  /**
477
618
  * The thread the loading status belongs to: the reply's thread, or the
478
619
  * message itself in a DM, whose reply has no thread to key one on.
@@ -505,6 +646,13 @@ class ReplyRenderer {
505
646
  statusTimer;
506
647
  /** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
507
648
  runningTool;
649
+ /**
650
+ * Calls started and not yet settled, by call id, oldest first. Core runs
651
+ * a response's read-only calls together, so one can settle while another
652
+ * still runs: both lines name the newest call still running, and clear
653
+ * only once none is.
654
+ */
655
+ activeTools = new Map();
508
656
  web;
509
657
  /**
510
658
  * The one person in the conversation when this turn is a DM: whoever sent
@@ -527,11 +675,13 @@ class ReplyRenderer {
527
675
  this.lazy = options.lazy ?? false;
528
676
  this.now = options.now ?? Date.now;
529
677
  this.streaming = options.streaming ?? true;
678
+ this.texts = !this.streaming && options.texts === true;
530
679
  this.statusThread = options.statusThread ?? threadTs;
531
680
  this.statusWarned = options.statusWarned ?? { value: false };
532
681
  this.after = options.after ?? Promise.resolve();
533
682
  this.dmWith = options.dmWith;
534
683
  this.onInterim = options.onInterim;
684
+ this.progressAfterMs = options.progressAfterMs ?? DEFAULT_PROGRESS_AFTER_MS;
535
685
  }
536
686
  /**
537
687
  * Show Slack's loading status for this turn, and keep it up until the
@@ -622,6 +772,9 @@ class ReplyRenderer {
622
772
  * included, so this turn's placeholder starts empty.
623
773
  */
624
774
  beginTurn() {
775
+ if (!this.turnStarted) {
776
+ this.turnStartedAt = Date.now();
777
+ }
625
778
  this.turnStarted = true;
626
779
  if (this.pendingEdit) {
627
780
  clearTimeout(this.pendingEdit);
@@ -629,6 +782,7 @@ class ReplyRenderer {
629
782
  }
630
783
  this.buffer = '';
631
784
  this.interim = '';
785
+ this.activeTools.clear();
632
786
  this.toolLine = undefined;
633
787
  this.runningTool = undefined;
634
788
  this.turnBreakPending = false;
@@ -705,9 +859,16 @@ class ReplyRenderer {
705
859
  // all, so this is the only thing between the text before a tool and
706
860
  // the text after it — without it they fuse in the live message.
707
861
  this.turnBreakPending = true;
708
- this.toolLine = `⚙ ${event.call.toolName}…`;
709
- this.runningTool = event.call.toolName;
862
+ this.activeTools.set(event.call.id, event.call.toolName);
863
+ this.showActiveTool();
710
864
  this.flushInterim(ofThisTurn);
865
+ if (ofThisTurn && this.mayShowProgress()) {
866
+ this.progress.steps += 1;
867
+ this.progress.running.set(event.call.id, event.call.toolName);
868
+ this.progress.waitingOn = undefined;
869
+ this.openProgressInTime();
870
+ this.scheduleProgress();
871
+ }
711
872
  this.scheduleEdit();
712
873
  return;
713
874
  }
@@ -716,21 +877,27 @@ class ReplyRenderer {
716
877
  // else clears the status line or marks the boundary for it — the
717
878
  // message would keep claiming a refused tool is running, and the
718
879
  // model's follow-up would fuse with the text before the attempt.
719
- this.toolLine = undefined;
720
- this.runningTool = undefined;
880
+ this.activeTools.delete(event.call.id);
881
+ this.showActiveTool();
721
882
  this.turnBreakPending = true;
722
883
  // Words written before a call that was refused were still said
723
884
  // before a tool, and must not run into the next response's.
724
885
  this.flushInterim(ofThisTurn);
886
+ if (ofThisTurn) {
887
+ this.settleProgressStep(event.call.id);
888
+ }
725
889
  this.scheduleEdit();
726
890
  return;
727
891
  }
728
892
  if (event.type === 'tool.completed') {
729
- this.toolLine = undefined;
730
- this.runningTool = undefined;
893
+ this.activeTools.delete(event.result.callId);
894
+ this.showActiveTool();
731
895
  // Settled here too for a call rejected before it ran, which never
732
896
  // reached `tool.called`; after one that did, this is empty.
733
897
  this.flushInterim(ofThisTurn);
898
+ if (ofThisTurn) {
899
+ this.settleProgressStep(event.result.callId);
900
+ }
734
901
  // Also a boundary, and not only for symmetry: a call rejected before
735
902
  // execution settles as tool.completed without ever having emitted
736
903
  // tool.called, so this is the only mark that attempt leaves.
@@ -821,8 +988,18 @@ class ReplyRenderer {
821
988
  const text = this.interim.trim();
822
989
  this.interim = '';
823
990
  if (this.streaming || this.lazy || !this.turnStarted || !ofThisTurn || text.length === 0) {
824
- return;
991
+ return undefined;
992
+ }
993
+ // Only words before the turn's first tool are a message of their own:
994
+ // after one, a step has run, and what follows is progress whether or
995
+ // not the turn opened with a line.
996
+ if (this.firstWordsSaid || this.progressOpen || this.progress.steps > 0) {
997
+ const note = toSlackMrkdwn(text);
998
+ if (note.length <= PROGRESS_NOTE_ROOM) {
999
+ return this.noteProgress(text, note);
1000
+ }
825
1001
  }
1002
+ this.firstWordsSaid = true;
826
1003
  const handover = this.handover;
827
1004
  const after = this.after;
828
1005
  let heard = '';
@@ -837,7 +1014,7 @@ class ReplyRenderer {
837
1014
  .then(async () => {
838
1015
  const landed = [];
839
1016
  try {
840
- for (const chunk of messageChunks(text)) {
1017
+ for (const chunk of this.chunksOf(text)) {
841
1018
  const posted = await this.web.chat.postMessage({
842
1019
  channel: this.channel,
843
1020
  text: chunk,
@@ -854,12 +1031,13 @@ class ReplyRenderer {
854
1031
  // reply, where repeating a few lines beats losing the rest.
855
1032
  if (landed.length > 0) {
856
1033
  this.interimPosted = true;
857
- heard = landed.length === messageChunks(text).length ? text : landed.join('\n\n');
1034
+ heard = landed.length === this.chunksOf(text).length ? text : landed.join('\n\n');
858
1035
  }
859
1036
  settleLanded(landed.length > 0);
860
1037
  }
861
1038
  this.lastInterim = text;
862
1039
  this.interimSaid.push(text);
1040
+ this.lastSaidWasNote = false;
863
1041
  // A post of the app's in this thread takes the status down while
864
1042
  // the turn is still working; put it back.
865
1043
  this.refreshLoading();
@@ -868,6 +1046,230 @@ class ReplyRenderer {
868
1046
  // Settled whatever happened above, or the other agents' intake would
869
1047
  // wait on it for good. A second settle is a no-op.
870
1048
  .finally(() => settleLanded(false));
1049
+ return landedNow;
1050
+ }
1051
+ /**
1052
+ * Whether this turn keeps a progress message (card 949): `final` mode,
1053
+ * a turn somebody asked for, and its own — the same turns that post
1054
+ * their words before a tool. A streaming turn's placeholder already
1055
+ * shows its tool lines, and a turn nobody asked for must not speak
1056
+ * before it decides to.
1057
+ */
1058
+ mayShowProgress() {
1059
+ return !this.streaming && !this.lazy && this.turnStarted && !this.finalized;
1060
+ }
1061
+ /**
1062
+ * Words the turn wrote between tools after its first line: a note in the
1063
+ * progress message, opening it if need be, rather than a post of their
1064
+ * own. Heard by the thread's other agents and stripped from a reply that
1065
+ * repeats them, as a message of their own would be. A note Slack refused
1066
+ * is taken out again, so a later edit never shows words nobody heard.
1067
+ */
1068
+ noteProgress(text, converted) {
1069
+ this.progressOpen = true;
1070
+ this.clearProgressOpenTimer();
1071
+ const note = { text: converted };
1072
+ this.progressNotes.push(note);
1073
+ this.trimProgressNotes();
1074
+ let heard = '';
1075
+ let settleLanded;
1076
+ const landedNow = new Promise((resolve) => {
1077
+ settleLanded = resolve;
1078
+ });
1079
+ this.onInterim?.(() => heard, landedNow);
1080
+ // Said once a write that held it has landed, whichever write that was,
1081
+ // and only then. Its own write can land after a slow Slack let later
1082
+ // notes trim it out unseen, or be refused after an earlier write showed
1083
+ // it. Unseen, it is not said: the others do not hear it, and a reply
1084
+ // that repeats it keeps it.
1085
+ const settle = () => {
1086
+ if (!note.shown) {
1087
+ this.progressNotes = this.progressNotes.filter((kept) => kept !== note);
1088
+ settleLanded(false);
1089
+ return;
1090
+ }
1091
+ this.interimPosted = true;
1092
+ heard = text;
1093
+ this.lastInterim = text;
1094
+ this.interimSaid.push(text);
1095
+ this.lastSaidWasNote = true;
1096
+ settleLanded(true);
1097
+ };
1098
+ this.queueProgressWrite(settle, settle);
1099
+ return landedNow;
1100
+ }
1101
+ /**
1102
+ * Keep the notes to what one message holds with its first line: the
1103
+ * newest, since the oldest are what a reader has already seen. Past
1104
+ * that the message opens with `…`, and memory stays bounded however
1105
+ * long the task runs.
1106
+ */
1107
+ trimProgressNotes() {
1108
+ let size = this.progressNotes.reduce((total, note) => total + note.text.length + 2, 0);
1109
+ while (size > PROGRESS_NOTE_ROOM && this.progressNotes.length > 1) {
1110
+ size -= (this.progressNotes.shift()?.text.length ?? 0) + 2;
1111
+ this.progressNotesCut = true;
1112
+ }
1113
+ }
1114
+ progressText(omitLast = false) {
1115
+ const notes = omitLast ? this.progressNotes.slice(0, -1) : this.progressNotes;
1116
+ const parts = [progressHeader(this.progress), ...(this.progressNotesCut ? ['…'] : []), ...notes.map((note) => note.text)];
1117
+ return truncateForSlack(parts.join('\n\n'));
1118
+ }
1119
+ /**
1120
+ * Put the progress message as it stands now into the thread: posted the
1121
+ * first time, edited after. On the upload chain, behind the turn ahead
1122
+ * and any handover, so it lands below this turn's first line and above
1123
+ * its files and reply, and reads its state when it runs, not when asked.
1124
+ */
1125
+ queueProgressWrite(landed, refused) {
1126
+ const handover = this.handover;
1127
+ const after = this.after;
1128
+ this.uploadChain = this.uploadChain
1129
+ .then(() => handover)
1130
+ .then(() => after)
1131
+ .then(async () => {
1132
+ this.lastProgressAt = Date.now();
1133
+ const held = [...this.progressNotes];
1134
+ await this.writeProgress(this.progressText());
1135
+ for (const note of held) {
1136
+ note.shown = true;
1137
+ }
1138
+ landed?.();
1139
+ })
1140
+ .catch((error) => {
1141
+ refused?.();
1142
+ this.warn(`could not update the progress message: ${error instanceof Error ? error.message : String(error)}`);
1143
+ });
1144
+ }
1145
+ async writeProgress(text) {
1146
+ const ref = this.progressRef;
1147
+ if (ref) {
1148
+ await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text });
1149
+ return;
1150
+ }
1151
+ const posted = await this.web.chat.postMessage({
1152
+ channel: this.channel,
1153
+ text,
1154
+ ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
1155
+ });
1156
+ if (posted.ts) {
1157
+ this.progressRef = { channel: posted.channel ?? this.channel, ts: posted.ts };
1158
+ }
1159
+ this.placeText(posted.ts);
1160
+ // A post of the app's in this thread takes the status down while the
1161
+ // turn is still working; put it back.
1162
+ this.refreshLoading();
1163
+ }
1164
+ /** Edit the progress message for a step that started or settled, at most once per edit interval. */
1165
+ scheduleProgress() {
1166
+ if (!this.progressOpen || this.finalized || this.progressTimer) {
1167
+ return;
1168
+ }
1169
+ const delay = Math.max(0, this.editIntervalMs - (Date.now() - this.lastProgressAt));
1170
+ this.progressTimer = setTimeout(() => {
1171
+ this.progressTimer = undefined;
1172
+ if (!this.finalized) {
1173
+ this.queueProgressWrite();
1174
+ }
1175
+ }, delay);
1176
+ this.progressTimer.unref?.();
1177
+ }
1178
+ /**
1179
+ * Open the progress message once the turn has worked `progressAfterMs`:
1180
+ * now, if it has, or when it will have. Asked on a tool call, so there
1181
+ * is always a step to show; a quick answer ends first and opens none.
1182
+ */
1183
+ openProgressInTime() {
1184
+ if (this.progressOpen || this.progressOpenTimer) {
1185
+ return;
1186
+ }
1187
+ const open = () => {
1188
+ this.progressOpen = true;
1189
+ this.queueProgressWrite();
1190
+ };
1191
+ const wait = this.progressAfterMs - (Date.now() - this.turnStartedAt);
1192
+ if (wait <= 0) {
1193
+ open();
1194
+ return;
1195
+ }
1196
+ this.progressOpenTimer = setTimeout(() => {
1197
+ this.progressOpenTimer = undefined;
1198
+ if (!this.finalized && !this.progressOpen) {
1199
+ open();
1200
+ }
1201
+ }, wait);
1202
+ this.progressOpenTimer.unref?.();
1203
+ }
1204
+ /** A step of this turn's finished or was refused. */
1205
+ settleProgressStep(callId) {
1206
+ if (!this.mayShowProgress()) {
1207
+ return;
1208
+ }
1209
+ this.progress.running.delete(callId);
1210
+ this.progress.waitingOn = undefined;
1211
+ this.scheduleProgress();
1212
+ }
1213
+ /**
1214
+ * A call of this turn's is waiting on a person: the progress message says
1215
+ * so until the call runs or is refused. The prompt itself is a message
1216
+ * of its own, below it.
1217
+ */
1218
+ awaitingApproval(toolName, ofThisTurn) {
1219
+ if (!ofThisTurn || !this.mayShowProgress()) {
1220
+ return;
1221
+ }
1222
+ this.progress.waitingOn = toolName;
1223
+ this.scheduleProgress();
1224
+ }
1225
+ clearProgressOpenTimer() {
1226
+ if (this.progressOpenTimer) {
1227
+ clearTimeout(this.progressOpenTimer);
1228
+ this.progressOpenTimer = undefined;
1229
+ }
1230
+ }
1231
+ clearProgressTimers() {
1232
+ this.clearProgressOpenTimer();
1233
+ if (this.progressTimer) {
1234
+ clearTimeout(this.progressTimer);
1235
+ this.progressTimer = undefined;
1236
+ }
1237
+ }
1238
+ /**
1239
+ * The progress message's last edit: how the task ended, and every note
1240
+ * but one that became the reply. Resolves once Slack has answered; a
1241
+ * refusal leaves the last state that landed, and is logged.
1242
+ */
1243
+ async closeProgress(omitLast) {
1244
+ this.clearProgressTimers();
1245
+ const ref = this.progressRef;
1246
+ if (!ref) {
1247
+ return;
1248
+ }
1249
+ this.progress.end = this.ending;
1250
+ try {
1251
+ await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text: this.progressText(omitLast) });
1252
+ }
1253
+ catch (error) {
1254
+ this.warn(`could not finish the progress message: ${error instanceof Error ? error.message : String(error)}`);
1255
+ }
1256
+ }
1257
+ /**
1258
+ * Post what the model said before a call that is about to ask for
1259
+ * approval, and resolve once it has landed (or could not), or at once
1260
+ * when there is nothing to post (card 950).
1261
+ *
1262
+ * The prompt is posted before the call starts, so waiting for
1263
+ * `tool.called` put the question above the line saying what the agent
1264
+ * set out to do, and the approver answered without it. The same flush
1265
+ * `tool.called` would have done, only earlier: what it posts is stripped
1266
+ * from the reply as before, and the flush at `tool.called` finds nothing.
1267
+ */
1268
+ sayBeforeAsking(ofThisTurn) {
1269
+ if (this.finalized) {
1270
+ return undefined;
1271
+ }
1272
+ return this.flushInterim(ofThisTurn);
871
1273
  }
872
1274
  /**
873
1275
  * Everything this turn said in the thread, for its other agents to hear:
@@ -902,6 +1304,12 @@ class ReplyRenderer {
902
1304
  }
903
1305
  return rest === this.lastInterim ? '' : rest;
904
1306
  }
1307
+ /** Point both status lines at the newest call still running, or clear them. */
1308
+ showActiveTool() {
1309
+ const running = [...this.activeTools.values()].at(-1);
1310
+ this.runningTool = running;
1311
+ this.toolLine = running === undefined ? undefined : `⚙ ${running}…`;
1312
+ }
905
1313
  currentText() {
906
1314
  const parts = [this.buffer.trim(), this.toolLine].filter((part) => Boolean(part && part.length > 0));
907
1315
  return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
@@ -1011,6 +1419,7 @@ class ReplyRenderer {
1011
1419
  const hadStatus = this.statusTimer !== undefined;
1012
1420
  this.stopLoading();
1013
1421
  this.finalized = true;
1422
+ this.clearProgressTimers();
1014
1423
  if (!this.streaming) {
1015
1424
  await this.after;
1016
1425
  await this.statusChain;
@@ -1039,10 +1448,25 @@ class ReplyRenderer {
1039
1448
  // tool call) is not posted again, and a turn that said things along
1040
1449
  // the way is not followed by `(no reply)`.
1041
1450
  await this.uploadChain;
1451
+ // Words the turn ended on, written before its last tool call, are its
1452
+ // answer (card 949): a reply that only repeats them, or an empty one,
1453
+ // posts them as the reply, and the progress message lets them go. Left
1454
+ // as its last note, the answer would sit in the message that says how
1455
+ // the work went, above where anyone looks for it.
1456
+ let promoted = false;
1042
1457
  if (this.interimPosted && !this.ref) {
1043
- reply = this.unsaid(reply);
1044
- }
1458
+ const rest = this.unsaid(reply);
1459
+ promoted = rest.length === 0 && this.lastSaidWasNote && this.lastInterim !== undefined;
1460
+ reply = promoted && this.lastInterim !== undefined ? this.lastInterim : rest;
1461
+ }
1462
+ // Alongside the reply rather than ahead of it: an edit keeps its place,
1463
+ // and the answer should not wait on a round trip for a status line.
1464
+ // Words promoted to the reply are the exception: they leave the
1465
+ // progress message only once the reply holding them has landed, so a
1466
+ // reply Slack refuses does not take the answer out of the thread.
1467
+ const closed = promoted ? undefined : this.closeProgress(false);
1045
1468
  if (!this.ref && this.interimPosted && reply.trim().length === 0) {
1469
+ await (closed ?? this.closeProgress(false));
1046
1470
  // Nothing posts after this to take the status down, so it is cleared.
1047
1471
  if (hadStatus) {
1048
1472
  this.setStatus('');
@@ -1051,7 +1475,7 @@ class ReplyRenderer {
1051
1475
  return this.outcome(true, true);
1052
1476
  }
1053
1477
  const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
1054
- const chunks = messageChunks(text);
1478
+ const chunks = this.chunksOf(text);
1055
1479
  // No placeholder — a handover could not open a fresh one — and the
1056
1480
  // reply is a message of its own rather than nothing at all.
1057
1481
  const rest = this.ref ? chunks.slice(1) : chunks;
@@ -1081,6 +1505,7 @@ class ReplyRenderer {
1081
1505
  // avoid, so it is taken back like an abandoned attempt's. One Slack
1082
1506
  // will not let go stands, and a message the thread can see is the
1083
1507
  // last thing said whatever it says.
1508
+ await (closed ?? this.closeProgress(published));
1084
1509
  let stranded = false;
1085
1510
  if (this.lazy && this.ref !== undefined && !landed && !this.edited) {
1086
1511
  stranded = !(await this.retract());
@@ -1102,6 +1527,10 @@ class ReplyRenderer {
1102
1527
  }
1103
1528
  return this.outcome(published, landed || this.edited || this.uploaded || stranded, !published);
1104
1529
  }
1530
+ /** This turn's text as the messages it is posted as, in this agent's reply mode. */
1531
+ chunksOf(text) {
1532
+ return messageChunks(text, this.texts ? 'texts' : undefined);
1533
+ }
1105
1534
  placeText(ts) {
1106
1535
  if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
1107
1536
  this.textAt = ts;
@@ -1157,6 +1586,7 @@ class ReplyRenderer {
1157
1586
  const hadStatus = this.statusTimer !== undefined;
1158
1587
  this.stopLoading();
1159
1588
  this.finalized = true;
1589
+ this.clearProgressTimers();
1160
1590
  // The barrier `finalizeInOrder` takes, for the same reason: the turn
1161
1591
  // queued behind this one waits on `posted`, and settling it before
1162
1592
  // the reply ahead has posted would let that turn's reply overtake it.
@@ -1192,6 +1622,7 @@ class ReplyRenderer {
1192
1622
  if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
1193
1623
  return this.finalize('');
1194
1624
  }
1625
+ this.ending = 'stopped';
1195
1626
  return this.finalize(`Stopped by <@${by}>.`);
1196
1627
  }
1197
1628
  /**
@@ -1217,6 +1648,8 @@ class ReplyRenderer {
1217
1648
  this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
1218
1649
  return this.finalize('');
1219
1650
  }
1651
+ // A spent budget is a limit working, not a fault: the task stopped.
1652
+ this.ending = refused ? 'stopped' : 'failed';
1220
1653
  return this.finalize(refused ? message : `Something went wrong: ${message}`);
1221
1654
  }
1222
1655
  /**
@@ -1453,8 +1886,12 @@ const messageText = (text) => truncateForSlack(toSlackMrkdwn(text));
1453
1886
  * Agent text as however many Slack messages it takes. Converted before it
1454
1887
  * is split, so a chunk is measured at the length it will be sent at, and so
1455
1888
  * no rewrite is ever asked to span a boundary.
1889
+ *
1890
+ * In `texts` mode a reply is first cut into the few messages it is texted
1891
+ * as, each then converted and sized on its own: the cuts never fall inside
1892
+ * code, and nothing else in the conversion crosses a line.
1456
1893
  */
1457
- const messageChunks = (text) => splitForSlack(toSlackMrkdwn(text));
1894
+ const messageChunks = (text, replies) => (replies === 'texts' ? textingParts(text) : [text]).flatMap((part) => splitForSlack(toSlackMrkdwn(part)));
1458
1895
  const APPROVAL_ACTIONS = {
1459
1896
  stratus_approve_once: 'once',
1460
1897
  stratus_approve_always: 'always',
@@ -1629,7 +2066,9 @@ const MIB = 1024 * 1024;
1629
2066
  * a 1024-pixel preview of a 14 MB screenshot as the screenshot, and states
1630
2067
  * what the small text says when it could only guess at it.
1631
2068
  */
1632
- const previewNote = (previews) => previews.map(({ name, width, height, original }) => `\n[${name} is shown as Slack's ${width}×${height} preview, because the original ${original}. Small text in it may not be legible: ask for a crop rather than guessing at it.]`).join('');
2069
+ const previewNote = (previews) => previews.map((preview) => `\n[${previewSentence(preview)}]`).join('');
2070
+ /** One preview, said the same way to a turn and to a read of history. */
2071
+ const previewSentence = ({ name, width, height, original }) => `${name} is shown as Slack's ${width}×${height} preview, because the original ${original}. Small text in it may not be legible: ask for a crop rather than guessing at it.`;
1633
2072
  /**
1634
2073
  * A message's attachments, sorted into the images the model will be shown
1635
2074
  * and everything else. An image is fetched only when Slack's own metadata
@@ -1820,6 +2259,102 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1820
2259
  });
1821
2260
  return { images, unread, previews };
1822
2261
  };
2262
+ /** A read's byte cap, said in the unit it was most likely set in. */
2263
+ const readCapLabel = (bytes) => (bytes % MIB === 0 ? `${bytes / MIB} MB` : `${bytes} bytes`);
2264
+ /**
2265
+ * The image cap a read asked for, held to what one request can carry, or
2266
+ * undefined when it asked for none: then files come back by name only.
2267
+ */
2268
+ const historyImageCap = (images) => {
2269
+ if (images === undefined || !Number.isInteger(images.count) || images.count <= 0 || !Number.isFinite(images.bytes) || images.bytes < 1) {
2270
+ return undefined;
2271
+ }
2272
+ return {
2273
+ count: Math.min(images.count, IMAGE_ATTACHMENTS_MAX_REPLAY_COUNT),
2274
+ bytes: Math.min(Math.floor(images.bytes), IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES),
2275
+ };
2276
+ };
2277
+ /** What a read is told about an image it tried to fetch and could not show. */
2278
+ const unshownImageNote = (label, reason) => {
2279
+ switch (reason) {
2280
+ case 'too-large':
2281
+ return `${label} is not shown: it is over the ${IMAGE_ATTACHMENT_MAX_BYTES / MIB} MB or ${IMAGE_ATTACHMENT_MAX_DIMENSION} pixels a side a model can be sent, and Slack had no smaller preview of it that could be.`;
2282
+ case 'timed-out':
2283
+ return `${label} is not shown: it was not downloaded in time.`;
2284
+ case 'unreadable':
2285
+ return `${label} is not shown: its bytes are not a complete image.`;
2286
+ default:
2287
+ return `${label} is not shown: it could not be downloaded.`;
2288
+ }
2289
+ };
2290
+ /**
2291
+ * The images on the messages a read returns, fetched for a read that asks
2292
+ * (`ConversationReadRequest.images`) and set on each message with a note
2293
+ * for every image file it does not show. Each image goes through the same
2294
+ * reader a turn's attachments do — Slack's metadata first, the bytes
2295
+ * checked, Slack's preview for one over the per-image limits, and only
2296
+ * from Slack's own file host — one file at a time, in the order the
2297
+ * messages are returned. The read's cap is spent in that order, and once
2298
+ * an image does not fit, none after it is taken, so what is shown is one
2299
+ * unbroken stretch of the read rather than whichever small ones squeezed
2300
+ * in. One deadline covers every download in the read.
2301
+ */
2302
+ const readHistoryImages = async (sources, cap, botToken, fetchFile, timeoutMs, warn) => {
2303
+ const deadline = AbortSignal.timeout(timeoutMs);
2304
+ const pastBytes = `past this read's cap of ${readCapLabel(cap.bytes)} of images`;
2305
+ let count = 0;
2306
+ let remaining = cap.bytes;
2307
+ let full;
2308
+ for (const { message, files } of sources) {
2309
+ const images = [];
2310
+ const notes = [];
2311
+ for (const file of files) {
2312
+ const label = fileLabel(file);
2313
+ if (!isImageAttachmentMediaType(file.mimetype)) {
2314
+ if (file.mimetype?.startsWith('image/') === true) {
2315
+ notes.push(`${label} is not shown: it is ${file.mimetype}, a kind of image a model cannot be shown.`);
2316
+ }
2317
+ continue;
2318
+ }
2319
+ if (full === undefined && count >= cap.count) {
2320
+ full = `past this read's cap of ${cap.count} images`;
2321
+ }
2322
+ // Slack's own size, trusted before any bytes move. An original over
2323
+ // the per-image limit may still go as its preview, whose size only
2324
+ // its bytes can say.
2325
+ if (full === undefined && file.size !== undefined && file.size <= IMAGE_ATTACHMENT_MAX_BYTES && file.size > remaining) {
2326
+ full = pastBytes;
2327
+ }
2328
+ if (full !== undefined) {
2329
+ notes.push(`${label} is not shown: ${full}.`);
2330
+ continue;
2331
+ }
2332
+ const reasons = new Map();
2333
+ const read = await readImageAttachments([file], botToken, fetchFile, timeoutMs, warn, reasons, deadline);
2334
+ const image = read.images[0];
2335
+ if (image === undefined) {
2336
+ notes.push(unshownImageNote(label, reasons.get(file)));
2337
+ continue;
2338
+ }
2339
+ const size = Buffer.byteLength(image.data, 'base64');
2340
+ if (size > remaining) {
2341
+ full = pastBytes;
2342
+ notes.push(`${label} is not shown: ${full}.`);
2343
+ continue;
2344
+ }
2345
+ remaining -= size;
2346
+ count += 1;
2347
+ images.push(image);
2348
+ notes.push(...read.previews.map(previewSentence));
2349
+ }
2350
+ if (images.length > 0) {
2351
+ message.images = images;
2352
+ }
2353
+ if (notes.length > 0) {
2354
+ message.imageNotes = notes;
2355
+ }
2356
+ }
2357
+ };
1823
2358
  /** The most one text attachment may weigh to be read into the message. */
1824
2359
  const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
1825
2360
  /**
@@ -2304,7 +2839,10 @@ const credentialModal = (post, requestId) => ({
2304
2839
  type: 'input',
2305
2840
  block_id: CREDENTIAL_BLOCK,
2306
2841
  label: { type: 'plain_text', text: `Value for ${post.name}` },
2307
- element: { type: 'plain_text_input', action_id: CREDENTIAL_INPUT },
2842
+ // Several lines: a one-line field flattens the line breaks of a pasted
2843
+ // key file, such as a PEM private key, and the key stops working.
2844
+ element: { type: 'plain_text_input', action_id: CREDENTIAL_INPUT, multiline: true },
2845
+ hint: { type: 'plain_text', text: 'Paste the whole value. A key file, such as a PEM private key, keeps its line breaks.' },
2308
2846
  },
2309
2847
  ],
2310
2848
  });
@@ -2351,6 +2889,33 @@ const slackErrorCode = (error) => {
2351
2889
  const code = error?.data?.error;
2352
2890
  return typeof code === 'string' && code.length > 0 ? code : undefined;
2353
2891
  };
2892
+ /** Slack's code for a failed call, or the SDK's message when Slack never answered. */
2893
+ const describeSlackError = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
2894
+ /** Codes Slack answers when the token itself is refused. */
2895
+ const SLACK_AUTH_ERRORS = new Set(['not_authed', 'invalid_auth', 'account_inactive', 'token_revoked', 'token_expired']);
2896
+ /**
2897
+ * Codes with which Slack refuses a whole `conversations.invite` without
2898
+ * inviting anyone, so `inviteToConversation` can reject and the members
2899
+ * are known unchanged. Everything else, per-person codes and failures that
2900
+ * may have partly worked included, is settled per entry instead.
2901
+ */
2902
+ const SLACK_INVITE_REFUSALS = new Set([
2903
+ 'not_in_channel',
2904
+ 'missing_scope',
2905
+ 'is_archived',
2906
+ 'channel_not_found',
2907
+ 'method_not_supported_for_channel_type',
2908
+ 'no_user',
2909
+ 'ratelimited',
2910
+ ...SLACK_AUTH_ERRORS,
2911
+ ]);
2912
+ /**
2913
+ * What `channel_not_found` means to an app: Slack says the same for a
2914
+ * wrong id and for a private channel it can't see, and so does
2915
+ * `conversations.info`, so the sentence names both.
2916
+ */
2917
+ const channelNotFoundSentence = (agentId, conversation) => `Slack found no channel ${conversation} that ${agentId}'s app can see. Check the id (copy it from the channel's link; a name won't work). `
2918
+ + 'If the id is right, the channel is private, and a member has to invite the app.';
2354
2919
  /**
2355
2920
  * Orders two Slack message timestamps ("seconds.micros"). Compared as
2356
2921
  * integers, not floats: a current ts has more digits than a double holds.
@@ -2377,13 +2942,19 @@ export const createSlackChannelAdapter = (options) => {
2377
2942
  const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
2378
2943
  const createSocket = options.createSocketClient ?? defaultSocketClient;
2379
2944
  const createWeb = options.createWebClient ?? defaultWebClient;
2380
- const fetchFile = options.fetchFile ?? defaultFetchFile;
2945
+ const fetchFile = onlySlackFileHost(options.fetchFile ?? defaultFetchFile);
2381
2946
  const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
2947
+ const stopWaitMs = options.stopWaitMs ?? DEFAULT_STOP_WAIT_MS;
2948
+ const approvalPromptWaitMs = options.approvalPromptWaitMs ?? DEFAULT_APPROVAL_PROMPT_WAIT_MS;
2949
+ const progressAfterMs = options.progressAfterMs ?? DEFAULT_PROGRESS_AFTER_MS;
2382
2950
  const now = options.now ?? Date.now;
2383
2951
  const connections = [];
2952
+ // The agents this adapter carries: `options.agents` until a reload reads
2953
+ // another list (`options.loadAgents`).
2954
+ let carried = options.agents;
2384
2955
  // Every agent this adapter was asked to carry, connected or not — the
2385
2956
  // difference between "mine and broken" and "not mine".
2386
- const configuredAgents = new Set(options.agents.map((agent) => agent.agentId));
2957
+ const configuredAgents = new Set(carried.map((agent) => agent.agentId));
2387
2958
  // Renderers queue per session: two messages in one thread share a
2388
2959
  // session id, and the gateway serializes their turns — so events route
2389
2960
  // to the FIRST registered renderer (the running turn), never a later
@@ -2577,8 +3148,9 @@ export const createSlackChannelAdapter = (options) => {
2577
3148
  * is the correct outcome there, and it is also the visible one — the same
2578
3149
  * thing a mention has always done when an app is down.
2579
3150
  *
2580
- * Insertion order is `options.agents` order, which is what makes
2581
- * `agentNamedIn` deterministic across every connection.
3151
+ * Insertion order is the carried agents' order (`options.agents`, or the
3152
+ * list a reload read), which is what makes `agentNamedIn` deterministic
3153
+ * across every connection.
2582
3154
  */
2583
3155
  const botIdentities = new Map();
2584
3156
  // Rendered approval requests, keyed by the gateway's request id — the
@@ -2605,8 +3177,24 @@ export const createSlackChannelAdapter = (options) => {
2605
3177
  // outstanding.
2606
3178
  const rendering = new Set();
2607
3179
  const resolvedWhileRendering = new Map();
3180
+ // Requests whose prompt is waiting for its turn's line to land first
3181
+ // (card 950), and those of them settled in that wait: a request answered
3182
+ // or cancelled before anyone was asked has no prompt to post. Bounded the
3183
+ // same way: an id is here only for one bounded wait.
3184
+ const askingAfterLine = new Set();
3185
+ const settledBeforeAsked = new Set();
2608
3186
  let unsubscribe;
2609
3187
  let gatewayRef;
3188
+ // Set as `stop()` begins, so a reload still connecting an app leaves
3189
+ // nothing up behind the stop.
3190
+ let stopRequested = false;
3191
+ // Sockets between `start()` and `connections`, by the agent they serve
3192
+ // (card 935). A stop can't see them to close them: `connect` closes each
3193
+ // once its start returns, and the stop waits for that, bounded.
3194
+ const socketsStarting = new Map();
3195
+ // Agents whose socket `connect` could not close for a stop: up still,
3196
+ // which the stop has to report.
3197
+ const leftUpAtStop = new Set();
2610
3198
  // Everything the adapter owes Slack goes through here, so stop() drains
2611
3199
  // it: an approval question or its retraction must reach the workspace
2612
3200
  // before the sockets close, or a message keeps offering buttons that
@@ -3361,7 +3949,7 @@ export const createSlackChannelAdapter = (options) => {
3361
3949
  };
3362
3950
  const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
3363
3951
  /** An agent's configuration, whether or not its socket came up. */
3364
- const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
3952
+ const agentConfigFor = (agentId) => carried.find((candidate) => candidate.agentId === agentId);
3365
3953
  /**
3366
3954
  * Which agent each home channel belongs to, keyed `team:channel` because
3367
3955
  * a channel id is unique only within its workspace. Settled at start,
@@ -3377,7 +3965,7 @@ export const createSlackChannelAdapter = (options) => {
3377
3965
  const settleHomeOwners = () => {
3378
3966
  homeOwners.clear();
3379
3967
  const claims = new Map();
3380
- for (const agent of options.agents) {
3968
+ for (const agent of carried) {
3381
3969
  const teamId = botIdentities.get(agent.agentId)?.teamId;
3382
3970
  if (teamId === undefined) {
3383
3971
  continue;
@@ -3908,6 +4496,7 @@ export const createSlackChannelAdapter = (options) => {
3908
4496
  names.set(user, await name);
3909
4497
  }
3910
4498
  const messages = [];
4499
+ const sources = [];
3911
4500
  for (const message of raw) {
3912
4501
  if (!message.ts) {
3913
4502
  continue;
@@ -3920,7 +4509,7 @@ export const createSlackChannelAdapter = (options) => {
3920
4509
  || (message.user ? names.get(message.user) : undefined);
3921
4510
  const at = Number.parseFloat(message.ts);
3922
4511
  const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
3923
- messages.push({
4512
+ const read = {
3924
4513
  id: message.ts,
3925
4514
  author,
3926
4515
  text: plainSlackText(connection, message.text ?? '', names),
@@ -3929,7 +4518,15 @@ export const createSlackChannelAdapter = (options) => {
3929
4518
  ...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
3930
4519
  ...(message.reply_count ? { replies: message.reply_count } : {}),
3931
4520
  ...(files.length > 0 ? { files } : {}),
3932
- });
4521
+ };
4522
+ messages.push(read);
4523
+ if (message.files !== undefined && message.files.length > 0) {
4524
+ sources.push({ message: read, files: message.files });
4525
+ }
4526
+ }
4527
+ const imageCap = historyImageCap(request.images);
4528
+ if (imageCap !== undefined) {
4529
+ await readHistoryImages(sources, imageCap, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
3933
4530
  }
3934
4531
  return { messages, more, ...(listing ? await listing : {}) };
3935
4532
  };
@@ -3972,15 +4569,60 @@ export const createSlackChannelAdapter = (options) => {
3972
4569
  if (!create || !setTopic || !setPurpose || !inviteUsers) {
3973
4570
  throw new Error('slack: this Slack client cannot create channels.');
3974
4571
  }
3975
- // Who each entry is, before anything exists: agents on this host become
3976
- // the user their app posts as, in the same workspace only.
3977
- const invited = [];
4572
+ // Who each entry is, before anything exists. The creating app is a
4573
+ // member already, so naming it counts as invited.
4574
+ const { own, notInvited, toInvite } = resolveInvitees(connection, request.agentId, invite);
4575
+ const invited = [...own];
4576
+ const kind = request.private === true ? 'private' : 'public';
4577
+ let created;
4578
+ try {
4579
+ created = await create({ name, is_private: request.private === true });
4580
+ }
4581
+ catch (error) {
4582
+ const code = slackErrorCode(error);
4583
+ if (code === 'missing_scope') {
4584
+ throw new Error(`slack: ${request.agentId}'s Slack app can't create ${kind} channels because it lacks the ${request.private === true ? 'groups:write' : 'channels:manage'} scope. Add it to the app's manifest and reinstall the app.`);
4585
+ }
4586
+ if (code === 'name_taken') {
4587
+ throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
4588
+ }
4589
+ throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
4590
+ }
4591
+ const conversation = created.channel?.id;
4592
+ if (!conversation) {
4593
+ throw new Error(`slack: Slack answered the create of #${name} without a channel id, so whether it exists is unknown. Read the channel list before trying again.`);
4594
+ }
4595
+ const notSet = [];
4596
+ if (request.topic) {
4597
+ await setTopic({ channel: conversation, topic: request.topic })
4598
+ .catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describeSlackError(error)}).` }));
4599
+ }
4600
+ if (request.purpose) {
4601
+ await setPurpose({ channel: conversation, purpose: request.purpose })
4602
+ .catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describeSlackError(error)}).` }));
4603
+ }
4604
+ // The channel exists now, so every code is settled per entry and none
4605
+ // turns into a rejection the agent would retry into a second channel.
4606
+ const settled = await inviteAtSlack(connection, inviteUsers, conversation, toInvite);
4607
+ invited.push(...settled.invited);
4608
+ notInvited.push(...settled.notInvited);
4609
+ sortByRequest(invite, invited, notInvited);
4610
+ return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
4611
+ };
4612
+ /**
4613
+ * Who each invite entry is, before Slack is asked: agents on this host
4614
+ * become the user their app posts as, in the same workspace only. `own`
4615
+ * holds the entries that are the asking app itself (the agent, or one
4616
+ * sharing its bot user), which no invitation can add.
4617
+ */
4618
+ const resolveInvitees = (connection, agentId, invite) => {
4619
+ const own = [];
3978
4620
  const notInvited = [];
3979
4621
  const toInvite = new Map();
3980
4622
  for (const who of invite) {
3981
4623
  const identity = botIdentities.get(who);
3982
- if (who === request.agentId || identity?.botUserId === connection.botUserId) {
3983
- invited.push(who);
4624
+ if (who === agentId || identity?.botUserId === connection.botUserId) {
4625
+ own.push(who);
3984
4626
  }
3985
4627
  else if (configuredAgents.has(who) && !identity?.botUserId) {
3986
4628
  notInvited.push({ who, reason: `Agent ${who}'s Slack app is not connected, so its Slack user is unknown.` });
@@ -4000,90 +4642,316 @@ export const createSlackChannelAdapter = (options) => {
4000
4642
  notInvited.push({ who, reason: 'Not a Slack user id (U… or W…) or an agent with a Slack app on this host.' });
4001
4643
  }
4002
4644
  }
4003
- const kind = request.private === true ? 'private' : 'public';
4004
- let created;
4645
+ return { own, notInvited, toInvite };
4646
+ };
4647
+ /**
4648
+ * Sends one `conversations.invite`, force on so a bad id cannot stop the
4649
+ * good ones, and settles each entry of `toInvite` (entry -> Slack user).
4650
+ * Slack's per-id failures name who was refused, and `already_in_channel`
4651
+ * counts as invited. A failed call that does not name everyone leaves
4652
+ * the rest to the channel's members, and who cannot be checked is never
4653
+ * claimed. A call Slack refused with a code `refusesWhole` accepts stops
4654
+ * there, before any members check, with nobody settled and the code in
4655
+ * `refusedWith` for the caller to reject on. Without `refusesWhole`
4656
+ * every code is settled per entry.
4657
+ */
4658
+ const inviteAtSlack = async (connection, inviteUsers, conversation, toInvite, refusesWhole = () => false) => {
4659
+ const invited = [];
4660
+ const notInvited = [];
4661
+ if (toInvite.size === 0) {
4662
+ return { invited, notInvited };
4663
+ }
4664
+ // Slack's per-id failures, by user id: on the answer, or on the error
4665
+ // the SDK throws for an ok: false answer.
4666
+ const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
4667
+ let refused;
4668
+ let failure;
4005
4669
  try {
4006
- created = await create({ name, is_private: request.private === true });
4670
+ refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
4007
4671
  }
4008
4672
  catch (error) {
4009
4673
  const code = slackErrorCode(error);
4010
- if (code === 'missing_scope') {
4011
- throw new Error(`slack: ${request.agentId}'s Slack app can't create ${kind} channels because it lacks the ${request.private === true ? 'groups:write' : 'channels:manage'} scope. Add it to the app's manifest and reinstall the app.`);
4012
- }
4013
- if (code === 'name_taken') {
4014
- throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
4674
+ if (code !== undefined && refusesWhole(code)) {
4675
+ return { invited, notInvited, refusedWith: code };
4015
4676
  }
4016
- throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
4677
+ refused = refusedOf(error?.data?.errors);
4678
+ failure = describeSlackError(error);
4017
4679
  }
4018
- const conversation = created.channel?.id;
4019
- if (!conversation) {
4020
- throw new Error(`slack: Slack answered the create of #${name} without a channel id, so whether it exists is unknown. Read the channel list before trying again.`);
4021
- }
4022
- const notSet = [];
4023
- const describe = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
4024
- if (request.topic) {
4025
- await setTopic({ channel: conversation, topic: request.topic })
4026
- .catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describe(error)}).` }));
4027
- }
4028
- if (request.purpose) {
4029
- await setPurpose({ channel: conversation, purpose: request.purpose })
4030
- .catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describe(error)}).` }));
4031
- }
4032
- if (toInvite.size > 0) {
4033
- // Slack's per-id failures, by user id: on the answer, or on the error
4034
- // the SDK throws for an ok: false answer.
4035
- const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
4036
- let refused;
4037
- let failure;
4680
+ // A failed call that does not name everyone leaves the rest unknown,
4681
+ // so the channel's members decide; who cannot be checked is not claimed.
4682
+ let members;
4683
+ let membersFailure;
4684
+ if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
4038
4685
  try {
4039
- refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
4686
+ members = new Set();
4687
+ let cursor;
4688
+ do {
4689
+ const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
4690
+ for (const member of page.members ?? []) {
4691
+ members.add(member);
4692
+ }
4693
+ cursor = page.response_metadata?.next_cursor || undefined;
4694
+ } while (cursor);
4040
4695
  }
4041
4696
  catch (error) {
4042
- refused = refusedOf(error?.data?.errors);
4043
- failure = describe(error);
4044
- }
4045
- // A failed call that does not name everyone leaves the rest unknown,
4046
- // so the channel's members decide; who cannot be checked is not claimed.
4047
- let members;
4048
- let membersFailure;
4049
- if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
4050
- try {
4051
- members = new Set();
4052
- let cursor;
4053
- do {
4054
- const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
4055
- for (const member of page.members ?? []) {
4056
- members.add(member);
4057
- }
4058
- cursor = page.response_metadata?.next_cursor || undefined;
4059
- } while (cursor);
4060
- }
4061
- catch (error) {
4062
- members = undefined;
4063
- membersFailure = describe(error);
4064
- }
4697
+ members = undefined;
4698
+ membersFailure = describeSlackError(error);
4065
4699
  }
4066
- for (const [who, user] of toInvite) {
4067
- const code = refused.get(user);
4068
- if (code === 'already_in_channel' || (code === undefined && (failure === undefined || members?.has(user)))) {
4069
- invited.push(who);
4070
- }
4071
- else if (code !== undefined || members !== undefined) {
4072
- notInvited.push({ who, reason: `Slack did not invite them (${code ?? failure}).` });
4073
- }
4074
- else {
4075
- notInvited.push({
4076
- who,
4077
- reason: `The invitation failed (${failure}) and the channel's members could not be checked (${membersFailure}), so they may or may not have been added.`,
4078
- });
4079
- }
4700
+ }
4701
+ for (const [who, user] of toInvite) {
4702
+ const code = refused.get(user);
4703
+ if (code === 'already_in_channel' || (code === undefined && (failure === undefined || members?.has(user)))) {
4704
+ invited.push(who);
4705
+ }
4706
+ else if (code !== undefined || members !== undefined) {
4707
+ notInvited.push({ who, reason: `Slack did not invite them (${code ?? failure}).` });
4708
+ }
4709
+ else {
4710
+ notInvited.push({
4711
+ who,
4712
+ reason: `The invitation failed (${failure}) and the channel's members could not be checked (${membersFailure}), so they may or may not have been added.`,
4713
+ });
4080
4714
  }
4081
4715
  }
4716
+ return { invited, notInvited };
4717
+ };
4718
+ /** Puts both lists back in the order the caller named the entries. */
4719
+ const sortByRequest = (invite, invited, notInvited) => {
4082
4720
  const order = new Map(invite.map((who, index) => [who, index]));
4083
4721
  const byRequest = (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0);
4084
4722
  invited.sort(byRequest);
4085
4723
  notInvited.sort((left, right) => byRequest(left.who, right.who));
4086
- return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
4724
+ };
4725
+ /**
4726
+ * Joins an existing public channel with the asking agent's own app —
4727
+ * `channel.join`. Anything it did not join rejects with a sentence; an
4728
+ * answer that leaves it unknown says so, and that a retry is safe,
4729
+ * since Slack treats a second join as a no-op.
4730
+ */
4731
+ const joinConversation = async (request) => {
4732
+ const conversation = request.conversation.trim();
4733
+ if (!conversation) {
4734
+ throw new Error('slack: joining needs a channel id (C…).');
4735
+ }
4736
+ const connection = connectionFor(request.agentId);
4737
+ if (!connection) {
4738
+ throw new Error(configuredAgents.has(request.agentId)
4739
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot join ${conversation}.`
4740
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot join a Slack channel.`);
4741
+ }
4742
+ const { join } = connection.web.conversations;
4743
+ if (!join) {
4744
+ throw new Error('slack: this Slack client cannot join channels.');
4745
+ }
4746
+ const agent = request.agentId;
4747
+ let joined;
4748
+ try {
4749
+ joined = await join({ channel: conversation });
4750
+ }
4751
+ catch (error) {
4752
+ const code = slackErrorCode(error);
4753
+ if (code === undefined || code === 'internal_error' || code === 'fatal_error') {
4754
+ throw new Error(`slack: Slack's answer left it unknown whether ${agent}'s app joined ${conversation} (${describeSlackError(error)}). Trying again is safe: joining a channel twice changes nothing.`);
4755
+ }
4756
+ if (code === 'missing_scope') {
4757
+ throw new Error(`slack: ${agent}'s Slack app can't join channels on its own because it lacks the channels:join scope. Add it to the app's manifest and reinstall the app.`);
4758
+ }
4759
+ if (code === 'method_not_supported_for_channel_type') {
4760
+ throw new Error(`slack: ${conversation} isn't a public channel. Slack never lets an app join a private channel on its own: a member has to invite the app.`);
4761
+ }
4762
+ if (code === 'channel_not_found') {
4763
+ throw new Error(`slack: ${channelNotFoundSentence(agent, conversation)}`);
4764
+ }
4765
+ if (code === 'is_archived') {
4766
+ throw new Error(`slack: ${conversation} is archived, so it can't be joined.`);
4767
+ }
4768
+ if (code === 'too_many_members') {
4769
+ throw new Error(`slack: ${conversation} is at Slack's member limit, so ${agent}'s app can't join it.`);
4770
+ }
4771
+ if (code === 'ratelimited') {
4772
+ throw new Error(`slack: Slack is rate-limiting ${agent}'s app; try joining ${conversation} again later.`);
4773
+ }
4774
+ if (SLACK_AUTH_ERRORS.has(code)) {
4775
+ throw new Error(`slack: Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`);
4776
+ }
4777
+ throw new Error(`slack: Slack did not let ${agent}'s app join ${conversation} (${code}).`);
4778
+ }
4779
+ const warnings = [joined.warning, ...(joined.response_metadata?.warnings ?? [])];
4780
+ return { conversation: joined.channel?.id ?? conversation, alreadyMember: warnings.includes('already_in_channel') };
4781
+ };
4782
+ /**
4783
+ * Invites people and agents to an existing channel with the asking
4784
+ * agent's own app — `channel.invite`. It rejects only when nobody was
4785
+ * invited: a request refused before Slack is asked, or a whole call Slack
4786
+ * refused without acting. Everything else is settled per entry by the
4787
+ * same code `createConversation` uses.
4788
+ */
4789
+ const inviteToConversation = async (request) => {
4790
+ const conversation = request.conversation.trim();
4791
+ if (!conversation) {
4792
+ throw new Error('slack: an invitation needs a channel id (C…).');
4793
+ }
4794
+ const invite = request.invite;
4795
+ if (invite.length === 0) {
4796
+ throw new Error('slack: an invitation needs at least one person or agent to invite.');
4797
+ }
4798
+ if (invite.length > CHANNEL_INVITE_LIMIT) {
4799
+ throw new Error(`slack: that invites ${invite.length.toLocaleString('en-US')} people; Slack invites at most ${CHANNEL_INVITE_LIMIT.toLocaleString('en-US')} at once. Nobody was invited.`);
4800
+ }
4801
+ const connection = connectionFor(request.agentId);
4802
+ if (!connection) {
4803
+ throw new Error(configuredAgents.has(request.agentId)
4804
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot invite anyone to ${conversation}.`
4805
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot invite anyone to a Slack channel.`);
4806
+ }
4807
+ const inviteUsers = connection.web.conversations.invite;
4808
+ if (!inviteUsers) {
4809
+ throw new Error('slack: this Slack client cannot invite people to channels.');
4810
+ }
4811
+ const agent = request.agentId;
4812
+ const { own, notInvited, toInvite } = resolveInvitees(connection, agent, invite);
4813
+ for (const who of own) {
4814
+ notInvited.push({ who, reason: 'That is your own app, which can\'t invite itself. To join a public channel, use channel.join.' });
4815
+ }
4816
+ const settled = await inviteAtSlack(connection, inviteUsers, conversation, toInvite, (code) => SLACK_INVITE_REFUSALS.has(code));
4817
+ if (settled.refusedWith !== undefined) {
4818
+ const code = settled.refusedWith;
4819
+ const why = code === 'not_in_channel'
4820
+ ? `${agent}'s app isn't in ${conversation}, so it can't invite anyone there. For a public channel, join it first with channel.join; for a private one, a member has to invite the app.`
4821
+ : code === 'channel_not_found'
4822
+ ? channelNotFoundSentence(agent, conversation)
4823
+ : code === 'missing_scope'
4824
+ ? `${agent}'s Slack app lacks the scope to invite people: channels:write.invites for a public channel or groups:write.invites for a private one (channels:manage or groups:write also work). Add it to the app's manifest and reinstall the app.`
4825
+ : code === 'is_archived'
4826
+ ? `${conversation} is archived, so nobody can be invited to it.`
4827
+ : code === 'method_not_supported_for_channel_type'
4828
+ ? `${conversation} is a direct message or another conversation Slack doesn't allow invitations to.`
4829
+ : code === 'ratelimited'
4830
+ ? `Slack is rate-limiting ${agent}'s app; try the invitation again later.`
4831
+ : SLACK_AUTH_ERRORS.has(code)
4832
+ ? `Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`
4833
+ : `Slack did not invite anyone to ${conversation} (${code}).`;
4834
+ throw new Error(`slack: ${why} Nobody was invited.`);
4835
+ }
4836
+ const invited = settled.invited;
4837
+ notInvited.push(...settled.notInvited);
4838
+ sortByRequest(invite, invited, notInvited);
4839
+ return { invited, notInvited };
4840
+ };
4841
+ /**
4842
+ * Other agents on this host whose Slack app is this agent's: the same
4843
+ * bot token configured (connected or not), or the same bot user in the
4844
+ * same workspace. Slack's `creator` names the app's bot user, so it
4845
+ * can't tell those agents' channels apart. This sees only this process;
4846
+ * one app per agent is the supported setup (see the README).
4847
+ */
4848
+ const agentsSharingAppWith = (connection) => {
4849
+ const agentId = connection.config.agentId;
4850
+ const sharing = new Set();
4851
+ for (const other of carried) {
4852
+ if (other.agentId !== agentId && other.botToken === connection.config.botToken) {
4853
+ sharing.add(other.agentId);
4854
+ }
4855
+ }
4856
+ for (const [other, identity] of botIdentities) {
4857
+ if (other !== agentId && identity.botUserId !== '' && identity.botUserId === connection.botUserId && identity.teamId === connection.teamId) {
4858
+ sharing.add(other);
4859
+ }
4860
+ }
4861
+ return [...sharing];
4862
+ };
4863
+ /**
4864
+ * Archives a channel the asking agent's own app created — what
4865
+ * `channel.archive` runs on. Everything it refuses is refused before
4866
+ * Slack is asked to archive: an id Slack can't show this app, a direct
4867
+ * message, a channel another user or app created, an app other agents
4868
+ * here share, a channel shared outside the workspace. Slack keeps who
4869
+ * created a channel and whether it is archived, so nothing is stored
4870
+ * here and the rule holds across restarts and hosts. Refusals name the
4871
+ * channel by id only: its name is whatever its members typed.
4872
+ */
4873
+ const archiveConversation = async (request) => {
4874
+ const conversation = request.conversation.trim();
4875
+ if (!conversation) {
4876
+ throw new Error('slack: archiving needs a channel id (C…).');
4877
+ }
4878
+ const connection = connectionFor(request.agentId);
4879
+ if (!connection) {
4880
+ throw new Error(configuredAgents.has(request.agentId)
4881
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot archive ${conversation}.`
4882
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot archive a Slack channel.`);
4883
+ }
4884
+ const { archive } = connection.web.conversations;
4885
+ if (!archive) {
4886
+ throw new Error('slack: this Slack client cannot archive channels.');
4887
+ }
4888
+ const agent = request.agentId;
4889
+ const nothing = 'Nothing was archived.';
4890
+ const sharing = agentsSharingAppWith(connection);
4891
+ if (sharing.length > 0) {
4892
+ throw new Error(`slack: ${agent}'s Slack app is also ${sharing.map((other) => `${other}'s`).join(' and ')} here, so Stratus can't tell which agent created ${conversation}. `
4893
+ + `Give each agent its own Slack app. A person can archive it in Slack. ${nothing}`);
4894
+ }
4895
+ let info;
4896
+ try {
4897
+ info = (await connection.web.conversations.info({ channel: conversation })).channel;
4898
+ }
4899
+ catch (error) {
4900
+ const code = slackErrorCode(error);
4901
+ if (code === 'channel_not_found') {
4902
+ throw new Error(`slack: ${channelNotFoundSentence(agent, conversation)} Either way, ${agent} can only archive channels its own app created. ${nothing}`);
4903
+ }
4904
+ if (code === 'ratelimited') {
4905
+ throw new Error(`slack: Slack is rate-limiting ${agent}'s app; try archiving ${conversation} again later. ${nothing}`);
4906
+ }
4907
+ if (code !== undefined && SLACK_AUTH_ERRORS.has(code)) {
4908
+ throw new Error(`slack: Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token. ${nothing}`);
4909
+ }
4910
+ throw new Error(`slack: Slack could not tell ${agent}'s app who created ${conversation} (${describeSlackError(error)}). ${nothing}`);
4911
+ }
4912
+ if (info?.is_im === true || info?.is_mpim === true) {
4913
+ throw new Error(`slack: ${conversation} is a direct message, and only channels can be archived. ${nothing}`);
4914
+ }
4915
+ const creator = info?.creator ?? '';
4916
+ if (creator === '' || connection.botUserId === '' || creator !== connection.botUserId) {
4917
+ throw new Error(`slack: ${agent}'s app didn't create ${conversation}: another user or app did, so ${agent} can't archive it. A person can archive it in Slack. ${nothing}`);
4918
+ }
4919
+ if (info?.is_shared === true || info?.is_ext_shared === true || info?.is_org_shared === true || info?.is_pending_ext_shared === true) {
4920
+ throw new Error(`slack: ${conversation} is shared with another workspace or organization, so archiving it would reach people outside this one. A person can archive it in Slack. ${nothing}`);
4921
+ }
4922
+ if (info?.is_archived === true) {
4923
+ return { conversation, alreadyArchived: true };
4924
+ }
4925
+ try {
4926
+ await archive({ channel: conversation });
4927
+ }
4928
+ catch (error) {
4929
+ const code = slackErrorCode(error);
4930
+ if (code === 'already_archived') {
4931
+ return { conversation, alreadyArchived: true };
4932
+ }
4933
+ warn(`slack: ${agent} could not archive ${conversation} (${describeSlackError(error)})`);
4934
+ if (code === undefined || code === 'internal_error' || code === 'fatal_error') {
4935
+ throw new Error(`slack: Slack's answer left it unknown whether ${agent}'s app archived ${conversation} (${describeSlackError(error)}). It may be archived. Trying again is safe: archiving a channel twice changes nothing.`);
4936
+ }
4937
+ const isPrivate = info?.is_private === true || info?.is_group === true;
4938
+ const why = code === 'missing_scope'
4939
+ ? `${agent}'s Slack app can't archive ${isPrivate ? 'private' : 'public'} channels because it lacks the ${isPrivate ? 'groups:write' : 'channels:manage'} scope. Add it to the app's manifest and reinstall the app.`
4940
+ : code === 'not_in_channel'
4941
+ ? `${agent}'s app is no longer a member of ${conversation}, so it can't archive it. A member has to add the app back first.`
4942
+ : code === 'restricted_action'
4943
+ ? `This workspace limits who can archive channels, so ${agent}'s app can't archive ${conversation}. A person has to do it in Slack.`
4944
+ : code === 'ratelimited'
4945
+ ? `Slack is rate-limiting ${agent}'s app; try archiving ${conversation} again later.`
4946
+ : code === 'channel_not_found'
4947
+ ? channelNotFoundSentence(agent, conversation)
4948
+ : SLACK_AUTH_ERRORS.has(code)
4949
+ ? `Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`
4950
+ : `Slack did not let ${agent}'s app archive ${conversation} (${code}).`;
4951
+ throw new Error(`slack: ${why} ${nothing}`);
4952
+ }
4953
+ log(`slack: ${agent} archived ${conversation}`);
4954
+ return { conversation, alreadyArchived: false };
4087
4955
  };
4088
4956
  /**
4089
4957
  * Renders one parked call as buttons and remembers where it went.
@@ -4530,7 +5398,7 @@ export const createSlackChannelAdapter = (options) => {
4530
5398
  // last. `NO_REPLY_TEXT` is what a rendered turn with nothing to say
4531
5399
  // puts in its own message too. With nothing queued behind, there is no
4532
5400
  // order to keep and nothing to say, so the files go on their own.
4533
- const chunks = routing.reply ? messageChunks(routing.reply) : [];
5401
+ const chunks = routing.reply ? messageChunks(routing.reply, connection.config.replies) : [];
4534
5402
  const posting = chunks.length > 0 ? chunks : (behind && files.length > 0 ? [NO_REPLY_TEXT] : []);
4535
5403
  let landed = [];
4536
5404
  const uploads = async () => {
@@ -6498,6 +7366,8 @@ export const createSlackChannelAdapter = (options) => {
6498
7366
  lazy: judged,
6499
7367
  now,
6500
7368
  streaming,
7369
+ progressAfterMs,
7370
+ texts: connection.config.replies === 'texts',
6501
7371
  statusThread: thread ?? event.ts,
6502
7372
  statusWarned: connection.statusWarned,
6503
7373
  ...(ahead ? { after: ahead } : {}),
@@ -6816,12 +7686,244 @@ export const createSlackChannelAdapter = (options) => {
6816
7686
  renderers.get(sessionId)?.[0]?.refreshLoading();
6817
7687
  }
6818
7688
  };
7689
+ /**
7690
+ * One agent's app, authenticated, with who its bot is recorded, and its
7691
+ * socket built but not started: undefined, with a warning, when it would
7692
+ * not authenticate. Recorded before the socket is even built: an app
7693
+ * that never comes up must still be recognizable when somebody names it.
7694
+ */
7695
+ const authenticate = async (config) => {
7696
+ try {
7697
+ const created = createWeb(config.botToken);
7698
+ const web = config.linkPreviews === false ? withoutLinkPreviews(created) : created;
7699
+ const auth = await web.auth.test();
7700
+ const botUserId = auth.user_id ?? '';
7701
+ const teamId = auth.team_id ?? '';
7702
+ checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
7703
+ botIdentities.set(config.agentId, { botUserId, teamId });
7704
+ return {
7705
+ config,
7706
+ web,
7707
+ socket: createSocket(config.appToken),
7708
+ botUserId,
7709
+ teamId,
7710
+ statusWarned: { value: false },
7711
+ };
7712
+ }
7713
+ catch (error) {
7714
+ // One broken app must not take the rest of the fleet down.
7715
+ warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
7716
+ return undefined;
7717
+ }
7718
+ };
7719
+ /**
7720
+ * Starts an authenticated app's socket and serves it. Resolves with the
7721
+ * listener a replay of kept messages delivers through, or undefined when
7722
+ * the socket would not start: authenticated but not serving, it answers
7723
+ * nothing, and stays recognizable so nobody answers in its place.
7724
+ */
7725
+ const connect = async (connection) => {
7726
+ const { config, socket } = connection;
7727
+ try {
7728
+ // Returns the work it started: the SDK ignores it, and a test's fake
7729
+ // socket waits on it rather than guessing how long it takes.
7730
+ const handle = (inbound) => {
7731
+ const handled = inbound.catch((error) => {
7732
+ warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
7733
+ });
7734
+ inflight.add(handled);
7735
+ void handled.finally(() => inflight.delete(handled));
7736
+ return handled;
7737
+ };
7738
+ // The replay's own deliveries never wait on the hold they release.
7739
+ const onReplay = (args) => handle(handleInbound(connection, args));
7740
+ const onEvent = (args) => {
7741
+ const channel = args.event?.channel;
7742
+ const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
7743
+ if (hold === undefined) {
7744
+ return onReplay(args);
7745
+ }
7746
+ // Acked now, as any delivery is, so Slack does not redeliver a
7747
+ // message that is only waiting its turn; admitted after the hold.
7748
+ void Promise.resolve(args.ack()).catch((error) => {
7749
+ warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
7750
+ });
7751
+ return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
7752
+ };
7753
+ socket.on('app_mention', onEvent);
7754
+ socket.on('message', onEvent);
7755
+ socket.on('interactive', (args) => {
7756
+ const handled = handleInteractive(connection, args).catch((error) => {
7757
+ warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
7758
+ });
7759
+ track(handled);
7760
+ return handled;
7761
+ });
7762
+ const up = (async () => {
7763
+ await socket.start();
7764
+ // A stop that began while this socket was starting has already
7765
+ // disconnected what it could see; one left up here would answer
7766
+ // beside the next daemon. Checked in the same step that adds it
7767
+ // to `connections`, so a stop either sees it there or finds it
7768
+ // here.
7769
+ if (stopRequested) {
7770
+ if (!await disconnect(connection, 'the adapter is stopping')) {
7771
+ leftUpAtStop.add(config.agentId);
7772
+ }
7773
+ return false;
7774
+ }
7775
+ connections.push(connection);
7776
+ return true;
7777
+ })();
7778
+ socketsStarting.set(up, config.agentId);
7779
+ try {
7780
+ if (!await up) {
7781
+ return undefined;
7782
+ }
7783
+ }
7784
+ finally {
7785
+ socketsStarting.delete(up);
7786
+ }
7787
+ log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
7788
+ return onReplay;
7789
+ }
7790
+ catch (error) {
7791
+ warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
7792
+ return undefined;
7793
+ }
7794
+ };
7795
+ /** Closes an app's socket; false, with a warning, when the close was refused. */
7796
+ const disconnect = async (connection, reason) => {
7797
+ try {
7798
+ await connection.socket.disconnect();
7799
+ return true;
7800
+ }
7801
+ catch (error) {
7802
+ warn(`slack: could not disconnect ${connection.config.agentId} (${reason}): ${error instanceof Error ? error.message : String(error)}`);
7803
+ return false;
7804
+ }
7805
+ };
7806
+ const sameApp = (a, b) => a.appToken === b.appToken && a.botToken === b.botToken;
7807
+ /**
7808
+ * Brings the connected apps in line with the agents to carry now (card
7809
+ * 894): connects an agent whose app is new, or that the roster has only
7810
+ * now, or that did not come up before; reconnects one whose tokens
7811
+ * changed; disconnects one whose tokens were removed or whose soul is
7812
+ * gone. Every other socket is left alone, so a conversation on it never
7813
+ * notices. Settings come with the list `options.loadAgents` reads, which
7814
+ * for the daemon is the config it started with.
7815
+ */
7816
+ const reconcile = async () => {
7817
+ const gateway = gatewayRef;
7818
+ if (gateway === undefined || stopRequested || options.loadAgents === undefined) {
7819
+ return;
7820
+ }
7821
+ const next = await options.loadAgents();
7822
+ if (gatewayRef !== gateway || stopRequested) {
7823
+ return;
7824
+ }
7825
+ const known = new Set(gateway.agents().map((agent) => agent.id));
7826
+ const wanted = new Map(next.filter((config) => known.has(config.agentId)).map((config) => [config.agentId, config]));
7827
+ const connected = new Map(connections.map((connection) => [connection.config.agentId, connection]));
7828
+ const leaving = [...connected.values()].filter((connection) => {
7829
+ const config = wanted.get(connection.config.agentId);
7830
+ return config === undefined || !sameApp(config, connection.config);
7831
+ });
7832
+ // Closed first, and only a closed one is let go of. A socket whose close
7833
+ // was refused may still deliver, so it stays this agent's connection,
7834
+ // on the tokens it has, and nothing is started beside it: a second
7835
+ // socket for the agent would answer every message twice, and a removed
7836
+ // app would keep answering while reported gone. A restart clears it.
7837
+ for (const connection of leaving) {
7838
+ const agentId = connection.config.agentId;
7839
+ const reason = !known.has(agentId)
7840
+ ? 'its soul is gone'
7841
+ : wanted.has(agentId) ? 'its tokens changed' : 'its tokens were removed';
7842
+ if (!await disconnect(connection, reason)) {
7843
+ warn(`slack: ${agentId} stays connected with the app it had until a restart, since its socket would not close`);
7844
+ continue;
7845
+ }
7846
+ const index = connections.indexOf(connection);
7847
+ if (index >= 0) {
7848
+ connections.splice(index, 1);
7849
+ }
7850
+ log(`slack: ${agentId} disconnected (${reason})`);
7851
+ }
7852
+ // What is still connected now, stuck sockets included, serves on.
7853
+ const serving = (agentId) => connections.find((connection) => connection.config.agentId === agentId);
7854
+ const arriving = [...wanted.values()].filter((config) => serving(config.agentId) === undefined);
7855
+ // A connected app keeps the configuration it is serving with.
7856
+ carried = [
7857
+ ...next.map((config) => serving(config.agentId)?.config ?? config),
7858
+ ...connections.filter((connection) => !next.some((config) => config.agentId === connection.config.agentId)).map((connection) => connection.config),
7859
+ ];
7860
+ configuredAgents.clear();
7861
+ for (const config of carried) {
7862
+ configuredAgents.add(config.agentId);
7863
+ }
7864
+ // Who each remaining bot is, in the carried order: an app no longer
7865
+ // carried is nobody's to recognize, and a new one is learned below.
7866
+ const kept = new Map(botIdentities);
7867
+ botIdentities.clear();
7868
+ for (const config of carried) {
7869
+ const identity = kept.get(config.agentId);
7870
+ if (identity !== undefined && serving(config.agentId) !== undefined) {
7871
+ botIdentities.set(config.agentId, identity);
7872
+ }
7873
+ }
7874
+ for (const config of next) {
7875
+ if (!known.has(config.agentId) && serving(config.agentId) === undefined) {
7876
+ warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
7877
+ }
7878
+ }
7879
+ // Every new identity first, then the sockets, as `start` does: a
7880
+ // mention of an agent not yet known would read as an untagged reply.
7881
+ const authenticated = [];
7882
+ for (const config of arriving) {
7883
+ if (stopRequested) {
7884
+ return;
7885
+ }
7886
+ const connection = await authenticate(config);
7887
+ if (connection) {
7888
+ authenticated.push(connection);
7889
+ }
7890
+ }
7891
+ if (authenticated.length > 0) {
7892
+ const learned = new Map(botIdentities);
7893
+ botIdentities.clear();
7894
+ for (const config of carried) {
7895
+ const identity = learned.get(config.agentId);
7896
+ if (identity !== undefined) {
7897
+ botIdentities.set(config.agentId, identity);
7898
+ }
7899
+ }
7900
+ }
7901
+ settleHomeOwners();
7902
+ for (const connection of authenticated) {
7903
+ if (stopRequested) {
7904
+ return;
7905
+ }
7906
+ await connect(connection);
7907
+ }
7908
+ };
7909
+ // One reload at a time: the gateway can call again before an earlier one
7910
+ // settled, since it stops waiting after a bound.
7911
+ let reloading = Promise.resolve();
7912
+ const reload = () => {
7913
+ const run = reloading.then(reconcile, reconcile);
7914
+ reloading = run.catch(() => undefined);
7915
+ return run;
7916
+ };
6819
7917
  return {
6820
7918
  name: 'slack',
7919
+ ...(options.loadAgents !== undefined ? { reload } : {}),
6821
7920
  resolveOutbound,
6822
7921
  readConversation,
6823
7922
  sessionConversation,
6824
7923
  createConversation,
7924
+ joinConversation,
7925
+ inviteToConversation,
7926
+ archiveConversation,
6825
7927
  // A channel id is unique only within its workspace, so a standing
6826
7928
  // grant on one is bound to the team the agent's app authenticated in.
6827
7929
  accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
@@ -6831,23 +7933,21 @@ export const createSlackChannelAdapter = (options) => {
6831
7933
  credentialEnvForms: true,
6832
7934
  async start(gateway) {
6833
7935
  gatewayRef = gateway;
7936
+ stopRequested = false;
7937
+ leftUpAtStop.clear();
6834
7938
  // Nothing counts as seen live until every socket is up (Codex
6835
7939
  // 4321128637): a message sent while the sockets are still coming up
6836
7940
  // reaches none of them, so a read stopping before it stops short.
6837
7941
  liveFrom = NOT_LIVE_YET;
6838
7942
  unsubscribe = gateway.bus.subscribe((event) => {
6839
- // Approval traffic is not part of a reply: it gets its own message
6840
- // with its own lifetime, and routing it through the running turn's
6841
- // renderer would fold a question into the answer being streamed.
6842
- if (event.type === 'tool.approval-requested') {
6843
- track(renderApprovalRequest(event));
6844
- return;
6845
- }
6846
7943
  if (event.type === 'credential.provided') {
6847
7944
  track(settleCredentialPost(event));
6848
7945
  return;
6849
7946
  }
6850
7947
  if (event.type === 'tool.approval-resolved') {
7948
+ if (askingAfterLine.has(event.requestId)) {
7949
+ settledBeforeAsked.add(event.requestId);
7950
+ }
6851
7951
  // The question was posted into the turn's thread, which took the
6852
7952
  // loading status down with it; the turn works on once answered.
6853
7953
  track(retractApprovalRequest(event).finally(() => renderers.get(event.sessionId)?.[0]?.refreshLoading()));
@@ -6900,6 +8000,30 @@ export const createSlackChannelAdapter = (options) => {
6900
8000
  // turn from a renderer still waiting to start, and the renderer
6901
8001
  // keeps what it is handed, as it always has.
6902
8002
  const foreign = exact && !rendered;
8003
+ // Approval traffic is not part of a reply: it gets its own message
8004
+ // with its own lifetime, and routing it through the running turn's
8005
+ // renderer would fold a question into the answer being streamed.
8006
+ // What the turn wrote before the call goes out first, though, so
8007
+ // the approver reads the line saying what the agent is doing above
8008
+ // the question it leads to (card 950). The wait is bounded: a line
8009
+ // Slack is slow to take must not keep the question from anyone.
8010
+ if (event.type === 'tool.approval-requested') {
8011
+ head?.awaitingApproval(event.call.toolName, !foreign);
8012
+ const said = head?.sayBeforeAsking(!foreign);
8013
+ if (!said) {
8014
+ track(renderApprovalRequest(event));
8015
+ return;
8016
+ }
8017
+ askingAfterLine.add(event.requestId);
8018
+ track(settledWithin(said, approvalPromptWaitMs).then(async () => {
8019
+ askingAfterLine.delete(event.requestId);
8020
+ if (settledBeforeAsked.delete(event.requestId)) {
8021
+ return;
8022
+ }
8023
+ await renderApprovalRequest(event);
8024
+ }));
8025
+ return;
8026
+ }
6903
8027
  if (event.type === 'tool.completed' && event.result.ok && (foreign || !head)) {
6904
8028
  const produced = collectFilePaths(event.result);
6905
8029
  if (produced.length > 0) {
@@ -6956,33 +8080,14 @@ export const createSlackChannelAdapter = (options) => {
6956
8080
  // agent not yet known reads as an untagged reply — which the agent
6957
8081
  // holding that thread would then answer.
6958
8082
  const authenticated = [];
6959
- for (const config of options.agents) {
8083
+ for (const config of carried) {
6960
8084
  if (!known.has(config.agentId)) {
6961
8085
  warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
6962
8086
  continue;
6963
8087
  }
6964
- try {
6965
- const created = createWeb(config.botToken);
6966
- const web = config.linkPreviews === false ? withoutLinkPreviews(created) : created;
6967
- const auth = await web.auth.test();
6968
- const botUserId = auth.user_id ?? '';
6969
- const teamId = auth.team_id ?? '';
6970
- checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
6971
- // Recorded before the socket is even built: an app that never
6972
- // comes up must still be recognizable when somebody names it.
6973
- botIdentities.set(config.agentId, { botUserId, teamId });
6974
- authenticated.push({
6975
- config,
6976
- web,
6977
- socket: createSocket(config.appToken),
6978
- botUserId,
6979
- teamId,
6980
- statusWarned: { value: false },
6981
- });
6982
- }
6983
- catch (error) {
6984
- // One broken app must not take the rest of the fleet down.
6985
- warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
8088
+ const connection = await authenticate(config);
8089
+ if (connection) {
8090
+ authenticated.push(connection);
6986
8091
  }
6987
8092
  }
6988
8093
  settleHomeOwners();
@@ -7005,51 +8110,9 @@ export const createSlackChannelAdapter = (options) => {
7005
8110
  }
7006
8111
  }
7007
8112
  for (const connection of authenticated) {
7008
- const { config, socket } = connection;
7009
- try {
7010
- // Returns the work it started: the SDK ignores it, and a test's fake
7011
- // socket waits on it rather than guessing how long it takes.
7012
- const handle = (inbound) => {
7013
- const handled = inbound.catch((error) => {
7014
- warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
7015
- });
7016
- inflight.add(handled);
7017
- void handled.finally(() => inflight.delete(handled));
7018
- return handled;
7019
- };
7020
- // The replay's own deliveries never wait on the hold they release.
7021
- const onReplay = (args) => handle(handleInbound(connection, args));
7022
- const onEvent = (args) => {
7023
- const channel = args.event?.channel;
7024
- const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
7025
- if (hold === undefined) {
7026
- return onReplay(args);
7027
- }
7028
- // Acked now, as any delivery is, so Slack does not redeliver a
7029
- // message that is only waiting its turn; admitted after the hold.
7030
- void Promise.resolve(args.ack()).catch((error) => {
7031
- warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
7032
- });
7033
- return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
7034
- };
7035
- socket.on('app_mention', onEvent);
7036
- socket.on('message', onEvent);
7037
- listeners.set(config.agentId, onReplay);
7038
- socket.on('interactive', (args) => {
7039
- const handled = handleInteractive(connection, args).catch((error) => {
7040
- warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
7041
- });
7042
- track(handled);
7043
- return handled;
7044
- });
7045
- await socket.start();
7046
- connections.push(connection);
7047
- log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
7048
- }
7049
- catch (error) {
7050
- // Authenticated but not serving: it answers nothing, and stays
7051
- // recognizable so nobody answers in its place.
7052
- warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
8113
+ const onReplay = await connect(connection);
8114
+ if (onReplay) {
8115
+ listeners.set(connection.config.agentId, onReplay);
7053
8116
  }
7054
8117
  }
7055
8118
  const liveMs = now() + LIVE_FROM_MARGIN_MS;
@@ -7063,11 +8126,28 @@ export const createSlackChannelAdapter = (options) => {
7063
8126
  track(replayKept(kept, new Map([...listeners].filter(([agentId]) => connections.some((connection) => connection.config.agentId === agentId))), replayStopping.signal).finally(releaseAllConversations));
7064
8127
  },
7065
8128
  async stop() {
8129
+ stopRequested = true;
7066
8130
  replayStopping?.abort();
7067
8131
  releaseAllConversations();
7068
8132
  // Stop intake first, so no new INBOUND event can slip into
7069
- // `inflight` while the drain below runs.
7070
- await Promise.allSettled(connections.map((connection) => connection.socket.disconnect()));
8133
+ // `inflight` while the drain below runs. A close that is refused
8134
+ // leaves a socket that may still deliver (card 935), and so does one
8135
+ // still starting that does not come down in time: the stop rejects
8136
+ // naming them, after the drain, and the gateway counts that as not
8137
+ // drained, so a restart exits for the service manager rather than
8138
+ // start a second daemon answering beside them.
8139
+ const closing = Promise.all(connections.map(async (connection) => await disconnect(connection, 'the adapter is stopping') ? undefined : connection.config.agentId));
8140
+ // Whatever is still in here once the wait ends never came up or down.
8141
+ const starting = new Map(socketsStarting);
8142
+ let waited;
8143
+ await Promise.race([
8144
+ Promise.allSettled([...starting.keys()].map((up) => up.finally(() => starting.delete(up)))),
8145
+ new Promise((resolve) => { waited = setTimeout(resolve, stopWaitMs); }),
8146
+ ]);
8147
+ clearTimeout(waited);
8148
+ const refused = [...new Set([...(await closing).filter((agentId) => agentId !== undefined), ...leftUpAtStop])];
8149
+ const stillStarting = [...new Set(starting.values())];
8150
+ leftUpAtStop.clear();
7071
8151
  // Until it stays empty, not once. The bus subscription is still live
7072
8152
  // here — it comes down after the drain, because a turn finishing
7073
8153
  // inside the drain still has an outcome to render — so a turn the
@@ -7101,6 +8181,13 @@ export const createSlackChannelAdapter = (options) => {
7101
8181
  rendering.clear();
7102
8182
  resolvedWhileRendering.clear();
7103
8183
  credentialsPosting.clear();
8184
+ const left = [
8185
+ ...(refused.length > 0 ? [`the socket for ${refused.join(', ')} would not close`] : []),
8186
+ ...(stillStarting.length > 0 ? [`the socket for ${stillStarting.join(', ')} was still starting after ${stopWaitMs}ms`] : []),
8187
+ ];
8188
+ if (left.length > 0) {
8189
+ throw new Error(`${left.join('; ')}, so it may still deliver messages`);
8190
+ }
7104
8191
  },
7105
8192
  };
7106
8193
  };