@stratusagent/channel-slack 0.11.10 → 0.12.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/README.md +13 -4
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +236 -32
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Slack adapter for Stratus agents. **One Slack app per agent** — Slack has no way to give a single bot several identities with real avatars, presence, and DMs, so each agent gets its own app, and the adapter runs one Socket Mode connection per agent (no public ingress needed; Mac Minis behind NAT are fine).
|
|
4
4
|
|
|
5
|
-
- **Resumable conversations**: session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>` (
|
|
5
|
+
- **Resumable conversations**: session keys are `slack:<agent>:<team>:<channel>:<thread_ts ?? ts>` (a DM's top level: the DM channel id alone; a thread in a DM adds its `thread_ts`) — a thread is a conversation, it survives daemon restarts, and two agents sharing a thread keep fully separate sessions. A turn parked on a human when the daemon died is re-asked after the restart, and a turn that was still running is continued from where it got to (unless the agent runs on a harness — `codex` or a Claude subscription — where it is failed instead, since the harness may already have acted on it); either way, when it finishes its reply is posted into the thread as a fresh message — the placeholder it would have edited belonged to the old process.
|
|
6
6
|
- **Replies post when they are ready**: by default each message is posted once, finished: what the agent writes before starting a tool is posted as the tool starts, and the reply follows, with Slack's loading status (`assistant.threads.setStatus` — "is thinking…", "is running shell.run…") shown while the turn works, so the notification carries the answer. The `stream` reply mode (`slack.replies` in the [config](../../docs/guides/slack.md#how-replies-appear)) keeps the placeholder-and-edit rendering instead: post `…`, edit as deltas arrive (throttled for `chat.update` limits), show `⚙ tool…` status lines, finalize with the full reply. Either way a reply that outgrows one message is split across several.
|
|
7
7
|
- **Markdown is translated to Slack's mrkdwn** on the way out — `**bold**` to `*bold*`, `*italic*` to `_italic_`, `~~struck~~` to `~struck~`, `[text](https://…)` to `<https://…|text>`, and a `#` heading to a bold line, since mrkdwn has no headings. A bare address is sent as `<https://…>`, Slack's own link markup, so emphasis around it (`**https://…**`) cannot be read as part of it, and a `*`, `_`, or `~` inside one is part of the address rather than a marker. Trailing punctuation (`.`, `,`, `**`) and a `)` the address never opened belong to the sentence, as in GFM autolinks, so `…/wiki/Function_(mathematics)` stays whole. A bare address is sent as `<https://…>`, Slack's own link markup, so emphasis around it (`**https://…**`) cannot be read as part of it, and the `*`, `_`, or `~` inside one is part of the address rather than a marker; trailing punctuation (`.`, `,`, `**`) is the sentence's, as in GFM autolinks. The reply is read once into tokens and written back from those, so what counts as code is decided one time rather than by each rule in turn. Three things follow from that. **A code run is one token**: nothing reads or rewrites inside it, an unclosed fence included, and the newlines in a fence are not line breaks — so a `#` on a line of somebody's shell script is not a heading. A run of N backticks opens code and only a run of exactly N closes it, which is what lets ``a span with a ` in it`` and a ````-fence holding a ```-fence work; one or two unmatched backticks are text, as they are in Markdown, but a pair is a span however far apart, because Slack pairs them too. **A delimiter is matched to its partner**, across whatever sits between — ``**the `fs.read` tool**`` is bold around a snippet, not four literal asterisks — though never across a line, so a marker still being streamed stays literal until its closing half arrives. A run is spent rather than matched whole, so the three asterisks ending `**bold and *italic***` close the italic with one and the bold with two; what no pair spends stays on the line as text, and what a run cannot buy — the third tilde of `~~~struck~~~`, a style that does not exist — is never charged for. **A wrapper is only written where its own character is not already loose** in what it wraps: Slack has one delimiter per style and no way to nest it, so a heading that already contains bold keeps the bold it has rather than gaining a second pair that would print instead of render, and `# glob *.ts` keeps an asterisk that was never a marker. A heading sheds its hashes and its trailing spaces from the prose that holds them, never from a snippet. Anything whose spelling already matches — lists, block quotes, inline code — is untouched. **A pipe table**, which mrkdwn has no syntax for, becomes a code block with its columns padded to line up (alignment colons respected, `**`/`` ` `` markers dropped from cells, since nothing renders inside code); a grid wider than 60 characters — past what a phone shows unwrapped — or one with a link in it (a link inside a code block is not clickable) becomes one line per row, `*Header*: value · *Header*: value`. A one-column table has nothing to line up and becomes a bulleted list under its bold header. A table inside a fence is somebody's text and stays as written, and a grid longer than one message is split like any code block, closed at the cut and reopened in the next, with its header and rule repeated so every part names its columns.
|
|
8
8
|
- **Mention to start, then it stays in the thread.** A mention opens a conversation; every reply in that thread reaches the agent without tagging it again, and free-form DMs are unchanged. A mention partway into an existing thread opens the conversation with what was said before it: the thread's parent and its newest 39 earlier messages. A mention at the top of a channel opens with the channel's 20 most recent messages instead, which is where a channel per client or per project keeps its context. Either way each message keeps its own speaker and its own trust, so a principal's mention does not carry a stranger's earlier words in under the principal's label (other bots count as `unknown`, and under `admit: "principals"` unlisted senders are left out). This needs the history scopes below; without them the agent sees only the message that named it, and the adapter logs a warning. Who an untagged reply is for is decided by [Who a message is for](#who-a-message-is-for). Inbound mentions of other users are humanized to `@Display Name` for the model; redeliveries are deduped so a slow turn never runs twice — in memory while the process remembers them, and after a restart by the gateway: each message is dispatched with its own key (`idempotencyKey`), a repeat runs no second turn, and the adapter posts that message's own reply and files (a file changed since that turn is skipped, since the path may now hold another file), or its failure if the turn failed — once more, if the process died after posting it, which beats a reply never posted.
|
|
@@ -146,8 +146,17 @@ message after what was said, between `[Attached: plan.md. Its contents
|
|
|
146
146
|
follow.]` and `[End of plan.md]`. A text file dropped in alone is a
|
|
147
147
|
question, like a screenshot. One file may be 100 KB and one message's text
|
|
148
148
|
files 200 KB together, since read text joins the conversation and is sent
|
|
149
|
-
again every turn
|
|
150
|
-
|
|
149
|
+
again every turn. A larger one, up to 10 MB, is saved under the agent's
|
|
150
|
+
workspace (`attachments/slack/<stamp>-<file id>-<name>`, `0600`) when the
|
|
151
|
+
host passes `attachmentStore`, and the message says `[Attached: spec.md,
|
|
152
|
+
251 KB, too large to include here. It is saved in full at <path>. …]`. The
|
|
153
|
+
path is recorded in the tainted-write ledger at the sender's label before
|
|
154
|
+
the bytes are written, so `fs.read` of it carries that label. One agent's
|
|
155
|
+
saved attachments are bounded together (`attachmentStore.maxBytes`, 256 MB
|
|
156
|
+
by default, and 1,000 files): past it a save is refused, the file reaches the turn as a
|
|
157
|
+
name, and the warning names the directory to clear. Nothing is deleted. `stratus
|
|
158
|
+
serve` passes it; a host that does not, or a file over 10 MB, gets the name
|
|
159
|
+
only, and `stratus serve` warns with the file's name. A file that is not UTF-8, or that comes back as
|
|
151
160
|
Slack's sign-in page because the scope is missing, is treated as unreadable
|
|
152
161
|
and the warning names the scope. Text files share the message's 30-second
|
|
153
162
|
download deadline with its images.
|
|
@@ -187,7 +196,7 @@ untagged reply in it, including the ones meant for somebody else. Give the
|
|
|
187
196
|
side conversation its own thread. It can now *hear* a thread it is not
|
|
188
197
|
answering; teaching it to choose — to answer only when it has something to
|
|
189
198
|
add, and to let "thanks, we've got it" be a sentence it read — is the rest
|
|
190
|
-
of
|
|
199
|
+
of the reading-the-room work.
|
|
191
200
|
|
|
192
201
|
### What the agent is told about the room
|
|
193
202
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AgentWorkspaces, type TrustLevel } from '@stratusagent/core';
|
|
1
2
|
import { type AdmitPolicy, type ChannelAdapter } from '@stratusagent/channels';
|
|
2
3
|
/** One agent's Slack identity: its own app (avatar, presence) and tokens. */
|
|
3
4
|
export interface SlackAgentConfig {
|
|
@@ -45,6 +46,12 @@ export interface SlackAgentConfig {
|
|
|
45
46
|
* a `…` placeholder at once and edits it as the reply is written.
|
|
46
47
|
*/
|
|
47
48
|
replies?: 'final' | 'stream';
|
|
49
|
+
/**
|
|
50
|
+
* `false` posts this agent's messages without link and media previews
|
|
51
|
+
* (Slack's `unfurl_links`/`unfurl_media`). Absent or `true` leaves
|
|
52
|
+
* Slack's default, which previews them.
|
|
53
|
+
*/
|
|
54
|
+
linkPreviews?: boolean;
|
|
48
55
|
/**
|
|
49
56
|
* Channel ids where a new top-level message from an admitted sender is
|
|
50
57
|
* this agent's to answer without a mention: the channel people come to
|
|
@@ -255,6 +262,8 @@ export interface SlackWebLike {
|
|
|
255
262
|
text: string;
|
|
256
263
|
thread_ts?: string;
|
|
257
264
|
blocks?: SlackBlock[];
|
|
265
|
+
unfurl_links?: boolean;
|
|
266
|
+
unfurl_media?: boolean;
|
|
258
267
|
}): Promise<{
|
|
259
268
|
ts?: string;
|
|
260
269
|
channel?: string;
|
|
@@ -409,6 +418,31 @@ export interface SlackAdapterOptions {
|
|
|
409
418
|
* Defaults to `DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS`.
|
|
410
419
|
*/
|
|
411
420
|
fileDownloadTimeoutMs?: number;
|
|
421
|
+
/**
|
|
422
|
+
* Where a text attachment too large to read into the message is saved
|
|
423
|
+
* instead, so the turn can be told a path rather than only a name.
|
|
424
|
+
*
|
|
425
|
+
* Both halves or neither: the file holds what a Slack member sent, and
|
|
426
|
+
* read back through `fs.read` without a ledger record it would arrive as
|
|
427
|
+
* the agent's own notes — the laundering the ledger exists to stop. So the
|
|
428
|
+
* path is recorded at the sender's label before the bytes land, as
|
|
429
|
+
* `plugin-mcp` does for a server's files. Without this option such a file
|
|
430
|
+
* reaches the turn as a name only, as before.
|
|
431
|
+
*/
|
|
432
|
+
attachmentStore?: {
|
|
433
|
+
workspaces: AgentWorkspaces;
|
|
434
|
+
/** The host's tainted-write ledger, the one `tool-fs` reads. */
|
|
435
|
+
ledger: {
|
|
436
|
+
recordWrite(agentId: string, absolutePath: string, trust: TrustLevel): Promise<void>;
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* The most one agent's saved attachments may weigh together, so anyone
|
|
440
|
+
* admitted to talk to the agent cannot fill the host's disk. A save that
|
|
441
|
+
* would pass it is refused (the file is a name-only note and the log
|
|
442
|
+
* says where to make room); nothing is deleted. Defaults to 256 MB.
|
|
443
|
+
*/
|
|
444
|
+
maxBytes?: number;
|
|
445
|
+
};
|
|
412
446
|
/**
|
|
413
447
|
* Test injection: the clock, in milliseconds, that an upload's place in
|
|
414
448
|
* the thread is read from (`ReplyOutcome.spokeAt`) when the upload's
|
|
@@ -416,6 +450,13 @@ export interface SlackAdapterOptions {
|
|
|
416
450
|
*/
|
|
417
451
|
now?: () => number;
|
|
418
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* A client whose every post goes out without link or media previews, for
|
|
455
|
+
* an agent configured with `linkPreviews: false`. Applied once around the
|
|
456
|
+
* client, so no post site can forget it. `chat.update` takes no unfurl
|
|
457
|
+
* flags; an edited placeholder keeps the post's own setting.
|
|
458
|
+
*/
|
|
459
|
+
export declare const withoutLinkPreviews: (web: SlackWebLike) => SlackWebLike;
|
|
419
460
|
/**
|
|
420
461
|
* The default fetcher over a `fetch`. Exported as a factory so the bounded
|
|
421
462
|
* read can be tested against a fake `fetch` with a body that never ends;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readdir, readFile, realpath } from 'node:fs/promises';
|
|
3
4
|
import { createRequire } from 'node:module';
|
|
4
5
|
import path from 'node:path';
|
|
5
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, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
|
|
@@ -138,6 +139,21 @@ const checkBotScopes = (agentId, granted, warn) => {
|
|
|
138
139
|
+ 'Without them, some features (thread follow-through, image viewing, '
|
|
139
140
|
+ 'channel history) will silently fail.');
|
|
140
141
|
};
|
|
142
|
+
/**
|
|
143
|
+
* A client whose every post goes out without link or media previews, for
|
|
144
|
+
* an agent configured with `linkPreviews: false`. Applied once around the
|
|
145
|
+
* client, so no post site can forget it. `chat.update` takes no unfurl
|
|
146
|
+
* flags; an edited placeholder keeps the post's own setting.
|
|
147
|
+
*/
|
|
148
|
+
export const withoutLinkPreviews = (web) => {
|
|
149
|
+
// Delegating objects, not copies: everything else on the client (and a
|
|
150
|
+
// method replaced on it later) is still reached through the original.
|
|
151
|
+
const chat = Object.create(web.chat);
|
|
152
|
+
chat.postMessage = (args) => web.chat.postMessage({ ...args, unfurl_links: false, unfurl_media: false });
|
|
153
|
+
const wrapped = Object.create(web);
|
|
154
|
+
wrapped.chat = chat;
|
|
155
|
+
return wrapped;
|
|
156
|
+
};
|
|
141
157
|
const defaultWebClient = (botToken) => {
|
|
142
158
|
const { WebClient } = requireModule('@slack/web-api');
|
|
143
159
|
return new WebClient(botToken);
|
|
@@ -1631,6 +1647,136 @@ const TEXT_ATTACHMENT_MAX_BYTES = 100 * 1024;
|
|
|
1631
1647
|
* is a cost decision as much as a context one.
|
|
1632
1648
|
*/
|
|
1633
1649
|
const TEXT_ATTACHMENTS_MAX_TOTAL_BYTES = 200 * 1024;
|
|
1650
|
+
/**
|
|
1651
|
+
* The most a text attachment may weigh to be saved to the agent's
|
|
1652
|
+
* workspace when it is too large to read into the message. A 251 KB
|
|
1653
|
+
* Markdown spec reached an agent as a filename and it had no way to open
|
|
1654
|
+
* it; saved, the agent reads it with its file tools, in parts.
|
|
1655
|
+
*/
|
|
1656
|
+
const TEXT_ATTACHMENT_SAVE_MAX_BYTES = 10 * 1024 * 1024;
|
|
1657
|
+
/** The default bound on one agent's saved attachments, all together. */
|
|
1658
|
+
const SAVED_ATTACHMENTS_MAX_BYTES = 256 * 1024 * 1024;
|
|
1659
|
+
/**
|
|
1660
|
+
* The most saved attachments one agent keeps, whatever they weigh. A file
|
|
1661
|
+
* crowded out of a full message is saved however small it is, and each save
|
|
1662
|
+
* is a ledger record, so the count is bounded as well as the bytes.
|
|
1663
|
+
*/
|
|
1664
|
+
const SAVED_ATTACHMENTS_MAX_FILES = 1_000;
|
|
1665
|
+
/** Under the agent's workspace, where saved attachments go. */
|
|
1666
|
+
const SAVED_ATTACHMENTS_DIR = path.join('attachments', 'slack');
|
|
1667
|
+
/**
|
|
1668
|
+
* A file name safe to join under a directory: Slack's name is whatever the
|
|
1669
|
+
* uploader's client sent, so separators, `..`, and control characters are
|
|
1670
|
+
* replaced rather than trusted. Stamped, so the same file shared twice is
|
|
1671
|
+
* two saves rather than a name already taken.
|
|
1672
|
+
*/
|
|
1673
|
+
const savedAttachmentName = (file, now) => {
|
|
1674
|
+
const base = ((file.name ?? file.title ?? '').split(/[\\/]/).pop() ?? '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[._]+/, '').slice(0, 120);
|
|
1675
|
+
const id = (file.id ?? '').replace(/[^A-Za-z0-9]+/g, '').slice(0, 32);
|
|
1676
|
+
return [now.toString(36), ...(id.length > 0 ? [id] : []), base.length > 0 ? base : 'attachment'].join('-');
|
|
1677
|
+
};
|
|
1678
|
+
/**
|
|
1679
|
+
* Refuses a save that would take the directory's regular files past
|
|
1680
|
+
* `maxBytes` together, or its entries past `SAVED_ATTACHMENTS_MAX_FILES`. Refused rather than making room: deleting on a
|
|
1681
|
+
* Slack member's say is a worse failure than a full directory, and with
|
|
1682
|
+
* nothing removed every ledger record still names a file that exists.
|
|
1683
|
+
*/
|
|
1684
|
+
const ensureRoom = async (dir, incoming, maxBytes) => {
|
|
1685
|
+
let total = 0;
|
|
1686
|
+
const names = await readdir(dir);
|
|
1687
|
+
for (const name of names) {
|
|
1688
|
+
const info = await lstat(path.join(dir, name)).catch(() => undefined);
|
|
1689
|
+
total += info?.isFile() ? info.size : 0;
|
|
1690
|
+
}
|
|
1691
|
+
if (names.length >= SAVED_ATTACHMENTS_MAX_FILES) {
|
|
1692
|
+
throw new Error(`${dir} already holds ${SAVED_ATTACHMENTS_MAX_FILES} saved attachments; remove files there to save more`);
|
|
1693
|
+
}
|
|
1694
|
+
if (total + incoming > maxBytes) {
|
|
1695
|
+
throw new Error(`saved attachments in ${dir} would pass their ${maxBytes}-byte bound; remove files there to save more`);
|
|
1696
|
+
}
|
|
1697
|
+
};
|
|
1698
|
+
/**
|
|
1699
|
+
* One save at a time per directory, across messages and sessions: two
|
|
1700
|
+
* concurrent saves would each measure the same free space and both write.
|
|
1701
|
+
*/
|
|
1702
|
+
const saveChains = new Map();
|
|
1703
|
+
const serialized = (key, run) => {
|
|
1704
|
+
const previous = saveChains.get(key) ?? Promise.resolve();
|
|
1705
|
+
const next = previous.catch(() => undefined).then(run);
|
|
1706
|
+
const settled = next.catch(() => undefined);
|
|
1707
|
+
saveChains.set(key, settled);
|
|
1708
|
+
void settled.then(() => {
|
|
1709
|
+
if (saveChains.get(key) === settled) {
|
|
1710
|
+
saveChains.delete(key);
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
return next;
|
|
1714
|
+
};
|
|
1715
|
+
/**
|
|
1716
|
+
* The saver for one agent and one sender. The directory is canonical
|
|
1717
|
+
* because the ledger is keyed the way `fs.read` looks a path up, through
|
|
1718
|
+
* `realpath`. The path is recorded before the bytes land, so a crash
|
|
1719
|
+
* between the two leaves a labelled path with no file, never a file with
|
|
1720
|
+
* no label; and the file is created exclusively, so a link planted at the
|
|
1721
|
+
* name fails the save rather than carrying the bytes somewhere the ledger
|
|
1722
|
+
* never saw.
|
|
1723
|
+
*/
|
|
1724
|
+
const createAttachmentSaver = (store, agentId, trust, now) => async (file, body) => {
|
|
1725
|
+
const workspace = store.workspaces.prepare(agentId);
|
|
1726
|
+
const lexical = path.join(workspace, SAVED_ATTACHMENTS_DIR);
|
|
1727
|
+
// One component at a time, never through a link: a recursive mkdir would
|
|
1728
|
+
// follow a link planted at `attachments` and create `slack` wherever it
|
|
1729
|
+
// points before the check below could refuse it.
|
|
1730
|
+
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
|
1731
|
+
let at = workspace;
|
|
1732
|
+
for (const part of SAVED_ATTACHMENTS_DIR.split(path.sep)) {
|
|
1733
|
+
at = path.join(at, part);
|
|
1734
|
+
await mkdir(at, { mode: 0o700 }).catch((error) => {
|
|
1735
|
+
if (error.code !== 'EEXIST') {
|
|
1736
|
+
throw error;
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
const info = await lstat(at);
|
|
1740
|
+
if (!info.isDirectory()) {
|
|
1741
|
+
throw new Error(`${at} is not a directory of the workspace's own`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
// The directory must be exactly where the workspace says, through no link
|
|
1745
|
+
// of its own: one already planted at `attachments` or `attachments/slack`
|
|
1746
|
+
// would put the file outside the workspace, where the agent's file tools
|
|
1747
|
+
// cannot read it and nothing should be written on a Slack member's say.
|
|
1748
|
+
const dir = await realpath(lexical);
|
|
1749
|
+
if (dir !== path.join(await realpath(workspace), SAVED_ATTACHMENTS_DIR)) {
|
|
1750
|
+
throw new Error(`${lexical} is a link, not the workspace's own directory`);
|
|
1751
|
+
}
|
|
1752
|
+
return serialized(dir, async () => {
|
|
1753
|
+
await ensureRoom(dir, body.length, store.maxBytes ?? SAVED_ATTACHMENTS_MAX_BYTES);
|
|
1754
|
+
const target = path.join(dir, savedAttachmentName(file, now()));
|
|
1755
|
+
await store.ledger.recordWrite(agentId, target, trust);
|
|
1756
|
+
// O_EXCL and O_NOFOLLOW guard only the last component. A directory on the
|
|
1757
|
+
// way swapped for a link after the record would send the create somewhere
|
|
1758
|
+
// else, so the open file is checked against the recorded name before a
|
|
1759
|
+
// byte is written (the same check as the plugins package's
|
|
1760
|
+
// `nameIdentifiesHandle`). A refused save leaves the empty file rather
|
|
1761
|
+
// than unlinking through a name that just proved it can move.
|
|
1762
|
+
const handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
1763
|
+
try {
|
|
1764
|
+
const [canonical, atName, held] = await Promise.all([
|
|
1765
|
+
realpath(target).catch(() => undefined),
|
|
1766
|
+
lstat(target).catch(() => undefined),
|
|
1767
|
+
handle.stat(),
|
|
1768
|
+
]);
|
|
1769
|
+
if (canonical !== target || atName === undefined || atName.dev !== held.dev || atName.ino !== held.ino) {
|
|
1770
|
+
throw new Error(`${target} moved between its provenance record and its write`);
|
|
1771
|
+
}
|
|
1772
|
+
await handle.writeFile(body);
|
|
1773
|
+
}
|
|
1774
|
+
finally {
|
|
1775
|
+
await handle.close();
|
|
1776
|
+
}
|
|
1777
|
+
return target;
|
|
1778
|
+
});
|
|
1779
|
+
};
|
|
1634
1780
|
const TEXT_APPLICATION_TYPES = new Set([
|
|
1635
1781
|
'application/json',
|
|
1636
1782
|
'application/xml',
|
|
@@ -1668,8 +1814,9 @@ const isTextAttachment = (file) => {
|
|
|
1668
1814
|
* caps, or comes back as a page stays in `unread` and is named in the
|
|
1669
1815
|
* note, exactly as before; the warning says why.
|
|
1670
1816
|
*/
|
|
1671
|
-
const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn, reasons) => {
|
|
1817
|
+
const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadline, warn, reasons, save) => {
|
|
1672
1818
|
const texts = [];
|
|
1819
|
+
const saved = [];
|
|
1673
1820
|
const unread = [];
|
|
1674
1821
|
const skip = (file, reason) => {
|
|
1675
1822
|
unread.push(file);
|
|
@@ -1691,8 +1838,13 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
|
|
|
1691
1838
|
continue;
|
|
1692
1839
|
}
|
|
1693
1840
|
const maxBytes = Math.min(TEXT_ATTACHMENT_MAX_BYTES, TEXT_ATTACHMENTS_MAX_TOTAL_BYTES - total);
|
|
1694
|
-
|
|
1695
|
-
|
|
1841
|
+
// Over the inline cap, a file is still downloaded when it can be saved
|
|
1842
|
+
// to the workspace instead; only over that cap too is it a name.
|
|
1843
|
+
const limit = save === undefined ? maxBytes : Math.max(maxBytes, TEXT_ATTACHMENT_SAVE_MAX_BYTES);
|
|
1844
|
+
if (file.size !== undefined && file.size > limit) {
|
|
1845
|
+
warn(save === undefined
|
|
1846
|
+
? `slack: ${fileLabel(file)} is ${file.size} bytes, over the ${maxBytes} bytes of text this message could still take; the turn is told it cannot be read. Paste the part that matters, or split the file.`
|
|
1847
|
+
: `slack: ${fileLabel(file)} is ${file.size} bytes, over the ${limit} bytes a text attachment may be saved at; the turn is told it cannot be read. Paste the part that matters, or split the file.`);
|
|
1696
1848
|
skip(file, 'too-large');
|
|
1697
1849
|
continue;
|
|
1698
1850
|
}
|
|
@@ -1703,7 +1855,7 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
|
|
|
1703
1855
|
}
|
|
1704
1856
|
let download;
|
|
1705
1857
|
try {
|
|
1706
|
-
download = await fetchFile(url, botToken, deadline,
|
|
1858
|
+
download = await fetchFile(url, botToken, deadline, limit);
|
|
1707
1859
|
}
|
|
1708
1860
|
catch (error) {
|
|
1709
1861
|
const reason = error instanceof Error && error.name === 'TimeoutError'
|
|
@@ -1722,8 +1874,8 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
|
|
|
1722
1874
|
skip(file, 'failed');
|
|
1723
1875
|
continue;
|
|
1724
1876
|
}
|
|
1725
|
-
if (download.truncated === true || download.body.length >
|
|
1726
|
-
warn(`slack: ${fileLabel(file)} is larger than the ${
|
|
1877
|
+
if (download.truncated === true || download.body.length > limit) {
|
|
1878
|
+
warn(`slack: ${fileLabel(file)} is larger than the ${limit} bytes ${save === undefined ? 'of text this message could still take' : 'a text attachment may be saved at'}; the download was abandoned and the turn is told it cannot be read.`);
|
|
1727
1879
|
skip(file, 'too-large');
|
|
1728
1880
|
continue;
|
|
1729
1881
|
}
|
|
@@ -1736,11 +1888,24 @@ const readTextAttachments = async (files, botToken, fetchFile, timeoutMs, deadli
|
|
|
1736
1888
|
skip(file, 'unreadable');
|
|
1737
1889
|
continue;
|
|
1738
1890
|
}
|
|
1891
|
+
if (download.body.length > maxBytes) {
|
|
1892
|
+
// Only reachable with a saver: `limit` is `maxBytes` without one.
|
|
1893
|
+
try {
|
|
1894
|
+
saved.push({ name: fileLabel(file), path: await save(file, download.body), bytes: download.body.length });
|
|
1895
|
+
}
|
|
1896
|
+
catch (error) {
|
|
1897
|
+
warn(`slack: could not save ${fileLabel(file)} to the agent's workspace: ${error instanceof Error ? error.message : String(error)}. The turn is told it cannot be read.`);
|
|
1898
|
+
skip(file, 'too-large');
|
|
1899
|
+
}
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1739
1902
|
total += download.body.length;
|
|
1740
1903
|
texts.push({ name: fileLabel(file), text });
|
|
1741
1904
|
}
|
|
1742
|
-
return { texts, unread };
|
|
1905
|
+
return { texts, saved, unread };
|
|
1743
1906
|
};
|
|
1907
|
+
/** Saved attachments, named with where each is, after what was said. */
|
|
1908
|
+
const savedAttachmentBlocks = (saved) => saved.map(({ name, path: at, bytes }) => `\n[Attached: ${name}, ${Math.ceil(bytes / 1024)} KB, too large to include here. It is saved in full at ${at}. Read it with your file tools, in parts if it is long.]`).join('');
|
|
1744
1909
|
/** Read text attachments, set into the message after what was said. */
|
|
1745
1910
|
const textAttachmentBlocks = (texts) => texts.map(({ name, text }) => `\n[Attached: ${name}. Its contents follow.]\n${text.replace(/\s+$/, '')}\n[End of ${name}]`).join('');
|
|
1746
1911
|
/**
|
|
@@ -2065,6 +2230,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2065
2230
|
const botIdentities = new Map();
|
|
2066
2231
|
// Rendered approval requests, keyed by the gateway's request id — the
|
|
2067
2232
|
// same id the buttons carry back.
|
|
2233
|
+
//
|
|
2234
|
+
// In memory only, and that is an open gap: a prompt posted by a process
|
|
2235
|
+
// that died is never retracted unless someone clicks it (a click on a
|
|
2236
|
+
// prompt this process never posted rewrites it from the click's own
|
|
2237
|
+
// coordinates). Retracting the ones nobody clicks needs a durable index
|
|
2238
|
+
// of these posts that survives the process that made them.
|
|
2068
2239
|
const approvalPosts = new Map();
|
|
2069
2240
|
// Credential requests whose post is in flight: Slack can show the message,
|
|
2070
2241
|
// and take a click on it, before postMessage resolves here. The same
|
|
@@ -2953,7 +3124,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2953
3124
|
if (!gateway) {
|
|
2954
3125
|
return;
|
|
2955
3126
|
}
|
|
2956
|
-
|
|
3127
|
+
// A delegated call is asked where the work was asked for: the
|
|
3128
|
+
// delegating conversation, through the app and approvers of the agent
|
|
3129
|
+
// at the root of the chain. The agent doing the work may have no
|
|
3130
|
+
// connection here at all, and its sub-session has no conversation of
|
|
3131
|
+
// its own — asked the old way, nobody saw it and the turn sat out the
|
|
3132
|
+
// whole timeout. The gateway makes such a call one-shot, so routing it
|
|
3133
|
+
// through another agent's approvers never grants anything standing.
|
|
3134
|
+
const origin = event.delegatedFrom;
|
|
3135
|
+
const viaOrigin = origin ? connectionFor(origin.agentId) : undefined;
|
|
3136
|
+
const connection = viaOrigin ?? connectionFor(event.agentId);
|
|
2957
3137
|
if (!connection) {
|
|
2958
3138
|
// Two different situations, and only one of them is this adapter's
|
|
2959
3139
|
// to answer.
|
|
@@ -2968,14 +3148,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2968
3148
|
// agent — denying here would let Slack refuse a question somebody
|
|
2969
3149
|
// else was going to ask. `stratus serve` reports agents that no
|
|
2970
3150
|
// channel can ask for, at startup, where the whole roster is visible.
|
|
2971
|
-
if (configuredAgents.has(event.agentId)) {
|
|
2972
|
-
warn(`slack: ${event.agentId} has no live connection; denying ${event.call.toolName}`);
|
|
3151
|
+
if (configuredAgents.has(event.agentId) || (origin && configuredAgents.has(origin.agentId))) {
|
|
3152
|
+
warn(`slack: ${origin?.agentId ?? event.agentId} has no live connection; denying ${event.call.toolName}`);
|
|
2973
3153
|
gateway.resolveApproval({ requestId: event.requestId, answer: 'deny', reason: 'undeliverable' });
|
|
2974
3154
|
}
|
|
2975
3155
|
return;
|
|
2976
3156
|
}
|
|
2977
3157
|
const approvers = new Set((connection.config.approvers ?? []).filter((id) => id.length > 0));
|
|
2978
|
-
const metadata = event.metadata ?? {};
|
|
3158
|
+
const metadata = (viaOrigin ? origin?.metadata : event.metadata) ?? {};
|
|
2979
3159
|
const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
|
|
2980
3160
|
? metadata.slackChannel
|
|
2981
3161
|
: undefined;
|
|
@@ -2989,14 +3169,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2989
3169
|
gateway.resolveApproval({ requestId: event.requestId, answer: 'deny', reason: 'undeliverable' });
|
|
2990
3170
|
};
|
|
2991
3171
|
if (approvers.size === 0) {
|
|
2992
|
-
decline(`no approvers configured for ${
|
|
3172
|
+
decline(`no approvers configured for ${connection.config.agentId}`);
|
|
2993
3173
|
return;
|
|
2994
3174
|
}
|
|
2995
3175
|
if (!channel) {
|
|
2996
|
-
decline(`no conversation to ask in for ${
|
|
3176
|
+
decline(`no conversation to ask in for ${connection.config.agentId}`);
|
|
2997
3177
|
return;
|
|
2998
3178
|
}
|
|
2999
|
-
const
|
|
3179
|
+
const nameOf = (agentId) => gateway.agents().find((agent) => agent.id === agentId)?.name ?? agentId;
|
|
3180
|
+
// "Quinn (for Nova)": the approver is being asked about Quinn's call,
|
|
3181
|
+
// by Nova's app, in Nova's conversation, and should be told both.
|
|
3182
|
+
const agentName = viaOrigin && origin && origin.agentId !== event.agentId
|
|
3183
|
+
? `${nameOf(event.agentId)} (for ${nameOf(origin.agentId)})`
|
|
3184
|
+
: nameOf(event.agentId);
|
|
3000
3185
|
// The notification preview is all some approvers see before deciding
|
|
3001
3186
|
// whether to open the thread, so it carries the arguments too — just
|
|
3002
3187
|
// the short form.
|
|
@@ -3277,7 +3462,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3277
3462
|
// "Said nothing" as the session can tell it: no reply saved for the
|
|
3278
3463
|
// turn. A line the old process had streamed into a placeholder and
|
|
3279
3464
|
// never saved is invisible here — the session records what was
|
|
3280
|
-
// said, not what Slack took (
|
|
3465
|
+
// said, not what Slack took (the open delivery-record item at the
|
|
3466
|
+
// gateway's lastSpokeAt) — so such a
|
|
3281
3467
|
// turn is failed without a note under that line. Taken over the
|
|
3282
3468
|
// alternative on purpose: a note for every judged turn a restart
|
|
3283
3469
|
// caught turns a silent turn, the common case, into the
|
|
@@ -3345,9 +3531,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3345
3531
|
return { said: routing.unaddressed === true, landed };
|
|
3346
3532
|
}
|
|
3347
3533
|
// A reply the thread's other agents hear like any other, taking their
|
|
3348
|
-
// place for it before the post, as `handleInbound` does.
|
|
3534
|
+
// place for it before the post, as `handleInbound` does. A DM thread
|
|
3535
|
+
// has nobody else in it to hear, as on the live path.
|
|
3349
3536
|
const published = postAheadOf(behind, connection, channel, thread, posting, uploads);
|
|
3350
|
-
const
|
|
3537
|
+
const conversation = metadata[CONVERSATION_METADATA_KEY];
|
|
3538
|
+
const isDm = typeof conversation === 'object' && conversation !== null && !Array.isArray(conversation)
|
|
3539
|
+
&& conversation.kind === 'direct';
|
|
3540
|
+
const heard = routing.reply !== undefined && thread !== undefined && !isDm
|
|
3351
3541
|
? overhearReply(connection, channel, thread, routing.reply, routing, published)
|
|
3352
3542
|
: undefined;
|
|
3353
3543
|
const delivered = await published;
|
|
@@ -4046,9 +4236,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4046
4236
|
const isDm = event.channel_type === 'im';
|
|
4047
4237
|
const team = args.body?.team_id ?? connection.teamId;
|
|
4048
4238
|
// A top-level mention has no thread_ts; its own ts roots the reply
|
|
4049
|
-
// thread that becomes the conversation.
|
|
4050
|
-
// peer, unkeyed by thread
|
|
4051
|
-
|
|
4239
|
+
// thread that becomes the conversation. A DM's top level is one
|
|
4240
|
+
// conversation per peer, unkeyed by thread; a thread inside the DM is a
|
|
4241
|
+
// conversation of its own, answered in that thread.
|
|
4242
|
+
const thread = isDm ? event.thread_ts : event.thread_ts ?? event.ts;
|
|
4052
4243
|
// Threads only: a DM has one agent by construction.
|
|
4053
4244
|
const threadKey = thread !== undefined && !isDm
|
|
4054
4245
|
? threadKeyFor({ team, conversation: event.channel, thread })
|
|
@@ -4371,20 +4562,22 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4371
4562
|
const { images, unread: notImages } = overhear || judged
|
|
4372
4563
|
? { images: [], unread: event.files ?? [] }
|
|
4373
4564
|
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn, reasons, deadline);
|
|
4374
|
-
const { texts, unread } = overhear || judged
|
|
4375
|
-
? { texts: [], unread: notImages }
|
|
4376
|
-
: await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn, reasons
|
|
4565
|
+
const { texts, saved, unread } = overhear || judged
|
|
4566
|
+
? { texts: [], saved: [], unread: notImages }
|
|
4567
|
+
: await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn, reasons, options.attachmentStore
|
|
4568
|
+
? createAttachmentSaver(options.attachmentStore, connection.config.agentId, senderTrustFor(connection.config, userId), options.now ?? Date.now)
|
|
4569
|
+
: undefined);
|
|
4377
4570
|
// An image or a document with nothing said is still a question
|
|
4378
4571
|
// ("what's this?"); a file the turn could not open with nothing said
|
|
4379
4572
|
// is not one, and gets no reply.
|
|
4380
|
-
if (cleaned.length === 0 && images.length === 0 && texts.length === 0) {
|
|
4573
|
+
if (cleaned.length === 0 && images.length === 0 && texts.length === 0 && saved.length === 0) {
|
|
4381
4574
|
return undefined;
|
|
4382
4575
|
}
|
|
4383
4576
|
const author = await authorFor(connection, userId);
|
|
4384
4577
|
// In shared channels the model should know who is speaking; a DM is
|
|
4385
4578
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
4386
4579
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
4387
|
-
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${attachmentNote(unread, reasons)}`;
|
|
4580
|
+
const userMessage = `${spoken}${textAttachmentBlocks(texts)}${savedAttachmentBlocks(saved)}${attachmentNote(unread, reasons)}`;
|
|
4388
4581
|
// Who sent this, judged against the operator's list — per message,
|
|
4389
4582
|
// never remembered from the first one in the thread. A DM proves
|
|
4390
4583
|
// nothing about who is typing, so a DM from an unlisted member is
|
|
@@ -4403,7 +4596,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4403
4596
|
: {
|
|
4404
4597
|
...room,
|
|
4405
4598
|
...(room.kind === 'direct' && senderTrust === 'user' ? { with: author } : {}),
|
|
4406
|
-
|
|
4599
|
+
// A DM thread is a thread too, with its own session; core takes
|
|
4600
|
+
// no root for a direct room, since nothing posts to one by id.
|
|
4601
|
+
...(thread !== undefined ? { thread: true, ...(room.kind !== 'direct' ? { threadRoot: thread } : {}) } : {}),
|
|
4407
4602
|
// People outside this workspace read a Slack Connect channel, and
|
|
4408
4603
|
// the event says so itself, even when the lookup failed.
|
|
4409
4604
|
...(room.kind !== 'direct' && args.body?.is_ext_shared_channel === true ? { shared: true } : {}),
|
|
@@ -4449,7 +4644,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4449
4644
|
// said before this. Inside the chain, before the placeholder, so the
|
|
4450
4645
|
// read cannot reorder this message against the next one.
|
|
4451
4646
|
let earlier = [];
|
|
4452
|
-
if (
|
|
4647
|
+
if (thread !== undefined) {
|
|
4453
4648
|
// A top-level mention roots its own session, so it is new but for
|
|
4454
4649
|
// a redelivery, which the gateway answers without reading this.
|
|
4455
4650
|
let exists = false;
|
|
@@ -4483,7 +4678,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4483
4678
|
// while the tool it preceded is still running. Under the session's
|
|
4484
4679
|
// label as stored, lowered by anything that tainted it during this
|
|
4485
4680
|
// turn, and the least trusted label where it cannot be read.
|
|
4486
|
-
|
|
4681
|
+
// A DM thread has nobody else in it to hear.
|
|
4682
|
+
...(thread !== undefined && !isDm
|
|
4487
4683
|
? {
|
|
4488
4684
|
onInterim: (heard, landed) => {
|
|
4489
4685
|
// Tracked, so a shutdown drains it like any other reaction:
|
|
@@ -4664,7 +4860,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4664
4860
|
replyLanded = outcome.published;
|
|
4665
4861
|
return true;
|
|
4666
4862
|
}, () => true);
|
|
4667
|
-
const heard = thread !== undefined
|
|
4863
|
+
const heard = thread !== undefined && !isDm
|
|
4668
4864
|
? overhearReply(connection, event.channel, thread, () => renderer.spokenText(replyLanded ? reply ?? '' : ''), session, settled)
|
|
4669
4865
|
: undefined;
|
|
4670
4866
|
const { spoke, spokeAt } = await finalized;
|
|
@@ -4689,7 +4885,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4689
4885
|
await heard;
|
|
4690
4886
|
}
|
|
4691
4887
|
else {
|
|
4692
|
-
|
|
4888
|
+
// A paused daemon's refusal is its answer, said as it is: the
|
|
4889
|
+
// operator wrote that sentence for the person reading it, and it is
|
|
4890
|
+
// not a failure of the agent. Matched by name, as this package does
|
|
4891
|
+
// not import the gateway. A turn nobody asked for stays silent.
|
|
4892
|
+
const pausedReply = failure instanceof Error && failure.name === 'IntakePausedError';
|
|
4893
|
+
const { spoke, spokeAt } = pausedReply
|
|
4894
|
+
? await renderer.finalize(renderer.lazy ? '' : failure.message)
|
|
4895
|
+
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
4693
4896
|
if (spoke && threadKey !== undefined) {
|
|
4694
4897
|
// A file it posted before breaking is still the last thing said.
|
|
4695
4898
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
@@ -4831,7 +5034,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4831
5034
|
continue;
|
|
4832
5035
|
}
|
|
4833
5036
|
try {
|
|
4834
|
-
const
|
|
5037
|
+
const created = createWeb(config.botToken);
|
|
5038
|
+
const web = config.linkPreviews === false ? withoutLinkPreviews(created) : created;
|
|
4835
5039
|
const auth = await web.auth.test();
|
|
4836
5040
|
const botUserId = auth.user_id ?? '';
|
|
4837
5041
|
const teamId = auth.team_id ?? '';
|