@stratusagent/channel-slack 0.13.7 → 0.14.1

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,7 +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, 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
8
  /**
9
9
  * The longest display name the model is shown. Slack caps profile names
@@ -110,6 +110,12 @@ const STATUS_REFRESH_MS = 60_000;
110
110
  * long.
111
111
  */
112
112
  const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
113
+ /**
114
+ * How long `stop()` waits on a socket that is still starting (card 935).
115
+ * Well inside the gateway's shutdown window, so a socket stuck connecting
116
+ * is reported by name rather than as the whole window running out.
117
+ */
118
+ const DEFAULT_STOP_WAIT_MS = 10_000;
113
119
  /** What a turn that produced no text puts in its message, wherever it is posted from. */
114
120
  const NO_REPLY_TEXT = '(no reply)';
115
121
  /**
@@ -266,6 +272,40 @@ export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signa
266
272
  return { status: response.status, ...typed, body: Buffer.concat(chunks) };
267
273
  };
268
274
  const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
275
+ /**
276
+ * The one origin a file URL may name for the bot token to go with it.
277
+ * `url_private` does not by itself mean Slack hosts the file: for a remote
278
+ * file (an app's pointer to a file it hosts), Slack returns the app's own
279
+ * URL there, on whatever host the app chose, so any app with
280
+ * `remote_files:write` could collect the token of every agent that opened
281
+ * its file. Slack's rule is never to send Slack credentials to such a URL,
282
+ * and to compare the parsed host exactly rather than a prefix
283
+ * (docs.slack.dev, Working with files: Downloading files). Previews and
284
+ * downloads both live here. A redirect elsewhere is not a leak either:
285
+ * fetch drops `authorization` on a cross-origin redirect.
286
+ */
287
+ const SLACK_FILE_ORIGIN = 'https://files.slack.com';
288
+ /**
289
+ * A fetcher that is only ever handed URLs on Slack's file host. Anything
290
+ * else is refused before a request is made, with a reason that names the
291
+ * origin, and the callers' own handling turns that into a name-only file
292
+ * and an operator warning, as for any download that failed.
293
+ */
294
+ const onlySlackFileHost = (fetchFile) => (url, botToken, signal, maxBytes) => {
295
+ let origin;
296
+ try {
297
+ const parsed = new URL(url);
298
+ // Credentials in the URL would be sent with the token: not Slack's.
299
+ origin = parsed.username === '' && parsed.password === '' ? parsed.origin : `${parsed.protocol}//(credentials)@${parsed.host}`;
300
+ }
301
+ catch {
302
+ origin = undefined;
303
+ }
304
+ if (origin !== SLACK_FILE_ORIGIN) {
305
+ 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`));
306
+ }
307
+ return fetchFile(url, botToken, signal, maxBytes);
308
+ };
269
309
  /**
270
310
  * The dispatch's idempotency key for a message's dedupe key: the key itself
271
311
  * when it fits the gateway's bound, and a digest of it when it does not —
@@ -1629,7 +1669,9 @@ const MIB = 1024 * 1024;
1629
1669
  * a 1024-pixel preview of a 14 MB screenshot as the screenshot, and states
1630
1670
  * what the small text says when it could only guess at it.
1631
1671
  */
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('');
1672
+ const previewNote = (previews) => previews.map((preview) => `\n[${previewSentence(preview)}]`).join('');
1673
+ /** One preview, said the same way to a turn and to a read of history. */
1674
+ 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
1675
  /**
1634
1676
  * A message's attachments, sorted into the images the model will be shown
1635
1677
  * and everything else. An image is fetched only when Slack's own metadata
@@ -1820,6 +1862,102 @@ const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn,
1820
1862
  });
1821
1863
  return { images, unread, previews };
1822
1864
  };
1865
+ /** A read's byte cap, said in the unit it was most likely set in. */
1866
+ const readCapLabel = (bytes) => (bytes % MIB === 0 ? `${bytes / MIB} MB` : `${bytes} bytes`);
1867
+ /**
1868
+ * The image cap a read asked for, held to what one request can carry, or
1869
+ * undefined when it asked for none: then files come back by name only.
1870
+ */
1871
+ const historyImageCap = (images) => {
1872
+ if (images === undefined || !Number.isInteger(images.count) || images.count <= 0 || !Number.isFinite(images.bytes) || images.bytes < 1) {
1873
+ return undefined;
1874
+ }
1875
+ return {
1876
+ count: Math.min(images.count, IMAGE_ATTACHMENTS_MAX_REPLAY_COUNT),
1877
+ bytes: Math.min(Math.floor(images.bytes), IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES),
1878
+ };
1879
+ };
1880
+ /** What a read is told about an image it tried to fetch and could not show. */
1881
+ const unshownImageNote = (label, reason) => {
1882
+ switch (reason) {
1883
+ case 'too-large':
1884
+ 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.`;
1885
+ case 'timed-out':
1886
+ return `${label} is not shown: it was not downloaded in time.`;
1887
+ case 'unreadable':
1888
+ return `${label} is not shown: its bytes are not a complete image.`;
1889
+ default:
1890
+ return `${label} is not shown: it could not be downloaded.`;
1891
+ }
1892
+ };
1893
+ /**
1894
+ * The images on the messages a read returns, fetched for a read that asks
1895
+ * (`ConversationReadRequest.images`) and set on each message with a note
1896
+ * for every image file it does not show. Each image goes through the same
1897
+ * reader a turn's attachments do — Slack's metadata first, the bytes
1898
+ * checked, Slack's preview for one over the per-image limits, and only
1899
+ * from Slack's own file host — one file at a time, in the order the
1900
+ * messages are returned. The read's cap is spent in that order, and once
1901
+ * an image does not fit, none after it is taken, so what is shown is one
1902
+ * unbroken stretch of the read rather than whichever small ones squeezed
1903
+ * in. One deadline covers every download in the read.
1904
+ */
1905
+ const readHistoryImages = async (sources, cap, botToken, fetchFile, timeoutMs, warn) => {
1906
+ const deadline = AbortSignal.timeout(timeoutMs);
1907
+ const pastBytes = `past this read's cap of ${readCapLabel(cap.bytes)} of images`;
1908
+ let count = 0;
1909
+ let remaining = cap.bytes;
1910
+ let full;
1911
+ for (const { message, files } of sources) {
1912
+ const images = [];
1913
+ const notes = [];
1914
+ for (const file of files) {
1915
+ const label = fileLabel(file);
1916
+ if (!isImageAttachmentMediaType(file.mimetype)) {
1917
+ if (file.mimetype?.startsWith('image/') === true) {
1918
+ notes.push(`${label} is not shown: it is ${file.mimetype}, a kind of image a model cannot be shown.`);
1919
+ }
1920
+ continue;
1921
+ }
1922
+ if (full === undefined && count >= cap.count) {
1923
+ full = `past this read's cap of ${cap.count} images`;
1924
+ }
1925
+ // Slack's own size, trusted before any bytes move. An original over
1926
+ // the per-image limit may still go as its preview, whose size only
1927
+ // its bytes can say.
1928
+ if (full === undefined && file.size !== undefined && file.size <= IMAGE_ATTACHMENT_MAX_BYTES && file.size > remaining) {
1929
+ full = pastBytes;
1930
+ }
1931
+ if (full !== undefined) {
1932
+ notes.push(`${label} is not shown: ${full}.`);
1933
+ continue;
1934
+ }
1935
+ const reasons = new Map();
1936
+ const read = await readImageAttachments([file], botToken, fetchFile, timeoutMs, warn, reasons, deadline);
1937
+ const image = read.images[0];
1938
+ if (image === undefined) {
1939
+ notes.push(unshownImageNote(label, reasons.get(file)));
1940
+ continue;
1941
+ }
1942
+ const size = Buffer.byteLength(image.data, 'base64');
1943
+ if (size > remaining) {
1944
+ full = pastBytes;
1945
+ notes.push(`${label} is not shown: ${full}.`);
1946
+ continue;
1947
+ }
1948
+ remaining -= size;
1949
+ count += 1;
1950
+ images.push(image);
1951
+ notes.push(...read.previews.map(previewSentence));
1952
+ }
1953
+ if (images.length > 0) {
1954
+ message.images = images;
1955
+ }
1956
+ if (notes.length > 0) {
1957
+ message.imageNotes = notes;
1958
+ }
1959
+ }
1960
+ };
1823
1961
  /** The most one text attachment may weigh to be read into the message. */
