@stratusagent/channel-slack 0.11.5 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Slack adapter for Stratus agents. **One Slack app per agent** — Slack has no w
4
4
 
5
5
  - **Resumable conversations**: session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>` (DMs: the DM channel id) — a thread is a conversation, it survives daemon restarts, and two agents sharing a thread keep fully separate sessions. A turn parked on a human when the daemon died is re-asked after the restart, and when it finishes its reply is posted into the thread as a fresh message — the placeholder it would have edited belonged to the old process.
6
6
  - **Replies post when they are ready**: by default the reply is posted once, finished, with Slack's loading status (`assistant.threads.setStatus` — "is thinking…", "is running shell.run…") shown while the turn works, so the notification carries the answer. The `stream` reply mode (`slack.replies` in the [config](../../docs/guides/slack.md#how-replies-appear)) keeps the placeholder-and-edit rendering instead: post `…`, edit as deltas arrive (throttled for `chat.update` limits), show `⚙ tool…` status lines, finalize with the full reply. Either way a reply that outgrows one message is split across several.
7
- - **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings. The reply is read once into tokens and written back from those, so what counts as code is decided one time rather than by each rule in turn. Three things follow from that. **A code run is one token**: nothing reads or rewrites inside it, an unclosed fence included, and the newlines in a fence are not line breaks — so a `#` on a line of somebody's shell script is not a heading. A run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work; one or two unmatched backticks are text, as they are in Markdown, but a pair is a span however far apart, because Slack pairs them too. **A delimiter is matched to its partner**, across whatever sits between — ``**the `fs.read` tool**`` is bold around a snippet, not four literal asterisks — though never across a line, so a marker still being streamed stays literal until its closing half arrives. A run is spent rather than matched whole, so the three asterisks ending `**bold and *italic***` close the italic with one and the bold with two; what no pair spends stays on the line as text, and what a run cannot buy — the third tilde of `~~~struck~~~`, a style that does not exist — is never charged for. **A wrapper is only written where its own character is not already loose** in what it wraps: Slack has one delimiter per style and no way to nest it, so a heading that already contains bold keeps the bold it has rather than gaining a second pair that would print instead of render, and `# glob *.ts` keeps an asterisk that was never a marker. A heading sheds its hashes and its trailing spaces from the prose that holds them, never from a snippet. Anything whose spelling already matches — lists, block quotes, inline code — is untouched.
7
+ - **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings. The reply is read once into tokens and written back from those, so what counts as code is decided one time rather than by each rule in turn. Three things follow from that. **A code run is one token**: nothing reads or rewrites inside it, an unclosed fence included, and the newlines in a fence are not line breaks — so a `#` on a line of somebody's shell script is not a heading. A run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work; one or two unmatched backticks are text, as they are in Markdown, but a pair is a span however far apart, because Slack pairs them too. **A delimiter is matched to its partner**, across whatever sits between — ``**the `fs.read` tool**`` is bold around a snippet, not four literal asterisks — though never across a line, so a marker still being streamed stays literal until its closing half arrives. A run is spent rather than matched whole, so the three asterisks ending `**bold and *italic***` close the italic with one and the bold with two; what no pair spends stays on the line as text, and what a run cannot buy — the third tilde of `~~~struck~~~`, a style that does not exist — is never charged for. **A wrapper is only written where its own character is not already loose** in what it wraps: Slack has one delimiter per style and no way to nest it, so a heading that already contains bold keeps the bold it has rather than gaining a second pair that would print instead of render, and `# glob *.ts` keeps an asterisk that was never a marker. A heading sheds its hashes and its trailing spaces from the prose that holds them, never from a snippet. Anything whose spelling already matches — lists, block quotes, inline code — is untouched. **A pipe table**, which mrkdwn has no syntax for, becomes a code block with its columns padded to line up (alignment colons respected, `**`/`` ` `` markers dropped from cells, since nothing renders inside code); a grid wider than 60 characters — past what a phone shows unwrapped — or one with a link in it (a link inside a code block is not clickable) becomes one line per row, `*Header*: value · *Header*: value`. A one-column table has nothing to line up and becomes a bulleted list under its bold header. A table inside a fence is somebody's text and stays as written, and a grid longer than one message is split like any code block, closed at the cut and reopened in the next, with its header and rule repeated so every part names its columns.
8
8
  - **Mention to start, then it stays in the thread.** A mention opens a conversation; every reply in that thread reaches the agent without tagging it again, and free-form DMs are unchanged. Who an untagged reply is for is decided by [Who a message is for](#who-a-message-is-for). Inbound mentions of other users are humanized to `@Display Name` for the model; redeliveries are deduped so a slow turn never runs twice.
9
9
  - **Approval buttons** for the gateway's `remote` permission mode: a gated tool call parks the turn and asks in its thread with **Allow once** / **Always allow** / **Deny**. See [Approving tool calls](#approving-tool-calls) — clicks are authorized by *who clicked*, never by who can see the message, and **Always allow** is offered only where the daemon would actually remember it.
10
10
  - **Tokens are gateway infrastructure secrets**, stored in the `channels` namespace of `~/.stratus/credentials.json` — never in an agent's credential allowlist:
@@ -131,11 +131,24 @@ agent's runtime: the Anthropic API, OpenAI-compatible providers, and the
131
131
  Claude Code runtime send it as image content; the Codex harness takes a
132
132
  text prompt, so there the agent is told the image's name and that it cannot
133
133
  see it.
134
- **Every other attachment is unreadable** — a log, a PDF, an image that was
135
- too large — so the message reaches the agent naming those files and saying
136
- they cannot be opened, which is what lets it answer honestly instead of as
137
- though it had read the log. Such a file dropped in with nothing said is not
138
- a question, and gets no reply.
134
+ **Text files are read into the message.** Markdown, plain text, CSV, JSON,
135
+ YAML, a log — anything Slack labels `text/*` (HTML aside), a few text
136
+ `application/*` types, or an unlabelled file whose name says it is one of
137
+ those — is downloaded with the same `files:read` scope and set into the
138
+ message after what was said, between `[Attached: plan.md. Its contents
139
+ follow.]` and `[End of plan.md]`. A text file dropped in alone is a
140
+ question, like a screenshot. One file may be 100 KB and one message's text
141
+ files 200 KB together, since read text joins the conversation and is sent
142
+ again every turn; a larger one is never downloaded, and `stratus serve`
143
+ warns with the file's name. A file that is not UTF-8, or that comes back as
144
+ Slack's sign-in page because the scope is missing, is treated as unreadable
145
+ and the warning names the scope. Text files share the message's 30-second
146
+ download deadline with its images.
147
+ **Every other attachment is unreadable** — a PDF, a Word file, an image that
148
+ was too large, a text file over the caps — so the message reaches the agent
149
+ naming those files and saying they cannot be opened, which is what lets it
150
+ answer honestly instead of as though it had read them. Such a file dropped
151
+ in with nothing said is not a question, and gets no reply.
139
152
  Sessions are still per agent: an agent hears a thread from the mention
140
153
  that brought it in, and what was said before that — to the other agent, or
141
154
  by it — is not backfilled ([#147](https://github.com/stratuslabs/agent/issues/147)).
@@ -189,9 +202,10 @@ editing `credentials.json` by hand.
189
202
  The manual equivalent, if you prefer:
190
203
 
191
204
  1. https://api.slack.com/apps → **Create New App → From a manifest** → paste `manifest/stratus-agent.manifest.json` with `NAME` replaced by the agent's name.
192
- The manifest asks for the `channels:history` / `groups:history` / `mpim:history` scopes and the matching `message.*` events, which is what lets an agent [stay in a thread](#who-a-message-is-for) instead of needing a mention every time, and for `files:read`, which is what lets it [see an attached image](#who-a-message-is-for). An app created before those shipped needs them added under **OAuth & Permissions** and **Event Subscriptions** and reinstalled once; leave the history scopes off and it answers mentions and DMs, exactly as it did before; leave `files:read` off and it hears about attachments by name only.
193
- 2. **Basic Information → App-Level Tokens** → generate a token with `connections:write` (that's the `appToken`, `xapp-…`).
194
- 3. **Install App** to the workspace → copy the **Bot User OAuth Token** (that's the `botToken`, `xoxb-…`).
205
+ The manifest asks for the `channels:history` / `groups:history` / `mpim:history` scopes and the matching `message.*` events, which is what lets an agent [stay in a thread](#who-a-message-is-for) instead of needing a mention every time, and for `files:read`, which is what lets it [see an attached image](#who-a-message-is-for). An app created before those shipped needs them added under **OAuth & Permissions** and **Event Subscriptions** and reinstalled once; leave the history scopes off and it answers mentions and DMs, exactly as it did before; leave `files:read` off and it hears about attachments, images and text files alike, by name only.
206
+ It also turns on the **Messages Tab** under App Home (`features.app_home.messages_tab_enabled`), which Slack creates apps without — until it is on, a DM to the app gets Slack's own *Sending messages to this app has been turned off* and never reaches the adapter. An app created before the manifest carried it needs **App Home → Messages Tab → Allow users to send Slash commands and messages from the messages tab** switched on once; reopen the DM afterwards.
207
+ 2. Still on **Basic Information**, scroll to **App-Level Tokens** → **Generate Token and Scopes** → name it anything → **Add Scope** → `connections:write` → **Generate**, and copy the token (that's the `appToken`, `xapp-…`). It is not on **OAuth & Permissions**, where the bot token is.
208
+ 3. **Install App** → **Install to** the workspace → **Allow** → copy the **Bot User OAuth Token** (that's the `botToken`, `xoxb-…`).
195
209
  4. Upload the agent's avatar under **Display Information**.
196
210
  5. Add both tokens under `channels.slack.<agentId>` in `~/.stratus/credentials.json` and restart `stratus serve` — the log will show `slack: <agentId> connected`.
197
211
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { filePathsOf, latestTurnReply, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, } from '@stratusagent/core';
6
6
  import { channelSessionKey, } from '@stratusagent/channels';
7
- import { toSlackMrkdwn } from "./mrkdwn.js";
7
+ import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
8
8
  /**
9
9
  * The longest display name the model is shown. Slack caps profile names
10
10
  * well under this; the bound is for a profile that is not a name.
@@ -309,10 +309,12 @@ class ReplyRenderer {
309
309
  return;
310
310
  }
311
311
  this.loading = true;
312
- // A thread has one status per app, so a turn queued behind another
313
- // must not publish its own: it would overwrite what the running turn
314
- // says it is doing. It publishes when it reaches the head (`beginTurn`,
315
- // or `refreshLoading` once the turn ahead has posted).
312
+ // A thread has one status per app, so a turn queued behind another in
313
+ // the same thread must not publish its own: it would overwrite what the
314
+ // running turn says it is doing. It publishes when it reaches the head
315
+ // (`beginTurn`, or `refreshLoading` once the turn ahead has posted).
316
+ // The caller passes `head` for a queued turn whose status lives on a
317
+ // key of its own — every DM message is — since it overwrites nothing.
316
318
  if (head) {
317
319
  this.publishLoading();
318
320
  }
@@ -894,9 +896,23 @@ const safeCutIndex = (text, index) => {
894
896
  const code = text.charCodeAt(index - 1);
895
897
  return code >= 0xd800 && code <= 0xdbff ? index - 1 : index;
896
898
  };
899
+ /** `index` moved forward, never back, off the middle of a surrogate pair. */
900
+ const pastCodePoint = (text, index) => {
901
+ const code = text.charCodeAt(index - 1);
902
+ return code >= 0xd800 && code <= 0xdbff ? index + 1 : index;
903
+ };
897
904
  const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
898
905
  ? text
899
906
  : `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
907
+ /**
908
+ * Text as however many messages Slack's limit makes it, never leaving a
909
+ * code block broken across the cut. A cut blind to fences ended one
910
+ * message on an unterminated block and started the next mid-block, where
911
+ * Slack read the rest of somebody's log as prose — every `*` and `_` in it
912
+ * formatting. So a cut that would land inside a code run moves in front of
913
+ * it when that still leaves a message worth sending, and otherwise closes
914
+ * the run at the cut and reopens it in the next.
915
+ */
900
916
  const splitForSlack = (text) => {
901
917
  if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
902
918
  return [text];
@@ -906,11 +922,49 @@ const splitForSlack = (text) => {
906
922
  while (rest.length > SLACK_MAX_MESSAGE_CHARS) {
907
923
  // Prefer a newline break inside the window; fall back to a hard cut.
908
924
  const window = rest.slice(0, SLACK_MAX_MESSAGE_CHARS);
909
- const breakAt = window.lastIndexOf('\n') > SLACK_MAX_MESSAGE_CHARS / 2
910
- ? window.lastIndexOf('\n')
911
- : safeCutIndex(rest, SLACK_MAX_MESSAGE_CHARS);
912
- chunks.push(rest.slice(0, breakAt));
913
- rest = rest.slice(breakAt).replace(/^\n+/, '');
925
+ const newline = window.lastIndexOf('\n');
926
+ const cut = newline > SLACK_MAX_MESSAGE_CHARS / 2 ? newline : safeCutIndex(rest, SLACK_MAX_MESSAGE_CHARS);
927
+ // Scanned two messages deep, not to the end: rescanning the whole rest
928
+ // for every chunk made a megabyte reply quadratic on the event loop. A
929
+ // fence that closes past the window still reads as open to its end,
930
+ // which is what it is here; an inline span that does is longer than a
931
+ // message, and is cut as text either way.
932
+ const run = codeRunsOf(rest.slice(0, SLACK_MAX_MESSAGE_CHARS * 2))
933
+ .find((candidate) => candidate.start < cut && cut < candidate.end);
934
+ if (run !== undefined && run.start > SLACK_MAX_MESSAGE_CHARS / 4) {
935
+ chunks.push(rest.slice(0, run.start).replace(/\n+$/, ''));
936
+ rest = rest.slice(run.start);
937
+ continue;
938
+ }
939
+ // The run is most of the message, so it is cut itself — at its last
940
+ // newline that leaves room for the closer, and past its opener so the
941
+ // continuation always makes progress. A continuation reopens with the
942
+ // info string only while it is short: a two-thousand-character info
943
+ // line repeated on every part would crowd out the code it labels.
944
+ const budget = SLACK_MAX_MESSAGE_CHARS - (run?.closer.length ?? 0);
945
+ const floor = run === undefined ? 0 : run.start + run.opener.length;
946
+ const reopen = run === undefined || run.opener.length <= SLACK_MAX_MESSAGE_CHARS / 8
947
+ ? run?.opener
948
+ : run.opener.endsWith('\n') ? `${run.closer.slice(1)}\n` : run.opener;
949
+ // A delimiter too long to close and reopen inside one message — a fence
950
+ // of a thousand backticks, an opening line that fills the window — is
951
+ // cut raw, as before this existed. Reopening it would send a message
952
+ // over the limit, and consume a character a message after that.
953
+ // Only a fence is reopened. Its closer and opener sit on lines of their
954
+ // own, so they can never run into a backtick of the code; an inline
955
+ // span's would, and `x` + `` would read as a different delimiter.
956
+ // `floor + 2`: the first cut past the opener has to fit a whole code
957
+ // point, or the step that guarantees progress lands inside an emoji.
958
+ const fence = run !== undefined && run.closer.startsWith('\n');
959
+ if (run === undefined || !fence || reopen === undefined || reopen.length + run.closer.length > SLACK_MAX_MESSAGE_CHARS / 4 || floor + 2 > budget) {
960
+ chunks.push(rest.slice(0, cut));
961
+ rest = rest.slice(cut).replace(/^\n+/, '');
962
+ continue;
963
+ }
964
+ const inner = rest.lastIndexOf('\n', budget - 1);
965
+ const within = inner > Math.max(floor, SLACK_MAX_MESSAGE_CHARS / 2) ? inner : Math.max(safeCutIndex(rest, budget), pastCodePoint(rest, floor + 1));
966
+ chunks.push(`${rest.slice(0, within)}${run.closer}`);
967
+ rest = `${reopen}${rest.slice(within).replace(/^\n/, '')}`;
914
968
  }
915
969
  if (rest.length > 0) {
916
970
  chunks.push(rest);
@@ -1057,15 +1111,13 @@ const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed fi
1057
1111
  * fix — the warning here names the scope instead, and the file falls back
1058
1112
  * to the by-name note like any other attachment the turn cannot open.
1059
1113
  */
1060
- const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn) => {
1114
+ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn, deadline = AbortSignal.timeout(timeoutMs)) => {
1061
1115
  // Decided from the last file back, the way the replay window is spent:
1062
1116
  // when a message's images do not all fit, the ones kept are the ones it
1063
1117
  // listed last, so intake and replay agree about which survive. Delivered
1064
1118
  // in the message's own order, because that is how the person sees them.
1065
1119
  const kept = new Map();
1066
1120
  const dropped = new Set();
1067
- // The message's one deadline, shared by every download in it.
1068
- const deadline = AbortSignal.timeout(timeoutMs);
1069
1121
  // Decoded bytes accepted so far. Checked against Slack's reported size
1070
1122
  // before a download and the real length after it, because the message
1071
1123
  // as a whole has a budget the per-image cap alone cannot keep.
@@ -1188,6 +1240,114 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn)
1188
1240
  });
1189
1241
  return { images, unread };
1190
1242
  };
1243
+ /** The most one text attachment may weigh to be read into the message. */
1244
+ const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
1245
+ /**
1246
+ * The most one message's text attachments may weigh together. Read text
1247
+ * joins the transcript and is sent again on every later turn, so the cap
1248
+ * is a cost decision as much as a context one.
1249
+ */
1250
+ const TEXT_ATTACHMENTS_MAX_TOTAL_BYTES = 200 * 1024;
1251
+ const TEXT_APPLICATION_TYPES = new Set([
1252
+ 'application/json',
1253
+ 'application/xml',
1254
+ 'application/yaml',
1255
+ 'application/x-yaml',
1256
+ 'application/toml',
1257
+ 'application/javascript',
1258
+ 'application/x-sh',
1259
+ 'application/sql',
1260
+ ]);
1261
+ const TEXT_EXTENSIONS = /\.(md|markdown|txt|text|csv|tsv|json|ya?ml|toml|xml|log|ini|conf)$/i;
1262
+ /**
1263
+ * Whether a file is text the model can be handed as it is. Slack's
1264
+ * mimetype comes from the uploader's client and a Markdown file can arrive
1265
+ * as `text/markdown`, `text/plain`, or an octet stream, so an unhelpful
1266
+ * type falls back to the name. HTML is left out: a sign-in page is HTML
1267
+ * too, and a file of it could not be told apart from Slack refusing one.
1268
+ */
1269
+ const isTextAttachment = (file) => {
1270
+ const mimetype = file.mimetype?.toLowerCase();
1271
+ if (mimetype === 'text/html') {
1272
+ return false;
1273
+ }
1274
+ if (mimetype !== undefined && (mimetype.startsWith('text/') || TEXT_APPLICATION_TYPES.has(mimetype))) {
1275
+ return true;
1276
+ }
1277
+ const name = file.name ?? file.title;
1278
+ return (mimetype === undefined || mimetype === 'application/octet-stream') && name !== undefined && TEXT_EXTENSIONS.test(name);
1279
+ };
1280
+ /**
1281
+ * The text files among a message's attachments, read so the turn gets
1282
+ * their contents rather than their names. A Markdown plan attached in a
1283
+ * DM reached the agent as a filename, and it told the person it had no
1284
+ * way to read Slack at all. Anything that is not UTF-8 text, is over the
1285
+ * caps, or comes back as a page stays in `unread` and is named in the
1286
+ * note, exactly as before; the warning says why.
1287
+ */
1288
+ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn) => {
1289
+ const texts = [];
1290
+ const unread = [];
1291
+ let total = 0;
1292
+ for (const file of files) {
1293
+ const url = file.url_private_download ?? file.url_private;
1294
+ if (!isTextAttachment(file) || url === undefined) {
1295
+ unread.push(file);
1296
+ continue;
1297
+ }
1298
+ const maxBytes = Math.min(TEXT_ATTACHMENT_MAX_BYTES, TEXT_ATTACHMENTS_MAX_TOTAL_BYTES - total);
1299
+ if (file.size !== undefined && file.size > maxBytes) {
1300
+ warn(`slack: ${fileLabel(file)} is ${file.size} bytes, over the ${maxBytes} bytes of text this message could still take; the turn is told it cannot be read. Paste the part that matters, or split the file.`);
1301
+ unread.push(file);
1302
+ continue;
1303
+ }
1304
+ if (deadline.aborted) {
1305
+ warn(`slack: ${fileLabel(file)} was not downloaded: this message's downloads had already taken longer than ${timeoutMs}ms. The turn is told it cannot be read.`);
1306
+ unread.push(file);
1307
+ continue;
1308
+ }
1309
+ let download;
1310
+ try {
1311
+ download = await fetchFile(url, botToken, deadline, maxBytes);
1312
+ }
1313
+ catch (error) {
1314
+ const reason = error instanceof Error && error.name === 'TimeoutError'
1315
+ ? `this message's downloads took longer than ${timeoutMs}ms`
1316
+ : (error instanceof Error ? error.message : String(error));
1317
+ warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
1318
+ unread.push(file);
1319
+ continue;
1320
+ }
1321
+ // HTML is never a file read here (see isTextAttachment), so an HTML
1322
+ // body is Slack's sign-in page: what a token without files:read gets.
1323
+ const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
1324
+ const head = download.body.subarray(0, 64).toString('utf8').trimStart().toLowerCase();
1325
+ if (download.status !== 200 || contentType === 'text/html' || head.startsWith('<!doctype html') || head.startsWith('<html')) {
1326
+ warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than the file. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
1327
+ unread.push(file);
1328
+ continue;
1329
+ }
1330
+ if (download.truncated === true || download.body.length > maxBytes) {
1331
+ warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes of text this message could still take; the download was abandoned and the turn is told it cannot be read.`);
1332
+ unread.push(file);
1333
+ continue;
1334
+ }
1335
+ let text;
1336
+ try {
1337
+ text = new TextDecoder('utf-8', { fatal: true }).decode(download.body);
1338
+ }
1339
+ catch {
1340
+ warn(`slack: ${fileLabel(file)} is not UTF-8 text; the turn is told it cannot be read.`);
1341
+ unread.push(file);
1342
+ continue;
1343
+ }
1344
+ total += download.body.length;
1345
+ texts.push({ name: fileLabel(file), text });
1346
+ }
1347
+ return { texts, unread };
1348
+ };
1349
+ /** Read text attachments, set into the message after what was said. */
1350
+ const textAttachmentBlocks = (texts) => texts.map(({ name, text }) => `\n[Attached: ${name}. Its contents follow.]\n${text.replace(/\s+$/, '')}\n[End of ${name}]`).join('');
1191
1351
  /**
1192
1352
  * Whether `text` addresses a bot user by name. Slack writes a mention as
1193
1353
  * `<@U123>` markup whoever typed it, so this is the same question for an
@@ -2868,20 +3028,25 @@ export const createSlackChannelAdapter = (options) => {
2868
3028
  // nobody asked for, since they reach the model ahead of the frame
2869
3029
  // that marks the message as somebody else's, and a judged message
2870
3030
  // names its attachments the way an overheard one does.
2871
- const { images, unread } = overhear || judged
3031
+ // Images and text files share the message's one download deadline.
3032
+ const deadline = AbortSignal.timeout(fileDownloadTimeoutMs);
3033
+ const { images, unread: notImages } = overhear || judged
2872
3034
  ? { images: [], unread: event.files ?? [] }
2873
- : await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
2874
- // An image with nothing said is still a question ("what's this?"); a
2875
- // file the turn could not open with nothing said is not one, and gets
2876
- // no reply.
2877
- if (cleaned.length === 0 && images.length === 0) {
3035
+ : await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, deadline);
3036
+ const { texts, unread } = overhear || judged
3037
+ ? { texts: [], unread: notImages }
3038
+ : await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn);
3039
+ // An image or a document with nothing said is still a question
3040
+ // ("what's this?"); a file the turn could not open with nothing said
3041
+ // is not one, and gets no reply.
3042
+ if (cleaned.length === 0 && images.length === 0 && texts.length === 0) {
2878
3043
  return undefined;
2879
3044
  }
2880
3045
  const author = await authorFor(connection, userId);
2881
3046
  // In shared channels the model should know who is speaking; a DM is
2882
3047
  // unambiguous. A bare image in a channel still says who sent it.
2883
3048
  const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
2884
- const userMessage = `${spoken}${attachmentNote(unread)}`;
3049
+ const userMessage = `${spoken}${textAttachmentBlocks(texts)}${attachmentNote(unread)}`;
2885
3050
  // Who sent this, judged against the operator's list — per message,
2886
3051
  // never remembered from the first one in the thread. A DM proves
2887
3052
  // nothing about who is typing, so a DM from an unlisted member is
@@ -2958,7 +3123,12 @@ export const createSlackChannelAdapter = (options) => {
2958
3123
  const queue = renderers.get(sessionId) ?? [];
2959
3124
  queue.push(renderer);
2960
3125
  renderers.set(sessionId, queue);
2961
- renderer.showLoading(queue.length === 1);
3126
+ // In a DM each message is its own status key, and Slack stops showing
3127
+ // the running turn's once a newer message sits below it — so a
3128
+ // message queued there that stayed dark read as the agent having
3129
+ // stopped until the reply ahead of it posted. In a channel thread the
3130
+ // key is shared, and the running turn's status stays up instead.
3131
+ renderer.showLoading(queue.length === 1 || queue[0]?.statusThread !== renderer.statusThread);
2962
3132
  const turn = gateway.dispatch({
2963
3133
  sessionId,
2964
3134
  agentId: connection.config.agentId,