@stratusagent/channel-slack 0.13.6 → 0.14.0

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,7 @@ 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, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
7
- import { admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/channels';
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';
8
7
  import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
9
8
  /**
10
9
  * The longest display name the model is shown. Slack caps profile names
@@ -85,6 +84,17 @@ const PLACEHOLDER_TEXT = '…';
85
84
  /** What the loading status says while a turn works, and while it runs a tool. */
86
85
  const THINKING_STATUS = 'is thinking…';
87
86
  const toolStatus = (toolName) => `is running ${toolName}…`;
87
+ /**
88
+ * The same step as a line of its own, for Slack's `loading_messages`
89
+ * ("is running shell.run…" -> "Running shell.run…"). Slack's agent sessions
90
+ * show their own generic text ("Finding answers…") in place of `status`,
91
+ * and rotate these instead. None for the clear: Slack wants one to ten,
92
+ * and the empty status alone takes the indicator down.
93
+ */
94
+ const loadingMessage = (status) => {
95
+ const step = status.replace(/^is /, '');
96
+ return step === '' ? undefined : step.charAt(0).toUpperCase() + step.slice(1);
97
+ };
88
98
  /**
89
99
  * How often a loading status is set again while its turn runs. Slack drops
90
100
  * one after two minutes with no message, and a long turn — a tool, an
@@ -583,13 +593,19 @@ class ReplyRenderer {
583
593
  }
584
594
  this.statusText = text;
585
595
  const thread = this.statusThread;
596
+ const loading = loadingMessage(text);
586
597
  // Not awaited by the caller: the turn never waits on a status. Only
587
598
  // `finalize` does, for the ordering reason on `statusChain`. A refusal
588
599
  // — a workspace or plan without it, a token Slack will not honour for
589
600
  // it — is said once and then left alone; the reply still arrives,
590
601
  // just without the status ahead of it.
591
602
  this.statusChain = this.statusChain
592
- .then(() => setStatus.call(this.web.assistant?.threads, { channel_id: this.channel, thread_ts: thread, status: text }))
603
+ .then(() => setStatus.call(this.web.assistant?.threads, {
604
+ channel_id: this.channel,
605
+ thread_ts: thread,
606
+ status: text,
607
+ ...(loading ? { loading_messages: [loading] } : {}),
608
+ }))
593
609
  .then(() => undefined, (error) => {
594
610
  if (!this.statusWarned.value) {
595
611
  this.statusWarned.value = true;
@@ -1178,7 +1194,13 @@ class ReplyRenderer {
1178
1194
  }
1179
1195
  return this.finalize(`Stopped by <@${by}>.`);
1180
1196
  }
1181
- async fail(message) {
1197
+ /**
1198
+ * `refused` is a turn the host stopped on purpose (`HostRefusalError`), a
1199
+ * spent budget, whose message is already a sentence for the person
1200
+ * reading it. Framed as something having gone wrong, it would read as a
1201
+ * fault to report when it is a limit working.
1202
+ */
1203
+ async fail(message, refused = false) {
1182
1204
  // The same wait `finalize` takes, for the same reason: a lazy turn's
1183
1205
  // first text may be opening its placeholder right now, and a failure
1184
1206
  // read as "before saying anything" would leave that placeholder saying
@@ -1195,7 +1217,7 @@ class ReplyRenderer {
1195
1217
  this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
1196
1218
  return this.finalize('');
1197
1219
  }
1198
- return this.finalize(`Something went wrong: ${message}`);
1220
+ return this.finalize(refused ? message : `Something went wrong: ${message}`);
1199
1221
  }
1200
1222
  /**
1201
1223
  * Claim the placeholder for a turn that finished ahead of this one — the
@@ -1570,12 +1592,53 @@ const attachmentNote = (files, reasons) => {
1570
1592
  };
1571
1593
  /** What Slack calls a file, in a warning about it. */
1572
1594
  const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed file';
1595
+ /**
1596
+ * Slack's scaled copies of an image, largest first. Slack makes these for
1597
+ * every image it hosts — a 14 MB, 2600×1800 PNG came back with thumbnails
1598
+ * from 64 to 1024 pixels a side, each a PNG under 3 MB — so an image too
1599
+ * big for the model still has a copy it can take, with no image library
1600
+ * here to make one. Smaller than 360 is too small to read anything in.
1601
+ */
1602
+ const SLACK_PREVIEW_KEYS = ['thumb_1024', 'thumb_960', 'thumb_800', 'thumb_720', 'thumb_480', 'thumb_360'];
1603
+ /** The largest preview Slack lists for a file, if it lists one. */
1604
+ const slackPreviewUrl = (file) => SLACK_PREVIEW_KEYS.map((key) => file[key]).find((url) => typeof url === 'string' && url.length > 0);
1605
+ /**
1606
+ * What an image's own bytes say it is. A preview's type is not in Slack's
1607
+ * metadata — only the original's is — so it is read from the file itself,
1608
+ * and anything that is none of the four the API takes is not an image here.
1609
+ */
1610
+ const imageMediaTypeOf = (bytes) => {
1611
+ const ascii = (at, length) => bytes.subarray(at, at + length).toString('latin1');
1612
+ if (bytes.length >= 8 && bytes[0] === 0x89 && ascii(1, 3) === 'PNG') {
1613
+ return 'image/png';
1614
+ }
1615
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
1616
+ return 'image/jpeg';
1617
+ }
1618
+ if (ascii(0, 4) === 'GIF8') {
1619
+ return 'image/gif';
1620
+ }
1621
+ if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
1622
+ return 'image/webp';
1623
+ }
1624
+ return undefined;
1625
+ };
1626
+ const MIB = 1024 * 1024;
1627
+ /**
1628
+ * Says which images the model sees scaled down. Without it the agent reads
1629
+ * a 1024-pixel preview of a 14 MB screenshot as the screenshot, and states
1630
+ * what the small text says when it could only guess at it.
1631
+ */
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('');
1573
1633
  /**
1574
1634
  * A message's attachments, sorted into the images the model will be shown
1575
1635
  * and everything else. An image is fetched only when Slack's own metadata
1576
1636
  * says it is one the API accepts and small enough to send; that keeps a
1577
1637
  * 40 MB PNG from being downloaded just to be dropped, and a PDF from being
1578
- * fetched at all. What comes back is checked as well: without `files:read`
1638
+ * fetched at all. An image over the per-image limits — 5 MB, or 8000
1639
+ * pixels a side — is sent as Slack's own largest preview of it instead,
1640
+ * when Slack made one, and the turn is told it is a preview. What comes
1641
+ * back is checked as well: without `files:read`
1579
1642
  * Slack serves a sign-in page with a 200, and sending that to the model as
1580
1643
  * an image would fail the turn with an error naming nothing the person can
1581
1644
  * fix — the warning here names the scope instead, and the file falls back
@@ -1587,6 +1650,7 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1587
1650
  // listed last, so intake and replay agree about which survive. Delivered
1588
1651
  // in the message's own order, because that is how the person sees them.
1589
1652
  const kept = new Map();
1653
+ const previewed = new Map();
1590
1654
  const dropped = new Set();
1591
1655
  // Recorded beside the drop, so the note says why and never guesses. A
1592
1656
  // non-image goes on to the text reader with no reason: it decides.
@@ -1604,121 +1668,157 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1604
1668
  // not slip in past a larger newer one that was left out.
1605
1669
  let windowClosed = false;
1606
1670
  // Why an image is being left out, or nothing when it fits. One place for
1607
- // both limits so the warning and the fallback cannot disagree.
1671
+ // both limits so the warning and the fallback cannot disagree. Over the
1672
+ // per-image cap, the image can still be sent as Slack's preview; over
1673
+ // what is left of the message's budget, it is not sent at all.
1608
1674
  const overLimit = (label, bytes) => {
1609
1675
  if (bytes > IMAGE_ATTACHMENT_MAX_BYTES) {
1610
- return `slack: ${label} is ${bytes} bytes, over the ${IMAGE_ATTACHMENT_MAX_BYTES}-byte limit an image can be sent to the model at; the turn is told it cannot be read.`;
1676
+ const why = `${label} is ${bytes} bytes, over the ${IMAGE_ATTACHMENT_MAX_BYTES}-byte limit an image can be sent to the model at`;
1677
+ return {
1678
+ reason: 'too-large',
1679
+ warning: `slack: ${why}; the turn is told it cannot be read.`,
1680
+ oversized: { why, original: `is ${(bytes / MIB).toFixed(1)} MB, over the ${IMAGE_ATTACHMENT_MAX_BYTES / MIB} MB a model can be sent` },
1681
+ };
1611
1682
  }
1612
1683
  if (total + bytes > IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES) {
1613
1684
  windowClosed = true;
1614
- return `slack: ${label} would take this message's images past the ${IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES} bytes one request can carry; the turn is told it cannot be read. Send it in a message of its own.`;
1685
+ return { reason: 'too-large', warning: `slack: ${label} would take this message's images past the ${IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES} bytes one request can carry; the turn is told it cannot be read. Send it in a message of its own.` };
1615
1686
  }
1616
1687
  return undefined;
1617
1688
  };
1618
- for (let position = files.length - 1; position >= 0; position -= 1) {
1619
- const file = files[position];
1620
- const url = file.url_private_download ?? file.url_private;
1621
- if (!isImageAttachmentMediaType(file.mimetype)) {
1622
- dropped.add(position);
1623
- continue;
1624
- }
1625
- if (url === undefined) {
1626
- drop(position, 'failed');
1627
- continue;
1628
- }
1629
- if (windowClosed) {
1630
- warn(`slack: ${fileLabel(file)} was not taken: an image listed after it already filled what this message's images can carry; the turn is told it cannot be read.`);
1631
- drop(position, 'too-large');
1632
- continue;
1633
- }
1634
- const tooBig = file.size === undefined ? undefined : overLimit(fileLabel(file), file.size);
1635
- if (tooBig !== undefined) {
1636
- warn(tooBig);
1637
- drop(position, 'too-large');
1638
- continue;
1639
- }
1689
+ // One download, checked the same way for an original and for a preview:
1690
+ // the image and its size, or why not. `oversized` marks a refusal for the
1691
+ // per-image limits alone — the only kind a smaller copy can get past.
1692
+ // `mediaType` is Slack's word for an original; a preview's is read from
1693
+ // its own bytes, since Slack's metadata describes only the original.
1694
+ const download = async (url, label, mediaType) => {
1640
1695
  // What this image may weigh: the per-image cap, or what is left of the
1641
1696
  // message's budget if that is less. Handed to the fetcher so an
1642
1697
  // oversized response is abandoned mid-body rather than buffered whole.
1643
1698
  const maxBytes = Math.min(IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES - total);
1644
1699
  if (deadline.aborted) {
1645
- 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.`);
1646
- drop(position, 'timed-out');
1647
- continue;
1700
+ return { reason: 'timed-out', warning: `slack: ${label} was not downloaded: this message's downloads had already taken longer than ${timeoutMs}ms. The turn is told it cannot be read.` };
1648
1701
  }
1649
- let download;
1702
+ let fetched;
1650
1703
  try {
1651
- download = await fetchFile(url, botToken, deadline, maxBytes);
1704
+ fetched = await fetchFile(url, botToken, deadline, maxBytes);
1652
1705
  }
1653
1706
  catch (error) {
1654
- const reason = error instanceof Error && error.name === 'TimeoutError'
1707
+ const timedOut = error instanceof Error && error.name === 'TimeoutError';
1708
+ const cause = timedOut
1655
1709
  ? `this message's downloads took longer than ${timeoutMs}ms`
1656
1710
  : (error instanceof Error ? error.message : String(error));
1657
- warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
1658
- drop(position, error instanceof Error && error.name === 'TimeoutError' ? 'timed-out' : 'failed');
1659
- continue;
1711
+ return { reason: timedOut ? 'timed-out' : 'failed', warning: `slack: could not download ${label}: ${cause}. The turn is told it cannot be read.` };
1660
1712
  }
1661
1713
  // Refused on what Slack sends when it will not serve the file — a
1662
1714
  // non-200, or a 200 whose body is a page — rather than on the label of
1663
1715
  // what it does serve: a download URL may be typed as the image or as
1664
1716
  // a plain octet stream, and either is the bytes Slack said it was.
1665
- const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
1666
- if (download.status !== 200 || contentType.startsWith('text/')) {
1667
- warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than an image. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
1668
- drop(position, 'failed');
1669
- continue;
1717
+ const contentType = fetched.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
1718
+ if (fetched.status !== 200 || contentType.startsWith('text/')) {
1719
+ return { reason: 'failed', warning: `slack: ${label} came back as ${contentType || 'no content type'} (HTTP ${fetched.status}) rather than an image. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.` };
1670
1720
  }
1671
- if (download.truncated === true) {
1721
+ if (fetched.truncated === true) {
1722
+ const why = `${label} is larger than the ${maxBytes} bytes this message could still take for an image; the download was abandoned`;
1672
1723
  // Cut off at what was left of the message's budget, rather than at
1673
1724
  // the per-image cap: the same overflow the size check closes the
1674
1725
  // window on, learned from the bytes instead of the metadata.
1675
1726
  if (maxBytes < IMAGE_ATTACHMENT_MAX_BYTES) {
1676
1727
  windowClosed = true;
1728
+ return { reason: 'too-large', warning: `slack: ${why} and the turn is told it cannot be read.` };
1677
1729
  }
1678
- warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes this message could still take for an image; the download was abandoned and the turn is told it cannot be read.`);
1679
- drop(position, 'too-large');
1680
- continue;
1730
+ return {
1731
+ reason: 'too-large',
1732
+ warning: `slack: ${why} and the turn is told it cannot be read.`,
1733
+ oversized: { why, original: `is over the ${IMAGE_ATTACHMENT_MAX_BYTES / MIB} MB a model can be sent` },
1734
+ };
1681
1735
  }
1682
- const stillTooBig = overLimit(fileLabel(file), download.body.length);
1683
- if (stillTooBig !== undefined) {
1684
- warn(stillTooBig);
1685
- drop(position, 'too-large');
1686
- continue;
1736
+ const tooBig = overLimit(label, fetched.body.length);
1737
+ if (tooBig !== undefined) {
1738
+ return tooBig;
1687
1739
  }
1688
1740
  // Read from the bytes, not from Slack's metadata: an image the model
1689
1741
  // API refuses fails the turn it arrives on and, once stored, every turn
1690
1742
  // after — so what is stored has to be what the API takes.
1691
- const dimensions = imageDimensions(download.body, file.mimetype);
1692
- if (dimensions === undefined) {
1693
- warn(`slack: ${fileLabel(file)} is not a complete ${file.mimetype} — its header or trailer is missing; the turn is told it cannot be read.`);
1694
- drop(position, 'unreadable');
1695
- continue;
1743
+ const type = mediaType ?? imageMediaTypeOf(fetched.body);
1744
+ const dimensions = type === undefined ? undefined : imageDimensions(fetched.body, type);
1745
+ if (type === undefined || dimensions === undefined) {
1746
+ return { reason: 'unreadable', warning: `slack: ${label} is not a complete ${type ?? 'image'} — its header or trailer is missing; the turn is told it cannot be read.` };
1696
1747
  }
1697
1748
  if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
1698
- warn(`slack: ${fileLabel(file)} is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION}-pixel side the model can take; the turn is told it cannot be read.`);
1749
+ const why = `${label} is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION}-pixel side the model can take`;
1750
+ return {
1751
+ reason: 'too-large',
1752
+ warning: `slack: ${why}; the turn is told it cannot be read.`,
1753
+ oversized: { why, original: `is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION} pixels a side a model can be sent` },
1754
+ };
1755
+ }
1756
+ return { body: fetched.body, mediaType: type, ...dimensions };
1757
+ };
1758
+ for (let position = files.length - 1; position >= 0; position -= 1) {
1759
+ const file = files[position];
1760
+ const url = file.url_private_download ?? file.url_private;
1761
+ if (!isImageAttachmentMediaType(file.mimetype)) {
1762
+ dropped.add(position);
1763
+ continue;
1764
+ }
1765
+ if (url === undefined) {
1766
+ drop(position, 'failed');
1767
+ continue;
1768
+ }
1769
+ if (windowClosed) {
1770
+ warn(`slack: ${fileLabel(file)} was not taken: an image listed after it already filled what this message's images can carry; the turn is told it cannot be read.`);
1699
1771
  drop(position, 'too-large');
1700
1772
  continue;
1701
1773
  }
1702
- total += download.body.length;
1774
+ // Slack's own size is trusted before any bytes move: an original it
1775
+ // says is too big is never fetched — only, when that is the per-image
1776
+ // cap, its preview.
1777
+ let result = (file.size === undefined ? undefined : overLimit(fileLabel(file), file.size))
1778
+ ?? await download(url, fileLabel(file), file.mimetype);
1779
+ const previewUrl = 'reason' in result && result.oversized !== undefined ? slackPreviewUrl(file) : undefined;
1780
+ if ('reason' in result && result.oversized !== undefined && previewUrl !== undefined) {
1781
+ const { why, original } = result.oversized;
1782
+ const preview = await download(previewUrl, `Slack's preview of ${fileLabel(file)}`, undefined);
1783
+ if ('reason' in preview) {
1784
+ warn(`slack: ${why}. Slack's smaller preview of it could not be used either: ${preview.warning.replace(/^slack: /, '')}`);
1785
+ drop(position, result.reason);
1786
+ continue;
1787
+ }
1788
+ warn(`slack: ${why}; sent Slack's ${preview.width}×${preview.height} preview of it instead, and the turn is told it is a preview.`);
1789
+ previewed.set(position, { name: fileLabel(file), width: preview.width, height: preview.height, original });
1790
+ result = preview;
1791
+ }
1792
+ if ('reason' in result) {
1793
+ warn(result.warning);
1794
+ drop(position, result.reason);
1795
+ continue;
1796
+ }
1797
+ total += result.body.length;
1703
1798
  const name = file.name ?? file.title;
1704
1799
  kept.set(position, {
1705
- mediaType: file.mimetype,
1706
- data: download.body.toString('base64'),
1800
+ mediaType: result.mediaType,
1801
+ data: result.body.toString('base64'),
1707
1802
  ...(name !== undefined ? { name } : {}),
1708
1803
  });
1709
1804
  }
1710
1805
  const images = [];
1711
1806
  const unread = [];
1807
+ const previews = [];
1712
1808
  files.forEach((file, position) => {
1713
1809
  const image = kept.get(position);
1714
1810
  if (image !== undefined) {
1715
1811
  images.push(image);
1812
+ const preview = previewed.get(position);
1813
+ if (preview !== undefined) {
1814
+ previews.push(preview);
1815
+ }
1716
1816
  }
1717
1817
  else if (dropped.has(position)) {
1718
1818
  unread.push(file);
1719
1819
  }
1720
1820
  });
1721
- return { images, unread };
1821
+ return { images, unread, previews };
1722
1822
  };
1723
1823
  /** The most one text attachment may weigh to be read into the message. */