1824
1962
  const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
1825
1963
  /**
@@ -2222,12 +2360,19 @@ const CREDENTIAL_REASON_LIMIT = 300;
2222
2360
  const credentialWhose = (agentName, scope) => scope === 'agent'
2223
2361
  ? `for ${agentName} only`
2224
2362
  : `stored for the whole fleet, granted to ${agentName} (other agents need it in their own soul)`;
2363
+ /**
2364
+ * What answering means when the agent asked for the key in a variable,
2365
+ * said before anything is typed: the approval covers the binding too.
2366
+ * `agentName` arrives escaped; the variable passed the gateway's rule, so
2367
+ * it is letters, digits and underscores.
2368
+ */
2369
+ const credentialShellLine = (agentName, env) => `${agentName}'s shell commands will be able to read this as \`${env}\`, so ${agentName} can see the value.`;
2225
2370
  /**
2226
2371
  * The request as it appears in the conversation. What it says about the
2227
2372
  * value is the promise the form keeps: straight to the credential store,
2228
2373
  * never into the thread, and never over a key already there.
2229
2374
  */
2230
- const credentialRequestBlocks = (agentName, name, scope, reason, requestId, replace = false) => {
2375
+ const credentialRequestBlocks = (agentName, name, scope, reason, requestId, replace = false, env) => {
2231
2376
  const shortReason = reason !== undefined && reason.length > CREDENTIAL_REASON_LIMIT
2232
2377
  ? `${reason.slice(0, CREDENTIAL_REASON_LIMIT)}…`
2233
2378
  : reason;
@@ -2244,6 +2389,9 @@ const credentialRequestBlocks = (agentName, name, scope, reason, requestId, repl
2244
2389
  ...(shortReason !== undefined
2245
2390
  ? [{ type: 'section', text: { type: 'mrkdwn', text: `_${escapeSlackText(agentName)} says:_ ${escapeSlackText(shortReason)}` } }]
2246
2391
  : []),
2392
+ ...(env !== undefined
2393
+ ? [{ type: 'section', text: { type: 'mrkdwn', text: `*${credentialShellLine(escapeSlackText(agentName), escapeSlackText(env))}*` } }]
2394
+ : []),
2247
2395
  {
2248
2396
  type: 'context',
2249
2397
  elements: [{
@@ -2282,11 +2430,12 @@ const credentialModal = (post, requestId) => ({
2282
2430
  type: 'section',
2283
2431
  text: {
2284
2432
  type: 'mrkdwn',
2285
- text: post.replace === true
2433
+ text: (post.replace === true
2286
2434
  ? `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
2287
2435
  + 'The new value replaces the stored one once it saves; it never appears in the conversation.'
2288
2436
  : `\`${escapeSlackText(post.name)}\`, ${credentialWhose(escapeSlackText(post.agentName), post.scope)}. `
2289
- + `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation.`,
2437
+ + `It is stored add-only and granted to ${escapeSlackText(post.agentName)}; it never appears in the conversation.`)
2438
+ + (post.env !== undefined ? ` ${credentialShellLine(escapeSlackText(post.agentName), escapeSlackText(post.env))}` : ''),
2290
2439
  },
2291
2440
  },
2292
2441
  {
@@ -2340,6 +2489,33 @@ const slackErrorCode = (error) => {
2340
2489
  const code = error?.data?.error;
2341
2490
  return typeof code === 'string' && code.length > 0 ? code : undefined;
2342
2491
  };
2492
+ /** Slack's code for a failed call, or the SDK's message when Slack never answered. */
2493
+ const describeSlackError = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
2494
+ /** Codes Slack answers when the token itself is refused. */
2495
+ const SLACK_AUTH_ERRORS = new Set(['not_authed', 'invalid_auth', 'account_inactive', 'token_revoked', 'token_expired']);
2496
+ /**
2497
+ * Codes with which Slack refuses a whole `conversations.invite` without
2498
+ * inviting anyone, so `inviteToConversation` can reject and the members
2499
+ * are known unchanged. Everything else, per-person codes and failures that
2500
+ * may have partly worked included, is settled per entry instead.
2501
+ */
2502
+ const SLACK_INVITE_REFUSALS = new Set([
2503
+ 'not_in_channel',
2504
+ 'missing_scope',
2505
+ 'is_archived',
2506
+ 'channel_not_found',
2507
+ 'method_not_supported_for_channel_type',
2508
+ 'no_user',
2509
+ 'ratelimited',
2510
+ ...SLACK_AUTH_ERRORS,
2511
+ ]);
2512
+ /**
2513
+ * What `channel_not_found` means to an app: Slack says the same for a
2514
+ * wrong id and for a private channel it can't see, and so does
2515
+ * `conversations.info`, so the sentence names both.
2516
+ */
2517
+ 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). `
2518
+ + 'If the id is right, the channel is private, and a member has to invite the app.';
2343
2519
  /**
2344
2520
  * Orders two Slack message timestamps ("seconds.micros"). Compared as
2345
2521
  * integers, not floats: a current ts has more digits than a double holds.
@@ -2366,13 +2542,17 @@ export const createSlackChannelAdapter = (options) => {
2366
2542
  const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
2367
2543
  const createSocket = options.createSocketClient ?? defaultSocketClient;
2368
2544
  const createWeb = options.createWebClient ?? defaultWebClient;
2369
- const fetchFile = options.fetchFile ?? defaultFetchFile;
2545
+ const fetchFile = onlySlackFileHost(options.fetchFile ?? defaultFetchFile);
2370
2546
  const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
2547
+ const stopWaitMs = options.stopWaitMs ?? DEFAULT_STOP_WAIT_MS;
2371
2548
  const now = options.now ?? Date.now;
2372
2549
  const connections = [];
2550
+ // The agents this adapter carries: `options.agents` until a reload reads
2551
+ // another list (`options.loadAgents`).
2552
+ let carried = options.agents;
2373
2553
  // Every agent this adapter was asked to carry, connected or not — the
2374
2554
  // difference between "mine and broken" and "not mine".
2375
- const configuredAgents = new Set(options.agents.map((agent) => agent.agentId));
2555
+ const configuredAgents = new Set(carried.map((agent) => agent.agentId));
2376
2556
  // Renderers queue per session: two messages in one thread share a
2377
2557
  // session id, and the gateway serializes their turns — so events route
2378
2558
  // to the FIRST registered renderer (the running turn), never a later
@@ -2566,8 +2746,9 @@ export const createSlackChannelAdapter = (options) => {
2566
2746
  * is the correct outcome there, and it is also the visible one — the same
2567
2747
  * thing a mention has always done when an app is down.
2568
2748
  *
2569
- * Insertion order is `options.agents` order, which is what makes
2570
- * `agentNamedIn` deterministic across every connection.
2749
+ * Insertion order is the carried agents' order (`options.agents`, or the
2750
+ * list a reload read), which is what makes `agentNamedIn` deterministic
2751
+ * across every connection.
2571
2752
  */
2572
2753
  const botIdentities = new Map();
2573
2754
  // Rendered approval requests, keyed by the gateway's request id — the
@@ -2596,6 +2777,16 @@ export const createSlackChannelAdapter = (options) => {
2596
2777
  const resolvedWhileRendering = new Map();
2597
2778
  let unsubscribe;
2598
2779
  let gatewayRef;
2780
+ // Set as `stop()` begins, so a reload still connecting an app leaves
2781
+ // nothing up behind the stop.
2782
+ let stopRequested = false;
2783
+ // Sockets between `start()` and `connections`, by the agent they serve
2784
+ // (card 935). A stop can't see them to close them: `connect` closes each
2785
+ // once its start returns, and the stop waits for that, bounded.
2786
+ const socketsStarting = new Map();
2787
+ // Agents whose socket `connect` could not close for a stop: up still,
2788
+ // which the stop has to report.
2789
+ const leftUpAtStop = new Set();
2599
2790
  // Everything the adapter owes Slack goes through here, so stop() drains
2600
2791
  // it: an approval question or its retraction must reach the workspace
2601
2792
  // before the sockets close, or a message keeps offering buttons that
@@ -3350,7 +3541,7 @@ export const createSlackChannelAdapter = (options) => {
3350
3541
  };
3351
3542
  const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
3352
3543
  /** An agent's configuration, whether or not its socket came up. */
3353
- const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
3544
+ const agentConfigFor = (agentId) => carried.find((candidate) => candidate.agentId === agentId);
3354
3545
  /**
3355
3546
  * Which agent each home channel belongs to, keyed `team:channel` because
3356
3547
  * a channel id is unique only within its workspace. Settled at start,
@@ -3366,7 +3557,7 @@ export const createSlackChannelAdapter = (options) => {
3366
3557
  const settleHomeOwners = () => {
3367
3558
  homeOwners.clear();
3368
3559
  const claims = new Map();
3369
- for (const agent of options.agents) {
3560
+ for (const agent of carried) {
3370
3561
  const teamId = botIdentities.get(agent.agentId)?.teamId;
3371
3562
  if (teamId === undefined) {
3372
3563
  continue;
@@ -3897,6 +4088,7 @@ export const createSlackChannelAdapter = (options) => {
3897
4088
  names.set(user, await name);
3898
4089
  }
3899
4090
  const messages = [];
4091
+ const sources = [];
3900
4092
  for (const message of raw) {
3901
4093
  if (!message.ts) {
3902
4094
  continue;
@@ -3909,7 +4101,7 @@ export const createSlackChannelAdapter = (options) => {
3909
4101
  || (message.user ? names.get(message.user) : undefined);
3910
4102
  const at = Number.parseFloat(message.ts);
3911
4103
  const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
3912
- messages.push({
4104
+ const read = {
3913
4105
  id: message.ts,
3914
4106
  author,
3915
4107
  text: plainSlackText(connection, message.text ?? '', names),
@@ -3918,7 +4110,15 @@ export const createSlackChannelAdapter = (options) => {
3918
4110
  ...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
3919
4111
  ...(message.reply_count ? { replies: message.reply_count } : {}),
3920
4112
  ...(files.length > 0 ? { files } : {}),
3921
- });
4113
+ };
4114
+ messages.push(read);
4115
+ if (message.files !== undefined && message.files.length > 0) {
4116
+ sources.push({ message: read, files: message.files });
4117
+ }
4118
+ }
4119
+ const imageCap = historyImageCap(request.images);
4120
+ if (imageCap !== undefined) {
4121
+ await readHistoryImages(sources, imageCap, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
3922
4122
  }
3923
4123
  return { messages, more, ...(listing ? await listing : {}) };
3924
4124
  };
@@ -3961,15 +4161,60 @@ export const createSlackChannelAdapter = (options) => {
3961
4161
  if (!create || !setTopic || !setPurpose || !inviteUsers) {
3962
4162
  throw new Error('slack: this Slack client cannot create channels.');
3963
4163
  }
3964
- // Who each entry is, before anything exists: agents on this host become
3965
- // the user their app posts as, in the same workspace only.
3966
- const invited = [];
4164
+ // Who each entry is, before anything exists. The creating app is a
4165
+ // member already, so naming it counts as invited.
4166
+ const { own, notInvited, toInvite } = resolveInvitees(connection, request.agentId, invite);
4167
+ const invited = [...own];
4168
+ const kind = request.private === true ? 'private' : 'public';
4169
+ let created;
4170
+ try {
4171
+ created = await create({ name, is_private: request.private === true });
4172
+ }
4173
+ catch (error) {
4174
+ const code = slackErrorCode(error);
4175
+ if (code === 'missing_scope') {
4176
+ 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.`);
4177
+ }
4178
+ if (code === 'name_taken') {
4179
+ throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
4180
+ }
4181
+ throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
4182
+ }
4183
+ const conversation = created.channel?.id;
4184
+ if (!conversation) {
4185
+ 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.`);
4186
+ }
4187
+ const notSet = [];
4188
+ if (request.topic) {
4189
+ await setTopic({ channel: conversation, topic: request.topic })
4190
+ .catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describeSlackError(error)}).` }));
4191
+ }
4192
+ if (request.purpose) {
4193
+ await setPurpose({ channel: conversation, purpose: request.purpose })
4194
+ .catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describeSlackError(error)}).` }));
4195
+ }
4196
+ // The channel exists now, so every code is settled per entry and none
4197
+ // turns into a rejection the agent would retry into a second channel.
4198
+ const settled = await inviteAtSlack(connection, inviteUsers, conversation, toInvite);
4199
+ invited.push(...settled.invited);
4200
+ notInvited.push(...settled.notInvited);
4201
+ sortByRequest(invite, invited, notInvited);
4202
+ return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
4203
+ };
4204
+ /**
4205
+ * Who each invite entry is, before Slack is asked: agents on this host
4206
+ * become the user their app posts as, in the same workspace only. `own`
4207
+ * holds the entries that are the asking app itself (the agent, or one
4208
+ * sharing its bot user), which no invitation can add.
4209
+ */
4210
+ const resolveInvitees = (connection, agentId, invite) => {
4211
+ const own = [];
3967
4212
  const notInvited = [];
3968
4213
  const toInvite = new Map();
3969
4214
  for (const who of invite) {
3970
4215
  const identity = botIdentities.get(who);
3971
- if (who === request.agentId || identity?.botUserId === connection.botUserId) {
3972
- invited.push(who);
4216
+ if (who === agentId || identity?.botUserId === connection.botUserId) {
4217
+ own.push(who);
3973
4218
  }
3974
4219
  else if (configuredAgents.has(who) && !identity?.botUserId) {
3975
4220
  notInvited.push({ who, reason: `Agent ${who}'s Slack app is not connected, so its Slack user is unknown.` });
@@ -3989,90 +4234,316 @@ export const createSlackChannelAdapter = (options) => {
3989
4234
  notInvited.push({ who, reason: 'Not a Slack user id (U… or W…) or an agent with a Slack app on this host.' });
3990
4235
  }
3991
4236
  }
3992
- const kind = request.private === true ? 'private' : 'public';
3993
- let created;
4237
+ return { own, notInvited, toInvite };
4238
+ };
4239
+ /**
4240
+ * Sends one `conversations.invite`, force on so a bad id cannot stop the
4241
+ * good ones, and settles each entry of `toInvite` (entry -> Slack user).
4242
+ * Slack's per-id failures name who was refused, and `already_in_channel`
4243
+ * counts as invited. A failed call that does not name everyone leaves
4244
+ * the rest to the channel's members, and who cannot be checked is never
4245
+ * claimed. A call Slack refused with a code `refusesWhole` accepts stops
4246
+ * there, before any members check, with nobody settled and the code in
4247
+ * `refusedWith` for the caller to reject on. Without `refusesWhole`
4248
+ * every code is settled per entry.
4249
+ */
4250
+ const inviteAtSlack = async (connection, inviteUsers, conversation, toInvite, refusesWhole = () => false) => {
4251
+ const invited = [];
4252
+ const notInvited = [];
4253
+ if (toInvite.size === 0) {
4254
+ return { invited, notInvited };
4255
+ }
4256
+ // Slack's per-id failures, by user id: on the answer, or on the error
4257
+ // the SDK throws for an ok: false answer.
4258
+ const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
4259
+ let refused;
4260
+ let failure;
3994
4261
  try {
3995
- created = await create({ name, is_private: request.private === true });
4262
+ refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
3996
4263
  }
3997
4264
  catch (error) {
3998
4265
  const code = slackErrorCode(error);
3999
- if (code === 'missing_scope') {
4000
- 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.`);
4001
- }
4002
- if (code === 'name_taken') {
4003
- throw new Error(`slack: there is already a channel named #${name} in this workspace, perhaps archived. Pick another name, or use that channel.`);
4266
+ if (code !== undefined && refusesWhole(code)) {
4267
+ return { invited, notInvited, refusedWith: code };
4004
4268
  }
4005
- throw new Error(`slack: Slack could not create #${name} (${code ?? (error instanceof Error ? error.message : String(error))}).`);
4269
+ refused = refusedOf(error?.data?.errors);
4270
+ failure = describeSlackError(error);
4006
4271
  }
4007
- const conversation = created.channel?.id;
4008
- if (!conversation) {
4009
- 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.`);
4010
- }
4011
- const notSet = [];
4012
- const describe = (error) => slackErrorCode(error) ?? (error instanceof Error ? error.message : String(error));
4013
- if (request.topic) {
4014
- await setTopic({ channel: conversation, topic: request.topic })
4015
- .catch((error) => notSet.push({ field: 'topic', reason: `Slack did not set the topic (${describe(error)}).` }));
4016
- }
4017
- if (request.purpose) {
4018
- await setPurpose({ channel: conversation, purpose: request.purpose })
4019
- .catch((error) => notSet.push({ field: 'purpose', reason: `Slack did not set the purpose (${describe(error)}).` }));
4020
- }
4021
- if (toInvite.size > 0) {
4022
- // Slack's per-id failures, by user id: on the answer, or on the error
4023
- // the SDK throws for an ok: false answer.
4024
- const refusedOf = (errors) => new Map((Array.isArray(errors) ? errors : []).flatMap((entry) => typeof entry?.user === 'string' && typeof entry.error === 'string' ? [[entry.user, entry.error]] : []));
4025
- let refused;
4026
- let failure;
4272
+ // A failed call that does not name everyone leaves the rest unknown,
4273
+ // so the channel's members decide; who cannot be checked is not claimed.
4274
+ let members;
4275
+ let membersFailure;
4276
+ if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
4027
4277
  try {
4028
- refused = refusedOf((await inviteUsers({ channel: conversation, users: [...new Set(toInvite.values())].join(','), force: true })).errors);
4278
+ members = new Set();
4279
+ let cursor;
4280
+ do {
4281
+ const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
4282
+ for (const member of page.members ?? []) {
4283
+ members.add(member);
4284
+ }
4285
+ cursor = page.response_metadata?.next_cursor || undefined;
4286
+ } while (cursor);
4029
4287
  }
4030
4288
  catch (error) {
4031
- refused = refusedOf(error?.data?.errors);
4032
- failure = describe(error);
4033
- }
4034
- // A failed call that does not name everyone leaves the rest unknown,
4035
- // so the channel's members decide; who cannot be checked is not claimed.
4036
- let members;
4037
- let membersFailure;
4038
- if (failure !== undefined && [...toInvite.values()].some((user) => !refused.has(user))) {
4039
- try {
4040
- members = new Set();
4041
- let cursor;
4042
- do {
4043
- const page = await connection.web.conversations.members({ channel: conversation, limit: 1000, ...(cursor ? { cursor } : {}) });
4044
- for (const member of page.members ?? []) {
4045
- members.add(member);
4046
- }
4047
- cursor = page.response_metadata?.next_cursor || undefined;
4048
- } while (cursor);
4049
- }
4050
- catch (error) {
4051
- members = undefined;
4052
- membersFailure = describe(error);
4053
- }
4289
+ members = undefined;
4290
+ membersFailure = describeSlackError(error);
4054
4291
  }
4055
- for (const [who, user] of toInvite) {
4056
- const code = refused.get(user);
4057
- if (code === 'already_in_channel' || (code === undefined && (failure === undefined || members?.has(user)))) {
4058
- invited.push(who);
4059
- }
4060
- else if (code !== undefined || members !== undefined) {
4061
- notInvited.push({ who, reason: `Slack did not invite them (${code ?? failure}).` });
4062
- }
4063
- else {
4064
- notInvited.push({
4065
- who,
4066
- reason: `The invitation failed (${failure}) and the channel's members could not be checked (${membersFailure}), so they may or may not have been added.`,
4067
- });
4068
- }
4292
+ }
4293
+ for (const [who, user] of toInvite) {
4294
+ const code = refused.get(user);
4295
+ if (code === 'already_in_channel' || (code === undefined && (failure === undefined || members?.has(user)))) {
4296
+ invited.push(who);
4297
+ }
4298
+ else if (code !== undefined || members !== undefined) {
4299
+ notInvited.push({ who, reason: `Slack did not invite them (${code ?? failure}).` });
4300
+ }
4301
+ else {
4302
+ notInvited.push({
4303
+ who,
4304
+ reason: `The invitation failed (${failure}) and the channel's members could not be checked (${membersFailure}), so they may or may not have been added.`,
4305
+ });
4069
4306
  }
4070
4307
  }