1724
1824
  const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
@@ -2122,12 +2222,19 @@ const CREDENTIAL_REASON_LIMIT = 300;
2122
2222
  const credentialWhose = (agentName, scope) => scope === 'agent'
2123
2223
  ? `for ${agentName} only`
2124
2224
  : `stored for the whole fleet, granted to ${agentName} (other agents need it in their own soul)`;
2225
+ /**
2226
+ * What answering means when the agent asked for the key in a variable,
2227
+ * said before anything is typed: the approval covers the binding too.
2228
+ * `agentName` arrives escaped; the variable passed the gateway's rule, so
2229
+ * it is letters, digits and underscores.
2230
+ */
2231
+ const credentialShellLine = (agentName, env) => `${agentName}'s shell commands will be able to read this as \`${env}\`, so ${agentName} can see the value.`;
2125
2232
  /**
2126
2233
  * The request as it appears in the conversation. What it says about the
2127
2234
  * value is the promise the form keeps: straight to the credential store,
2128
2235
  * never into the thread, and never over a key already there.
2129
2236
  */
2130
- const credentialRequestBlocks = (agentName, name, scope, reason, requestId, replace = false) => {
2237
+ const credentialRequestBlocks = (agentName, name, scope, reason, requestId, replace = false, env) => {
2131
2238
  const shortReason = reason !== undefined && reason.length > CREDENTIAL_REASON_LIMIT
2132
2239
  ? `${reason.slice(0, CREDENTIAL_REASON_LIMIT)}…`
2133
2240
  : reason;
@@ -2144,6 +2251,9 @@ const credentialRequestBlocks = (agentName, name, scope, reason, requestId, repl
2144
2251
  ...(shortReason !== undefined
2145
2252
  ? [{ type: 'section', text: { type: 'mrkdwn', text: `_${escapeSlackText(agentName)} says:_ ${escapeSlackText(shortReason)}` } }]
2146
2253
  : []),
2254
+ ...(env !== undefined
2255
+ ? [{ type: 'section', text: { type: 'mrkdwn', text: `*${credentialShellLine(escapeSlackText(agentName), escapeSlackText(env))}*` } }]
2256
+ : []),
2147
2257
  {
2148
2258
  type: 'context',
2149
2259
  elements: [{
@@ -2182,11 +2292,12 @@ const credentialModal = (post, requestId) => ({
2182
2292
  type: 'section',
2183
2293
  text: {
2184
2294
  type: 'mrkdwn',
2185
- text: post.replace === true
2295
+ text: (post.replace === true
2186
2296
  ? `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
2187
2297
  + 'The new value replaces the stored one once it saves; it never appears in the conversation.'
2188
2298
  : `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
2189
- + `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation.`,
2299
+ + `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation.`)
2300
+ + (post.env !== undefined ? ` ${credentialShellLine(escapeSlackText(post.agentName), escapeSlackText(post.env))}` : ''),
2190
2301
  },
2191
2302
  },
2192
2303
  {
@@ -2220,6 +2331,26 @@ const credentialModal = (post, requestId) => ({
2220
2331
  */
2221
2332
  /** Uncached author names one `message.read` looks up; the rest keep their id. */
2222
2333
  const READ_NAME_LOOKUPS = 10;
2334
+ /** Slack's longest channel name. */
2335
+ const CHANNEL_NAME_LIMIT = 80;
2336
+ /** Slack's longest channel topic, and its longest purpose. */
2337
+ const CHANNEL_TEXT_LIMIT = 250;
2338
+ /** The most users one `conversations.invite` call takes. */
2339
+ const CHANNEL_INVITE_LIMIT = 1000;
2340
+ /** A bookmark title longer than this is cut, with an ellipsis. */
2341
+ const BOOKMARK_TITLE_LIMIT = 200;
2342
+ /** A bookmark link longer than this is replaced by `BOOKMARK_LINK_TOO_LONG`. */
2343
+ const BOOKMARK_LINK_LIMIT = 2000;
2344
+ const BOOKMARK_LINK_TOO_LONG = '(this link is longer than 2,000 characters, so it is not shown)';
2345
+ /**
2346
+ * The error code of a failed Web API call (`missing_scope`, `ratelimited`),
2347
+ * which `@slack/web-api` puts on the error's `data.error`. Undefined for a
2348
+ * failure that never reached Slack.
2349
+ */
2350
+ const slackErrorCode = (error) => {
2351
+ const code = error?.data?.error;
2352
+ return typeof code === 'string' && code.length > 0 ? code : undefined;
2353
+ };
2223
2354
  /**
2224
2355
  * Orders two Slack message timestamps ("seconds.micros"). Compared as
2225
2356
  * integers, not floats: a current ts has more digits than a double holds.
@@ -3606,14 +3737,76 @@ export const createSlackChannelAdapter = (options) => {
3606
3737
  }
3607
3738
  return kind;
3608
3739
  };
3740
+ /**
3741
+ * The bookmarks a first-page read returns beside the messages. Never
3742
+ * throws: a failure is a sentence for the agent, and the messages come
3743
+ * back either way.
3744
+ */
3745
+ const listBookmarks = async (web, channel) => {
3746
+ if (!web.bookmarks) {
3747
+ return {};
3748
+ }
3749
+ let listed;
3750
+ try {
3751
+ listed = await web.bookmarks.list({ channel_id: channel });
3752
+ }
3753
+ catch (error) {
3754
+ const code = slackErrorCode(error);
3755
+ return {
3756
+ bookmarksUnavailable: code === 'missing_scope'
3757
+ ? 'This Slack app can\'t read bookmarks because it lacks the bookmarks:read scope. Add it to the app\'s manifest and reinstall the app.'
3758
+ : `The channel's bookmarks could not be listed (${code ?? (error instanceof Error ? error.message : String(error))}).`,
3759
+ };
3760
+ }
3761
+ const bookmarks = [];
3762
+ for (const entry of Array.isArray(listed?.bookmarks) ? listed.bookmarks : []) {
3763
+ const link = typeof entry?.link === 'string' ? entry.link.trim() : '';
3764
+ if (!link) {
3765
+ continue;
3766
+ }
3767
+ const title = (typeof entry.title === 'string' && entry.title.trim()) || link;
3768
+ bookmarks.push({
3769
+ title: title.length > BOOKMARK_TITLE_LIMIT ? `${title.slice(0, BOOKMARK_TITLE_LIMIT - 1)}…` : title,
3770
+ // A cut link is a broken one, so an over-long link is not shown.
3771
+ link: link.length > BOOKMARK_LINK_LIMIT ? BOOKMARK_LINK_TOO_LONG : link,
3772
+ });
3773
+ }
3774
+ return bookmarks.length > 0 ? { bookmarks } : {};
3775
+ };
3776
+ /**
3777
+ * The conversation a session lives in, read off its id, for this
3778
+ * agent's connected app only: exactly `slack:<agent>:<this app's
3779
+ * team>:<conversation>`, or that plus one thread `ts`, the keys this
3780
+ * adapter opens sessions under. Anything longer (a delegation's
3781
+ * `:delegate:…`, an archive's `:rolledover:…`) or of another shape (a
3782
+ * schedule's) is no session of a conversation here. No lookup: the id
3783
+ * is the identity check, and only the gateway names it.
3784
+ */
3785
+ const SLACK_TS = /^\d+\.\d+$/;
3786
+ const sessionConversation = (agentId, sessionId) => {
3787
+ const connection = connectionFor(agentId);
3788
+ if (!connection?.teamId) {
3789
+ return undefined;
3790
+ }
3791
+ const prefix = channelSessionKey({ channel: 'slack', agentId, team: connection.teamId, conversation: '' });
3792
+ if (!sessionId.startsWith(prefix)) {
3793
+ return undefined;
3794
+ }
3795
+ const [conversation, thread, ...rest] = sessionId.slice(prefix.length).split(':');
3796
+ if (!conversation || rest.length > 0 || (thread !== undefined && !SLACK_TS.test(thread))) {
3797
+ return undefined;
3798
+ }
3799
+ return conversation;
3800
+ };
3609
3801
  /**
3610
3802
  * The read side of the same boundary `resolveOutbound` draws: a channel
3611
3803
  * the agent's own app is a member of, public or private. DMs and group
3612
- * DMs are refused outright, member or not. Their only members are the
3613
- * people in them, and a read from a conversation elsewhere — a public
3614
- * thread above all — would carry what they said to people who were never
3615
- * in the room. A thread in a channel is fine for the same reason the
3616
- * channel is.
3804
+ * DMs are refused, member or not, except the one the reading session
3805
+ * lives in (`sessionConversation`). Their only members are the people
3806
+ * in them, and a read from a conversation elsewhere — a public thread
3807
+ * above all — would carry what they said to people who were never in
3808
+ * the room; from inside, it is what they already said to this agent. A
3809
+ * thread in a channel is fine for the same reason the channel is.
3617
3810
  */
3618
3811
  const readConversation = async (request) => {
3619
3812
  const channel = request.conversation.trim();
@@ -3637,10 +3830,14 @@ export const createSlackChannelAdapter = (options) => {
3637
3830
  if (!info?.id) {
3638
3831
  throw new Error(`slack: ${request.agentId}'s app cannot see a conversation ${channel}.`);
3639
3832
  }
3640
- if (info.is_im === true || info.is_mpim === true) {
3641
- throw new Error(`slack: ${channel} is a direct message, and direct messages are never read from outside them. Approval cannot unlock this; read a channel instead.`);
3833
+ const direct = info.is_im === true || info.is_mpim === true;
3834
+ if (direct && (request.sessionId === undefined || sessionConversation(request.agentId, request.sessionId) !== info.id)) {
3835
+ throw new Error(`slack: ${channel} is a direct message this conversation is not in, and a direct message is read only from inside it. Approval cannot unlock this; read a channel instead.`);
3642
3836
  }
3643
- if (info.is_member !== true) {
3837
+ // A one-to-one IM states no membership; the session living in it is
3838
+ // the proof. A group DM does say, and an app that left one cannot read
3839
+ // it however its stale session is named.
3840
+ if (info.is_im !== true && info.is_member !== true) {
3644
3841
  throw new Error(`slack: ${request.agentId}'s app is not a member of ${channel} — invite it there before it can read.`);
3645
3842
  }
3646
3843
  const { history, replies } = connection.web.conversations;
@@ -3650,6 +3847,12 @@ export const createSlackChannelAdapter = (options) => {
3650
3847
  if (!fetchPage) {
3651
3848
  throw new Error(`slack: this Slack client cannot read ${request.thread !== undefined ? 'threads' : 'channel history'}.`);
3652
3849
  }
3850
+ // On the first page of the top level only, so paging costs no extra
3851
+ // calls, and only once the checks above have said the read may happen.
3852
+ // Asked beside the history, and settled without throwing.
3853
+ const listing = request.thread === undefined && request.after === undefined && request.before === undefined
3854
+ ? listBookmarks(connection.web, info.id)
3855
+ : undefined;
3653
3856
  const base = {
3654
3857
  channel: info.id,
3655
3858
  inclusive: false,
@@ -3728,7 +3931,159 @@ export const createSlackChannelAdapter = (options) => {
3728
3931
  ...(files.length > 0 ? { files } : {}),
3729
3932
  });
3730
3933
  }
3731
- return { messages, more };
3934
+ return { messages, more, ...(listing ? await listing : {}) };
3935
+ };
3936
+ /**
3937
+ * Creates a channel with the asking agent's own app — `channel.create`.
3938
+ * Everything Slack would refuse once the channel exists is refused here
3939
+ * first, so a bad request leaves no channel behind. After the create
3940
+ * nothing throws: a topic, purpose or invitation that fails is reported,
3941
+ * and the channel stays. Slack names are unique, so an agent that
3942
+ * retries a create gets `name_taken`, never a second channel.
3943
+ */
3944
+ const createConversation = async (request) => {
3945
+ const name = request.name.trim().replace(/^#/, '');
3946
+ if (!name) {
3947
+ throw new Error('slack: a channel needs a name.');
3948
+ }
3949
+ if (!/^[a-z0-9_-]+$/.test(name)) {
3950
+ throw new Error(`slack: "${name}" can't be a channel name: Slack names use only lowercase letters, numbers, hyphens and underscores. Nothing was created.`);
3951
+ }
3952
+ if (name.length > CHANNEL_NAME_LIMIT) {
3953
+ throw new Error(`slack: a channel name is at most ${CHANNEL_NAME_LIMIT} characters; this one is ${name.length}. Nothing was created.`);
3954
+ }
3955
+ for (const field of ['topic', 'purpose']) {
3956
+ const length = [...(request[field] ?? '')].length;
3957
+ if (length > CHANNEL_TEXT_LIMIT) {
3958
+ throw new Error(`slack: the ${field} is ${length} characters; Slack allows ${CHANNEL_TEXT_LIMIT}. Nothing was created.`);
3959
+ }
3960
+ }
3961
+ const invite = request.invite ?? [];
3962
+ if (invite.length > CHANNEL_INVITE_LIMIT) {
3963
+ throw new Error(`slack: that invites ${invite.length.toLocaleString('en-US')} people; Slack invites at most ${CHANNEL_INVITE_LIMIT.toLocaleString('en-US')} at once. Nothing was created.`);
3964
+ }
3965
+ const connection = connectionFor(request.agentId);
3966
+ if (!connection) {
3967
+ throw new Error(configuredAgents.has(request.agentId)
3968
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot create a channel.`
3969
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot create a Slack channel.`);
3970
+ }
3971
+ const { create, setTopic, setPurpose, invite: inviteUsers } = connection.web.conversations;
3972
+ if (!create || !setTopic || !setPurpose || !inviteUsers) {
3973
+ throw new Error('slack: this Slack client cannot create channels.');
3974
+ }
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 = [];
3978
+ const notInvited = [];
3979
+ const toInvite = new Map();
3980
+ for (const who of invite) {
3981
+ const identity = botIdentities.get(who);
3982
+ if (who === request.agentId || identity?.botUserId === connection.botUserId) {
3983
+ invited.push(who);
3984
+ }
3985
+ else if (configuredAgents.has(who) && !identity?.botUserId) {
3986
+ notInvited.push({ who, reason: `Agent ${who}'s Slack app is not connected, so its Slack user is unknown.` });
3987
+ }
3988
+ else if (identity) {
3989
+ if (identity.teamId !== connection.teamId) {
3990
+ notInvited.push({ who, reason: `Agent ${who}'s Slack app is in another Slack workspace.` });
3991
+ }
3992
+ else {
3993
+ toInvite.set(who, identity.botUserId);
3994
+ }
3995
+ }
3996
+ else if (/^[UW][A-Z0-9]+$/.test(who)) {
3997
+ toInvite.set(who, who);
3998
+ }
3999
+ else {
4000
+ notInvited.push({ who, reason: 'Not a Slack user id (U… or W…) or an agent with a Slack app on this host.' });
4001
+ }
4002
+ }
4003
+ const kind = request.private === true ? 'private' : 'public';
4004
+ let created;
4005
+ try {
4006
+ created = await create({ name, is_private: request.private === true });
4007
+ }
4008
+ catch (error) {
4009
+ 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.`);
4015
+ }
4016
+ throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
4017
+ }
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;
4038
+ try {
4039
+ refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
4040
+ }
4041
+ 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
+ }
4065
+ }
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
+ }
4080
+ }
4081
+ }
4082
+ const order = new Map(invite.map((who, index) => [who, index]));
4083
+ const byRequest = (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0);
4084
+ invited.sort(byRequest);
4085
+ notInvited.sort((left, right) => byRequest(left.who, right.who));
4086
+ return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
3732
4087
  };
3733
4088
  /**
3734
4089
  * Renders one parked call as buttons and remembers where it went.
@@ -4129,7 +4484,9 @@ export const createSlackChannelAdapter = (options) => {
4129
4484
  return { said: true, landed: await uploadUnrenderedFiles(connection, channel, thread, files) };
4130
4485
  }
4131
4486
  let landed = [];
4132
- const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy === undefined ? `Something went wrong: ${event.error}` : `Stopped by <@${stoppedBy}>.`), async () => {
4487
+ const said = await postAheadOf(behind, connection, channel, thread, messageChunks(stoppedBy !== undefined
4488
+ ? `Stopped by <@${stoppedBy}>.`
4489
+ : event.refused ? event.error : `Something went wrong: ${event.error}`), async () => {
4133
4490
  landed = await uploadUnrenderedFiles(connection, channel, thread, files);
4134
4491
  });
4135
4492
  return { said, landed };
@@ -4287,7 +4644,7 @@ export const createSlackChannelAdapter = (options) => {
4287
4644
  channel,
4288
4645
  text: escapeSlackText(`${agentName} is asking ${request.replace === true ? 'to replace' : 'for'} a credential: ${request.name}, ${credentialWhose(agentName, request.scope)}.`),
4289
4646
  ...(thread ? { thread_ts: thread } : {}),
4290
- blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId, request.replace === true),
4647
+ blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId, request.replace === true, request.env),
4291
4648
  });
4292
4649
  }
4293
4650
  catch (error) {
@@ -4309,6 +4666,7 @@ export const createSlackChannelAdapter = (options) => {
4309
4666
  name: request.name,
4310
4667
  scope: request.scope,
4311
4668
  ...(request.replace === true ? { replace: true } : {}),
4669
+ ...(request.env !== undefined ? { env: request.env } : {}),
4312
4670
  approvers,
4313
4671
  });
4314
4672
  };
@@ -4322,14 +4680,17 @@ export const createSlackChannelAdapter = (options) => {
4322
4680
  ? `for ${escapeSlackText(post.agentName)} only`
4323
4681
  : `as a shared credential, granted to ${escapeSlackText(post.agentName)}`;
4324
4682
  const by = event.actor !== undefined && post.approvers.has(event.actor) ? `, by <@${event.actor}>` : '';
4325
- const text = event.replace === true
4683
+ const shell = post.env !== undefined && event.grantError === undefined
4684
+ ? ` Its shell commands read it as \`${escapeSlackText(post.env)}\`.`
4685
+ : '';
4686
+ const text = (event.replace === true
4326
4687
  ? `Replaced \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}. `
4327
4688
  + `${escapeSlackText(post.agentName)} uses the new value from its next reply; it is in the credential store, not here.`
4328
4689
  : event.grantError === undefined
4329
4690
  ? `Added \`${escapeSlackText(post.name)}\` ${whose}${by}. ${escapeSlackText(post.agentName)} can use it from its next reply; the value is in the credential store, not here.`
4330
4691
  : `Stored \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}, `
4331
4692
  + `but it could not be added to ${escapeSlackText(post.agentName)}'s soul (${escapeSlackText(event.grantError)}), so ${escapeSlackText(post.agentName)} cannot use it yet. `
4332
- + 'Add the name to the credentials list in its soul on the machine.';
4693
+ + 'Add the name to the credentials list in its soul on the machine.') + shell;
4333
4694
  try {
4334
4695
  await post.connection.web.chat.update({ channel: post.channel, ts: post.ts, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] });
4335
4696
  }
@@ -6017,8 +6378,8 @@ export const createSlackChannelAdapter = (options) => {
6017
6378
  reasons.set(file, 'not-opened');
6018
6379
  }
6019
6380
  }
6020
- const { images, unread: notImages } = overhear
6021
- ? { images: [], unread: event.files ?? [] }
6381
+ const { images, unread: notImages, previews } = overhear
6382
+ ? { images: [], unread: event.files ?? [], previews: [] }
6022
6383
  : await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, reasons, deadline);
6023
6384
  const { texts, saved, unread } = overhear
6024
6385
  ? { texts: [], saved: [], unread: notImages }
@@ -6042,7 +6403,7 @@ export const createSlackChannelAdapter = (options) => {
6042
6403
  // In shared channels the model should know who is speaking; a DM is
6043
6404
  // unambiguous. A bare image in a channel still says who sent it.
6044
6405
  const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
6045
- const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${attachmentNote(unread, reasons)}`;
6406
+ const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${previewNote(previews)}${attachmentNote(unread, reasons)}`;
6046
6407
  // Who sent this, judged against the operator's list — per message,
6047
6408
  // never remembered from the first one in the thread. A DM proves
6048
6409
  // nothing about who is typing, so a DM from an unlisted member is
@@ -6442,7 +6803,7 @@ export const createSlackChannelAdapter = (options) => {
6442
6803
  ? await renderer.stopped(renderer.stoppedBy)
6443
6804
  : refused
6444
6805
  ? await renderer.finalize(renderer.lazy ? '' : deferredText ?? failure.message)
6445
- : await renderer.fail(failure instanceof Error ? failure.message : String(failure));
6806
+ : await renderer.fail(failure instanceof Error ? failure.message : String(failure), failure instanceof HostRefusalError);
6446
6807
  const { spoke, spokeAt } = refused && renderer.stoppedBy === undefined
6447
6808
  ? outcome
6448
6809
  : noteReplayOutcome(connection, event.channel, event.ts, outcome);
@@ -6459,12 +6820,15 @@ export const createSlackChannelAdapter = (options) => {
6459
6820
  name: 'slack',
6460
6821
  resolveOutbound,
6461
6822
  readConversation,
6823
+ sessionConversation,
6824
+ createConversation,
6462
6825
  // A channel id is unique only within its workspace, so a standing
6463
6826
  // grant on one is bound to the team the agent's app authenticated in.
6464
6827
  accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
6465
6828
  conversationKindFor,
6466
6829
  requestCredential,
6467
6830
  credentialReplaceForms: true,
6831
+ credentialEnvForms: true,
6468
6832
  async start(gateway) {
6469
6833
  gatewayRef = gateway;
6470
6834
  // Nothing counts as seen live until every socket is up (Codex