4308
+ return { invited, notInvited };
4309
+ };
4310
+ /** Puts both lists back in the order the caller named the entries. */
4311
+ const sortByRequest = (invite, invited, notInvited) => {
4071
4312
  const order = new Map(invite.map((who, index) => [who, index]));
4072
4313
  const byRequest = (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0);
4073
4314
  invited.sort(byRequest);
4074
4315
  notInvited.sort((left, right) => byRequest(left.who, right.who));
4075
- return { conversation, name: created.channel?.name ?? name, invited, notInvited, notSet };
4316
+ };
4317
+ /**
4318
+ * Joins an existing public channel with the asking agent's own app —
4319
+ * `channel.join`. Anything it did not join rejects with a sentence; an
4320
+ * answer that leaves it unknown says so, and that a retry is safe,
4321
+ * since Slack treats a second join as a no-op.
4322
+ */
4323
+ const joinConversation = async (request) => {
4324
+ const conversation = request.conversation.trim();
4325
+ if (!conversation) {
4326
+ throw new Error('slack: joining needs a channel id (C…).');
4327
+ }
4328
+ const connection = connectionFor(request.agentId);
4329
+ if (!connection) {
4330
+ throw new Error(configuredAgents.has(request.agentId)
4331
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot join ${conversation}.`
4332
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot join a Slack channel.`);
4333
+ }
4334
+ const { join } = connection.web.conversations;
4335
+ if (!join) {
4336
+ throw new Error('slack: this Slack client cannot join channels.');
4337
+ }
4338
+ const agent = request.agentId;
4339
+ let joined;
4340
+ try {
4341
+ joined = await join({ channel: conversation });
4342
+ }
4343
+ catch (error) {
4344
+ const code = slackErrorCode(error);
4345
+ if (code === undefined || code === 'internal_error' || code === 'fatal_error') {
4346
+ 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.`);
4347
+ }
4348
+ if (code === 'missing_scope') {
4349
+ 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.`);
4350
+ }
4351
+ if (code === 'method_not_supported_for_channel_type') {
4352
+ 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.`);
4353
+ }
4354
+ if (code === 'channel_not_found') {
4355
+ throw new Error(`slack: ${channelNotFoundSentence(agent, conversation)}`);
4356
+ }
4357
+ if (code === 'is_archived') {
4358
+ throw new Error(`slack: ${conversation} is archived, so it can't be joined.`);
4359
+ }
4360
+ if (code === 'too_many_members') {
4361
+ throw new Error(`slack: ${conversation} is at Slack's member limit, so ${agent}'s app can't join it.`);
4362
+ }
4363
+ if (code === 'ratelimited') {
4364
+ throw new Error(`slack: Slack is rate-limiting ${agent}'s app; try joining ${conversation} again later.`);
4365
+ }
4366
+ if (SLACK_AUTH_ERRORS.has(code)) {
4367
+ throw new Error(`slack: Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`);
4368
+ }
4369
+ throw new Error(`slack: Slack did not let ${agent}'s app join ${conversation} (${code}).`);
4370
+ }
4371
+ const warnings = [joined.warning, ...(joined.response_metadata?.warnings ?? [])];
4372
+ return { conversation: joined.channel?.id ?? conversation, alreadyMember: warnings.includes('already_in_channel') };
4373
+ };
4374
+ /**
4375
+ * Invites people and agents to an existing channel with the asking
4376
+ * agent's own app — `channel.invite`. It rejects only when nobody was
4377
+ * invited: a request refused before Slack is asked, or a whole call Slack
4378
+ * refused without acting. Everything else is settled per entry by the
4379
+ * same code `createConversation` uses.
4380
+ */
4381
+ const inviteToConversation = async (request) => {
4382
+ const conversation = request.conversation.trim();
4383
+ if (!conversation) {
4384
+ throw new Error('slack: an invitation needs a channel id (C…).');
4385
+ }
4386
+ const invite = request.invite;
4387
+ if (invite.length === 0) {
4388
+ throw new Error('slack: an invitation needs at least one person or agent to invite.');
4389
+ }
4390
+ if (invite.length > CHANNEL_INVITE_LIMIT) {
4391
+ 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.`);
4392
+ }
4393
+ const connection = connectionFor(request.agentId);
4394
+ if (!connection) {
4395
+ throw new Error(configuredAgents.has(request.agentId)
4396
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot invite anyone to ${conversation}.`
4397
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot invite anyone to a Slack channel.`);
4398
+ }
4399
+ const inviteUsers = connection.web.conversations.invite;
4400
+ if (!inviteUsers) {
4401
+ throw new Error('slack: this Slack client cannot invite people to channels.');
4402
+ }
4403
+ const agent = request.agentId;
4404
+ const { own, notInvited, toInvite } = resolveInvitees(connection, agent, invite);
4405
+ for (const who of own) {
4406
+ notInvited.push({ who, reason: 'That is your own app, which can\'t invite itself. To join a public channel, use channel.join.' });
4407
+ }
4408
+ const settled = await inviteAtSlack(connection, inviteUsers, conversation, toInvite, (code) => SLACK_INVITE_REFUSALS.has(code));
4409
+ if (settled.refusedWith !== undefined) {
4410
+ const code = settled.refusedWith;
4411
+ const why = code === 'not_in_channel'
4412
+ ? `${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.`
4413
+ : code === 'channel_not_found'
4414
+ ? channelNotFoundSentence(agent, conversation)
4415
+ : code === 'missing_scope'
4416
+ ? `${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.`
4417
+ : code === 'is_archived'
4418
+ ? `${conversation} is archived, so nobody can be invited to it.`
4419
+ : code === 'method_not_supported_for_channel_type'
4420
+ ? `${conversation} is a direct message or another conversation Slack doesn't allow invitations to.`
4421
+ : code === 'ratelimited'
4422
+ ? `Slack is rate-limiting ${agent}'s app; try the invitation again later.`
4423
+ : SLACK_AUTH_ERRORS.has(code)
4424
+ ? `Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`
4425
+ : `Slack did not invite anyone to ${conversation} (${code}).`;
4426
+ throw new Error(`slack: ${why} Nobody was invited.`);
4427
+ }
4428
+ const invited = settled.invited;
4429
+ notInvited.push(...settled.notInvited);
4430
+ sortByRequest(invite, invited, notInvited);
4431
+ return { invited, notInvited };
4432
+ };
4433
+ /**
4434
+ * Other agents on this host whose Slack app is this agent's: the same
4435
+ * bot token configured (connected or not), or the same bot user in the
4436
+ * same workspace. Slack's `creator` names the app's bot user, so it
4437
+ * can't tell those agents' channels apart. This sees only this process;
4438
+ * one app per agent is the supported setup (see the README).
4439
+ */
4440
+ const agentsSharingAppWith = (connection) => {
4441
+ const agentId = connection.config.agentId;
4442
+ const sharing = new Set();
4443
+ for (const other of carried) {
4444
+ if (other.agentId !== agentId && other.botToken === connection.config.botToken) {
4445
+ sharing.add(other.agentId);
4446
+ }
4447
+ }
4448
+ for (const [other, identity] of botIdentities) {
4449
+ if (other !== agentId && identity.botUserId !== '' && identity.botUserId === connection.botUserId && identity.teamId === connection.teamId) {
4450
+ sharing.add(other);
4451
+ }
4452
+ }
4453
+ return [...sharing];
4454
+ };
4455
+ /**
4456
+ * Archives a channel the asking agent's own app created — what
4457
+ * `channel.archive` runs on. Everything it refuses is refused before
4458
+ * Slack is asked to archive: an id Slack can't show this app, a direct
4459
+ * message, a channel another user or app created, an app other agents
4460
+ * here share, a channel shared outside the workspace. Slack keeps who
4461
+ * created a channel and whether it is archived, so nothing is stored
4462
+ * here and the rule holds across restarts and hosts. Refusals name the
4463
+ * channel by id only: its name is whatever its members typed.
4464
+ */
4465
+ const archiveConversation = async (request) => {
4466
+ const conversation = request.conversation.trim();
4467
+ if (!conversation) {
4468
+ throw new Error('slack: archiving needs a channel id (C…).');
4469
+ }
4470
+ const connection = connectionFor(request.agentId);
4471
+ if (!connection) {
4472
+ throw new Error(configuredAgents.has(request.agentId)
4473
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so it cannot archive ${conversation}.`
4474
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot archive a Slack channel.`);
4475
+ }
4476
+ const { archive } = connection.web.conversations;
4477
+ if (!archive) {
4478
+ throw new Error('slack: this Slack client cannot archive channels.');
4479
+ }
4480
+ const agent = request.agentId;
4481
+ const nothing = 'Nothing was archived.';
4482
+ const sharing = agentsSharingAppWith(connection);
4483
+ if (sharing.length > 0) {
4484
+ 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}. `
4485
+ + `Give each agent its own Slack app. A person can archive it in Slack. ${nothing}`);
4486
+ }
4487
+ let info;
4488
+ try {
4489
+ info = (await connection.web.conversations.info({ channel: conversation })).channel;
4490
+ }
4491
+ catch (error) {
4492
+ const code = slackErrorCode(error);
4493
+ if (code === 'channel_not_found') {
4494
+ throw new Error(`slack: ${channelNotFoundSentence(agent, conversation)} Either way, ${agent} can only archive channels its own app created. ${nothing}`);
4495
+ }
4496
+ if (code === 'ratelimited') {
4497
+ throw new Error(`slack: Slack is rate-limiting ${agent}'s app; try archiving ${conversation} again later. ${nothing}`);
4498
+ }
4499
+ if (code !== undefined && SLACK_AUTH_ERRORS.has(code)) {
4500
+ throw new Error(`slack: Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token. ${nothing}`);
4501
+ }
4502
+ throw new Error(`slack: Slack could not tell ${agent}'s app who created ${conversation} (${describeSlackError(error)}). ${nothing}`);
4503
+ }
4504
+ if (info?.is_im === true || info?.is_mpim === true) {
4505
+ throw new Error(`slack: ${conversation} is a direct message, and only channels can be archived. ${nothing}`);
4506
+ }
4507
+ const creator = info?.creator ?? '';
4508
+ if (creator === '' || connection.botUserId === '' || creator !== connection.botUserId) {
4509
+ 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}`);
4510
+ }
4511
+ if (info?.is_shared === true || info?.is_ext_shared === true || info?.is_org_shared === true || info?.is_pending_ext_shared === true) {
4512
+ 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}`);
4513
+ }
4514
+ if (info?.is_archived === true) {
4515
+ return { conversation, alreadyArchived: true };
4516
+ }
4517
+ try {
4518
+ await archive({ channel: conversation });
4519
+ }
4520
+ catch (error) {
4521
+ const code = slackErrorCode(error);
4522
+ if (code === 'already_archived') {
4523
+ return { conversation, alreadyArchived: true };
4524
+ }
4525
+ warn(`slack: ${agent} could not archive ${conversation} (${describeSlackError(error)})`);
4526
+ if (code === undefined || code === 'internal_error' || code === 'fatal_error') {
4527
+ 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.`);
4528
+ }
4529
+ const isPrivate = info?.is_private === true || info?.is_group === true;
4530
+ const why = code === 'missing_scope'
4531
+ ? `${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.`
4532
+ : code === 'not_in_channel'
4533
+ ? `${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.`
4534
+ : code === 'restricted_action'
4535
+ ? `This workspace limits who can archive channels, so ${agent}'s app can't archive ${conversation}. A person has to do it in Slack.`
4536
+ : code === 'ratelimited'
4537
+ ? `Slack is rate-limiting ${agent}'s app; try archiving ${conversation} again later.`
4538
+ : code === 'channel_not_found'
4539
+ ? channelNotFoundSentence(agent, conversation)
4540
+ : SLACK_AUTH_ERRORS.has(code)
4541
+ ? `Slack refused ${agent}'s app token (${code}). Reinstall the app or replace its bot token.`
4542
+ : `Slack did not let ${agent}'s app archive ${conversation} (${code}).`;
4543
+ throw new Error(`slack: ${why} ${nothing}`);
4544
+ }
4545
+ log(`slack: ${agent} archived ${conversation}`);
4546
+ return { conversation, alreadyArchived: false };
4076
4547
  };
4077
4548
  /**
4078
4549
  * Renders one parked call as buttons and remembers where it went.
@@ -4633,7 +5104,7 @@ export const createSlackChannelAdapter = (options) => {
4633
5104
  channel,
4634
5105
  text: escapeSlackText(`${agentName} is asking ${request.replace === true ? 'to replace' : 'for'} a credential: ${request.name}, ${credentialWhose(agentName, request.scope)}.`),
4635
5106
  ...(thread ? { thread_ts: thread } : {}),
4636
- blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId, request.replace === true),
5107
+ blocks: credentialRequestBlocks(agentName, request.name, request.scope, request.reason, request.requestId, request.replace === true, request.env),
4637
5108
  });
4638
5109
  }
4639
5110
  catch (error) {
@@ -4655,6 +5126,7 @@ export const createSlackChannelAdapter = (options) => {
4655
5126
  name: request.name,
4656
5127
  scope: request.scope,
4657
5128
  ...(request.replace === true ? { replace: true } : {}),
5129
+ ...(request.env !== undefined ? { env: request.env } : {}),
4658
5130
  approvers,
4659
5131
  });
4660
5132
  };
@@ -4668,14 +5140,17 @@ export const createSlackChannelAdapter = (options) => {
4668
5140
  ? `for ${escapeSlackText(post.agentName)} only`
4669
5141
  : `as a shared credential, granted to ${escapeSlackText(post.agentName)}`;
4670
5142
  const by = event.actor !== undefined && post.approvers.has(event.actor) ? `, by <@${event.actor}>` : '';
4671
- const text = event.replace === true
5143
+ const shell = post.env !== undefined && event.grantError === undefined
5144
+ ? ` Its shell commands read it as \`${escapeSlackText(post.env)}\`.`
5145
+ : '';
5146
+ const text = (event.replace === true
4672
5147
  ? `Replaced \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}. `
4673
5148
  + `${escapeSlackText(post.agentName)} uses the new value from its next reply; it is in the credential store, not here.`
4674
5149
  : event.grantError === undefined
4675
5150
  ? `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.`
4676
5151
  : `Stored \`${escapeSlackText(post.name)}\` ${event.scope === 'agent' ? `for ${escapeSlackText(post.agentName)}` : 'as a shared credential'}${by}, `
4677
5152
  + `but it could not be added to ${escapeSlackText(post.agentName)}'s soul (${escapeSlackText(event.grantError)}), so ${escapeSlackText(post.agentName)} cannot use it yet. `
4678
- + 'Add the name to the credentials list in its soul on the machine.';
5153
+ + 'Add the name to the credentials list in its soul on the machine.') + shell;
4679
5154
  try {
4680
5155
  await post.connection.web.chat.update({ channel: post.channel, ts: post.ts, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] });
4681
5156
  }
@@ -6801,20 +7276,255 @@ export const createSlackChannelAdapter = (options) => {
6801
7276
  renderers.get(sessionId)?.[0]?.refreshLoading();
6802
7277
  }
6803
7278
  };
7279
+ /**
7280
+ * One agent's app, authenticated, with who its bot is recorded, and its
7281
+ * socket built but not started: undefined, with a warning, when it would
7282
+ * not authenticate. Recorded before the socket is even built: an app
7283
+ * that never comes up must still be recognizable when somebody names it.
7284
+ */
7285
+ const authenticate = async (config) => {
7286
+ try {
7287
+ const created = createWeb(config.botToken);
7288
+ const web = config.linkPreviews === false ? withoutLinkPreviews(created) : created;
7289
+ const auth = await web.auth.test();
7290
+ const botUserId = auth.user_id ?? '';
7291
+ const teamId = auth.team_id ?? '';
7292
+ checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
7293
+ botIdentities.set(config.agentId, { botUserId, teamId });
7294
+ return {
7295
+ config,
7296
+ web,
7297
+ socket: createSocket(config.appToken),
7298
+ botUserId,
7299
+ teamId,
7300
+ statusWarned: { value: false },
7301
+ };
7302
+ }
7303
+ catch (error) {
7304
+ // One broken app must not take the rest of the fleet down.
7305
+ warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
7306
+ return undefined;
7307
+ }
7308
+ };
7309
+ /**
7310
+ * Starts an authenticated app's socket and serves it. Resolves with the
7311
+ * listener a replay of kept messages delivers through, or undefined when
7312
+ * the socket would not start: authenticated but not serving, it answers
7313
+ * nothing, and stays recognizable so nobody answers in its place.
7314
+ */
7315
+ const connect = async (connection) => {
7316
+ const { config, socket } = connection;
7317
+ try {
7318
+ // Returns the work it started: the SDK ignores it, and a test's fake
7319
+ // socket waits on it rather than guessing how long it takes.
7320
+ const handle = (inbound) => {
7321
+ const handled = inbound.catch((error) => {
7322
+ warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
7323
+ });
7324
+ inflight.add(handled);
7325
+ void handled.finally(() => inflight.delete(handled));
7326
+ return handled;
7327
+ };
7328
+ // The replay's own deliveries never wait on the hold they release.
7329
+ const onReplay = (args) => handle(handleInbound(connection, args));
7330
+ const onEvent = (args) => {
7331
+ const channel = args.event?.channel;
7332
+ const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
7333
+ if (hold === undefined) {
7334
+ return onReplay(args);
7335
+ }
7336
+ // Acked now, as any delivery is, so Slack does not redeliver a
7337
+ // message that is only waiting its turn; admitted after the hold.
7338
+ void Promise.resolve(args.ack()).catch((error) => {
7339
+ warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
7340
+ });
7341
+ return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
7342
+ };
7343
+ socket.on('app_mention', onEvent);
7344
+ socket.on('message', onEvent);
7345
+ socket.on('interactive', (args) => {
7346
+ const handled = handleInteractive(connection, args).catch((error) => {
7347
+ warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
7348
+ });
7349
+ track(handled);
7350
+ return handled;
7351
+ });
7352
+ const up = (async () => {
7353
+ await socket.start();
7354
+ // A stop that began while this socket was starting has already
7355
+ // disconnected what it could see; one left up here would answer
7356
+ // beside the next daemon. Checked in the same step that adds it
7357
+ // to `connections`, so a stop either sees it there or finds it
7358
+ // here.
7359
+ if (stopRequested) {
7360
+ if (!await disconnect(connection, 'the adapter is stopping')) {
7361
+ leftUpAtStop.add(config.agentId);
7362
+ }
7363
+ return false;
7364
+ }
7365
+ connections.push(connection);
7366
+ return true;
7367
+ })();
7368
+ socketsStarting.set(up, config.agentId);
7369
+ try {
7370
+ if (!await up) {
7371
+ return undefined;
7372
+ }
7373
+ }
7374
+ finally {
7375
+ socketsStarting.delete(up);
7376
+ }
7377
+ log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
7378
+ return onReplay;
7379
+ }
7380
+ catch (error) {
7381
+ warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
7382
+ return undefined;
7383
+ }
7384
+ };
7385
+ /** Closes an app's socket; false, with a warning, when the close was refused. */
7386
+ const disconnect = async (connection, reason) => {
7387
+ try {
7388
+ await connection.socket.disconnect();
7389
+ return true;
7390
+ }
7391
+ catch (error) {
7392
+ warn(`slack: could not disconnect ${connection.config.agentId} (${reason}): ${error instanceof Error ? error.message : String(error)}`);
7393
+ return false;
7394
+ }
7395
+ };
7396
+ const sameApp = (a, b) => a.appToken === b.appToken && a.botToken === b.botToken;
7397
+ /**
7398
+ * Brings the connected apps in line with the agents to carry now (card
7399
+ * 894): connects an agent whose app is new, or that the roster has only
7400
+ * now, or that did not come up before; reconnects one whose tokens
7401
+ * changed; disconnects one whose tokens were removed or whose soul is
7402
+ * gone. Every other socket is left alone, so a conversation on it never
7403
+ * notices. Settings come with the list `options.loadAgents` reads, which
7404
+ * for the daemon is the config it started with.
7405
+ */
7406
+ const reconcile = async () => {
7407
+ const gateway = gatewayRef;
7408
+ if (gateway === undefined || stopRequested || options.loadAgents === undefined) {
7409
+ return;
7410
+ }
7411
+ const next = await options.loadAgents();
7412
+ if (gatewayRef !== gateway || stopRequested) {
7413
+ return;
7414
+ }
7415
+ const known = new Set(gateway.agents().map((agent) => agent.id));
7416
+ const wanted = new Map(next.filter((config) => known.has(config.agentId)).map((config) => [config.agentId, config]));
7417
+ const connected = new Map(connections.map((connection) => [connection.config.agentId, connection]));
7418
+ const leaving = [...connected.values()].filter((connection) => {
7419
+ const config = wanted.get(connection.config.agentId);
7420
+ return config === undefined || !sameApp(config, connection.config);
7421
+ });
7422
+ // Closed first, and only a closed one is let go of. A socket whose close
7423
+ // was refused may still deliver, so it stays this agent's connection,
7424
+ // on the tokens it has, and nothing is started beside it: a second
7425
+ // socket for the agent would answer every message twice, and a removed
7426
+ // app would keep answering while reported gone. A restart clears it.
7427
+ for (const connection of leaving) {
7428
+ const agentId = connection.config.agentId;
7429
+ const reason = !known.has(agentId)
7430
+ ? 'its soul is gone'
7431
+ : wanted.has(agentId) ? 'its tokens changed' : 'its tokens were removed';
7432
+ if (!await disconnect(connection, reason)) {
7433
+ warn(`slack: ${agentId} stays connected with the app it had until a restart, since its socket would not close`);
7434
+ continue;
7435
+ }
7436
+ const index = connections.indexOf(connection);
7437
+ if (index >= 0) {
7438
+ connections.splice(index, 1);
7439
+ }
7440
+ log(`slack: ${agentId} disconnected (${reason})`);
7441
+ }
7442
+ // What is still connected now, stuck sockets included, serves on.
7443
+ const serving = (agentId) => connections.find((connection) => connection.config.agentId === agentId);
7444
+ const arriving = [...wanted.values()].filter((config) => serving(config.agentId) === undefined);
7445
+ // A connected app keeps the configuration it is serving with.
7446
+ carried = [
7447
+ ...next.map((config) => serving(config.agentId)?.config ?? config),
7448
+ ...connections.filter((connection) => !next.some((config) => config.agentId === connection.config.agentId)).map((connection) => connection.config),
7449
+ ];
7450
+ configuredAgents.clear();
7451
+ for (const config of carried) {
7452
+ configuredAgents.add(config.agentId);
7453
+ }
7454
+ // Who each remaining bot is, in the carried order: an app no longer
7455
+ // carried is nobody's to recognize, and a new one is learned below.
7456
+ const kept = new Map(botIdentities);
7457
+ botIdentities.clear();
7458
+ for (const config of carried) {
7459
+ const identity = kept.get(config.agentId);
7460
+ if (identity !== undefined && serving(config.agentId) !== undefined) {
7461
+ botIdentities.set(config.agentId, identity);
7462
+ }
7463
+ }
7464
+ for (const config of next) {
7465
+ if (!known.has(config.agentId) && serving(config.agentId) === undefined) {
7466
+ warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
7467
+ }
7468
+ }
7469
+ // Every new identity first, then the sockets, as `start` does: a
7470
+ // mention of an agent not yet known would read as an untagged reply.
7471
+ const authenticated = [];
7472
+ for (const config of arriving) {
7473
+ if (stopRequested) {
7474
+ return;
7475
+ }
7476
+ const connection = await authenticate(config);
7477
+ if (connection) {
7478
+ authenticated.push(connection);
7479
+ }
7480
+ }
7481
+ if (authenticated.length > 0) {
7482
+ const learned = new Map(botIdentities);
7483
+ botIdentities.clear();
7484
+ for (const config of carried) {
7485
+ const identity = learned.get(config.agentId);
7486
+ if (identity !== undefined) {
7487
+ botIdentities.set(config.agentId, identity);
7488
+ }
7489
+ }
7490
+ }
7491
+ settleHomeOwners();
7492
+ for (const connection of authenticated) {
7493
+ if (stopRequested) {
7494
+ return;
7495
+ }
7496
+ await connect(connection);
7497
+ }
7498
+ };
7499
+ // One reload at a time: the gateway can call again before an earlier one
7500
+ // settled, since it stops waiting after a bound.
7501
+ let reloading = Promise.resolve();
7502
+ const reload = () => {
7503
+ const run = reloading.then(reconcile, reconcile);
7504
+ reloading = run.catch(() => undefined);
7505
+ return run;
7506
+ };
6804
7507
  return {
6805
7508
  name: 'slack',
7509
+ ...(options.loadAgents !== undefined ? { reload } : {}),
6806
7510
  resolveOutbound,
6807
7511
  readConversation,
6808
7512
  sessionConversation,
6809
7513
  createConversation,
7514
+ joinConversation,
7515
+ inviteToConversation,
7516
+ archiveConversation,
6810
7517
  // A channel id is unique only within its workspace, so a standing
6811
7518
  // grant on one is bound to the team the agent's app authenticated in.
6812
7519
  accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
6813
7520
  conversationKindFor,
6814
7521
  requestCredential,
6815
7522
  credentialReplaceForms: true,
7523
+ credentialEnvForms: true,
6816
7524
  async start(gateway) {
6817
7525
  gatewayRef = gateway;
7526
+ stopRequested = false;
7527
+ leftUpAtStop.clear();
6818
7528
  // Nothing counts as seen live until every socket is up (Codex
6819
7529
  // 4321128637): a message sent while the sockets are still coming up
6820
7530
  // reaches none of them, so a read stopping before it stops short.
@@ -6940,33 +7650,14 @@ export const createSlackChannelAdapter = (options) => {
6940
7650
  // agent not yet known reads as an untagged reply — which the agent
6941
7651
  // holding that thread would then answer.
6942
7652
  const authenticated = [];
6943
- for (const config of options.agents) {
7653
+ for (const config of carried) {
6944
7654
  if (!known.has(config.agentId)) {
6945
7655
  warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
6946
7656
  continue;
6947
7657
  }
6948
- try {
6949
- const created = createWeb(config.botToken);
6950
- const web = config.linkPreviews === false ? withoutLinkPreviews(created) : created;
6951
- const auth = await web.auth.test();
6952
- const botUserId = auth.user_id ?? '';
6953
- const teamId = auth.team_id ?? '';
6954
- checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
6955
- // Recorded before the socket is even built: an app that never
6956
- // comes up must still be recognizable when somebody names it.
6957
- botIdentities.set(config.agentId, { botUserId, teamId });
6958
- authenticated.push({
6959
- config,
6960
- web,
6961
- socket: createSocket(config.appToken),
6962
- botUserId,
6963
- teamId,
6964
- statusWarned: { value: false },
6965
- });
6966
- }
6967
- catch (error) {
6968
- // One broken app must not take the rest of the fleet down.
6969
- warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
7658
+ const connection = await authenticate(config);
7659
+ if (connection) {
7660
+ authenticated.push(connection);
6970
7661
  }
6971
7662
  }
6972
7663
  settleHomeOwners();
@@ -6989,51 +7680,9 @@ export const createSlackChannelAdapter = (options) => {
6989
7680
  }
6990
7681
  }
6991
7682
  for (const connection of authenticated) {
6992
- const { config, socket } = connection;
6993
- try {
6994
- // Returns the work it started: the SDK ignores it, and a test's fake
6995
- // socket waits on it rather than guessing how long it takes.
6996
- const handle = (inbound) => {
6997
- const handled = inbound.catch((error) => {
6998
- warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
6999
- });
7000
- inflight.add(handled);
7001
- void handled.finally(() => inflight.delete(handled));
7002
- return handled;
7003
- };
7004
- // The replay's own deliveries never wait on the hold they release.
7005
- const onReplay = (args) => handle(handleInbound(connection, args));
7006
- const onEvent = (args) => {
7007
- const channel = args.event?.channel;
7008
- const hold = typeof channel === 'string' ? replayHolds.get(holdKey(config.agentId, channel))?.held : undefined;
7009
- if (hold === undefined) {
7010
- return onReplay(args);
7011
- }
7012
- // Acked now, as any delivery is, so Slack does not redeliver a
7013
- // message that is only waiting its turn; admitted after the hold.
7014
- void Promise.resolve(args.ack()).catch((error) => {
7015
- warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
7016
- });
7017
- return handle(hold.then(() => handleInbound(connection, { ...args, ack: async () => { } })));
7018
- };
7019
- socket.on('app_mention', onEvent);
7020
- socket.on('message', onEvent);
7021
- listeners.set(config.agentId, onReplay);
7022
- socket.on('interactive', (args) => {
7023
- const handled = handleInteractive(connection, args).catch((error) => {
7024
- warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
7025
- });
7026
- track(handled);
7027
- return handled;
7028
- });
7029
- await socket.start();
7030
- connections.push(connection);
7031
- log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
7032
- }
7033
- catch (error) {
7034
- // Authenticated but not serving: it answers nothing, and stays
7035
- // recognizable so nobody answers in its place.
7036
- warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
7683
+ const onReplay = await connect(connection);
7684
+ if (onReplay) {
7685
+ listeners.set(connection.config.agentId, onReplay);
7037
7686
  }
7038
7687
  }
7039
7688
  const liveMs = now() + LIVE_FROM_MARGIN_MS;
@@ -7047,11 +7696,28 @@ export const createSlackChannelAdapter = (options) => {
7047
7696
  track(replayKept(kept, new Map([...listeners].filter(([agentId]) => connections.some((connection) => connection.config.agentId === agentId))), replayStopping.signal).finally(releaseAllConversations));
7048
7697
  },
7049
7698
  async stop() {
7699
+ stopRequested = true;
7050
7700
  replayStopping?.abort();
7051
7701
  releaseAllConversations();
7052
7702
  // Stop intake first, so no new INBOUND event can slip into
7053
- // `inflight` while the drain below runs.
7054
- await Promise.allSettled(connections.map((connection) => connection.socket.disconnect()));
7703
+ // `inflight` while the drain below runs. A close that is refused
7704
+ // leaves a socket that may still deliver (card 935), and so does one
7705
+ // still starting that does not come down in time: the stop rejects
7706
+ // naming them, after the drain, and the gateway counts that as not
7707
+ // drained, so a restart exits for the service manager rather than
7708
+ // start a second daemon answering beside them.
7709
+ const closing = Promise.all(connections.map(async (connection) => await disconnect(connection, 'the adapter is stopping') ? undefined : connection.config.agentId));
7710
+ // Whatever is still in here once the wait ends never came up or down.
7711
+ const starting = new Map(socketsStarting);
7712
+ let waited;
7713
+ await Promise.race([
7714
+ Promise.allSettled([...starting.keys()].map((up) => up.finally(() => starting.delete(up)))),
7715
+ new Promise((resolve) => { waited = setTimeout(resolve, stopWaitMs); }),
7716
+ ]);
7717
+ clearTimeout(waited);
7718
+ const refused = [...new Set([...(await closing).filter((agentId) => agentId !== undefined), ...leftUpAtStop])];
7719
+ const stillStarting = [...new Set(starting.values())];
7720
+ leftUpAtStop.clear();
7055
7721
  // Until it stays empty, not once. The bus subscription is still live
7056
7722
  // here — it comes down after the drain, because a turn finishing
7057
7723
  // inside the drain still has an outcome to render — so a turn the
@@ -7085,6 +7751,13 @@ export const createSlackChannelAdapter = (options) => {
7085
7751
  rendering.clear();
7086
7752
  resolvedWhileRendering.clear();
7087
7753
  credentialsPosting.clear();
7754
+ const left = [
7755
+ ...(refused.length > 0 ? [`the socket for ${refused.join(', ')} would not close`] : []),
7756
+ ...(stillStarting.length > 0 ? [`the socket for ${stillStarting.join(', ')} was still starting after ${stopWaitMs}ms`] : []),
7757
+ ];
7758
+ if (left.length > 0) {
7759
+ throw new Error(`${left.join('; ')}, so it may still deliver messages`);
7760
+ }
7088
7761
  },
7089
7762
  };
7090
7763
  };