@fastagent-sh/fastagent 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
@@ -12,6 +12,16 @@ export interface DownloadedSlackFile {
12
12
  name: string;
13
13
  size: number;
14
14
  }
15
+ export interface UploadedSlackFile {
16
+ id: string;
17
+ name: string;
18
+ }
19
+ export interface SentSlackMessage {
20
+ /** The first message's ts (a long Markdown body continues in further messages). */
21
+ ts: string;
22
+ /** Where it landed: a user-id target (`U…`) resolves to that user's DM channel (`D…`). */
23
+ channelId: string;
24
+ }
15
25
  export declare class SlackApiError extends Error {
16
26
  readonly method: string;
17
27
  readonly status: number;
@@ -24,7 +34,7 @@ export declare class SlackApiError extends Error {
24
34
  * internal, and timeout failures are ambiguous: Slack may already have created the stream. */
25
35
  export declare function isSlackNativeUnavailable(error: unknown): boolean;
26
36
  export interface SlackApiOptions {
27
- botToken: string | (() => Promise<string>);
37
+ botToken: string;
28
38
  baseUrl?: string;
29
39
  }
30
40
  export interface SlackApi {
@@ -38,7 +48,9 @@ export interface SlackApi {
38
48
  updateMessage(channelId: string, ts: string, text: string): Promise<void>;
39
49
  updateMarkdown(channelId: string, ts: string, markdown: string): Promise<void>;
40
50
  deleteMessage(channelId: string, ts: string): Promise<void>;
41
- sendMarkdown(target: SlackTarget, markdown: string): Promise<string | undefined>;
51
+ /** Post standard Markdown, split under Slack's limit. `target.channelId` may be a user id: Slack
52
+ * then opens (or reuses) the app's DM with that user, and the result names it. */
53
+ sendMarkdown(target: SlackTarget, markdown: string): Promise<SentSlackMessage>;
42
54
  startStream(target: SlackTarget, markdown?: string): Promise<string>;
43
55
  appendStream(channelId: string, ts: string, markdown: string): Promise<void>;
44
56
  stopStream(channelId: string, ts: string, markdown?: string): Promise<void>;
@@ -49,6 +61,12 @@ export interface SlackApi {
49
61
  fileInfo(fileId: string): Promise<SlackFile>;
50
62
  fetchImage(file: SlackFile): Promise<ImageRef>;
51
63
  fetchFile(file: SlackFile, channelId: string, filesDir: string): Promise<DownloadedSlackFile>;
64
+ /** Upload one local file into a channel/thread through Slack's external-upload protocol
65
+ * (getUploadURLExternal → bytes → completeUploadExternal). */
66
+ uploadFile(target: SlackTarget, path: string, options?: {
67
+ title?: string;
68
+ initialComment?: string;
69
+ }): Promise<UploadedSlackFile>;
52
70
  }
53
71
  /** Split a Slack text/Markdown field at a code-point-safe boundary, preferring a newline. */
54
72
  export declare function chunkSlackText(text: string, maxPoints?: number): string[];
@@ -1,13 +1,14 @@
1
- /** Slack Web API transport: one JSON pipeline plus authenticated, capped private-file downloads. */
2
- import { mkdir, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
1
+ /** Slack Web API transport: one JSON pipeline, authenticated capped private-file downloads, and the
2
+ * external-upload protocol. */
3
+ import { mkdir, open, stat, writeFile } from "node:fs/promises";
4
+ import { basename } from "node:path";
5
+ import { attachmentPath } from "../kit/attachment-path.js";
4
6
  import { codePointPrefix } from "../kit/text.js";
5
7
  const API_TIMEOUT_MS = 30_000;
6
- const DOWNLOAD_TIMEOUT_MS = 120_000;
8
+ const FILE_TRANSFER_TIMEOUT_MS = 120_000;
7
9
  const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
8
10
  const RETRIES = 3;
9
11
  const MAX_RETRY_AFTER_S = 30;
10
- const MAX_REDIRECTS = 3;
11
12
  /** Slack's standard-Markdown fields cap each call at 12,000 characters. Keep headroom for
12
13
  * code-fence balancing and future server-side transformations. */
13
14
  const SLACK_MAX_MARKDOWN = 10_000;
@@ -95,11 +96,6 @@ export function chunkSlackMarkdown(markdown, maxPoints = SLACK_MAX_MARKDOWN) {
95
96
  }
96
97
  return output;
97
98
  }
98
- function safeFileName(file) {
99
- const raw = file.name ?? file.title ?? file.id ?? "file";
100
- const safe = raw.replace(/[/\\]/g, "_").replace(/^\.+/, "_") || "file";
101
- return `${file.id ?? "slack"}-${safe}`;
102
- }
103
99
  function fileDownloadUrl(file) {
104
100
  if (file.file_access === "check_file_info")
105
101
  throw new Error(`Slack file ${file.id ?? "(unknown)"} is not ready`);
@@ -138,25 +134,17 @@ async function readBytesCapped(response) {
138
134
  }
139
135
  return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total);
140
136
  }
137
+ /** A rotating token (an app created by a release up to 0.20) would work for up to 12 hours and then
138
+ * fail as `token_expired`, far from the cause — refuse it where the token enters, on both paths. */
139
+ function assertLongLivedBotToken(botToken) {
140
+ if (botToken.startsWith("xoxe.")) {
141
+ throw new Error("got a rotating Slack bot token (xoxe.…); this release uses a long-lived xoxb- token — " +
142
+ 'create a new app (docs/slack.md → "Upgrading from a rotating-token app")');
143
+ }
144
+ }
141
145
  export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" }) {
146
+ assertLongLivedBotToken(botToken);
142
147
  const apiBase = baseUrl.replace(/\/$/, "");
143
- const configuredOrigin = new URL(apiBase).origin;
144
- const currentToken = typeof botToken === "string" ? async () => botToken : botToken;
145
- const trustedDownloadUrl = (value) => {
146
- const url = new URL(value);
147
- const host = url.hostname.toLowerCase();
148
- const slackHost = host === "slack-files.com" ||
149
- host.endsWith(".slack-files.com") ||
150
- host === "slack.com" ||
151
- host.endsWith(".slack.com") ||
152
- host === "slack-edge.com" ||
153
- host.endsWith(".slack-edge.com");
154
- if (!slackHost && url.origin !== configuredOrigin)
155
- throw new Error(`refusing non-Slack file URL host ${host}`);
156
- if (url.protocol !== "https:" && url.origin !== configuredOrigin)
157
- throw new Error("refusing non-HTTPS Slack file URL");
158
- return url;
159
- };
160
148
  const call = async (method, body, httpMethod = "POST") => {
161
149
  const url = new URL(`${apiBase}/${method}`);
162
150
  if (httpMethod === "GET") {
@@ -169,11 +157,10 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
169
157
  let response;
170
158
  let raw;
171
159
  try {
172
- const token = await currentToken();
173
160
  response = await fetch(url, {
174
161
  method: httpMethod,
175
162
  headers: {
176
- authorization: `Bearer ${token}`,
163
+ authorization: `Bearer ${botToken}`,
177
164
  ...(httpMethod === "POST" ? { "content-type": "application/json; charset=utf-8" } : {}),
178
165
  },
179
166
  ...(httpMethod === "POST" ? { body: JSON.stringify(body) } : {}),
@@ -211,36 +198,38 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
211
198
  throw new SlackApiError(method, response.status, detail, data.error);
212
199
  }
213
200
  };
201
+ // Slack owns both halves of this download: the URL comes from its own files.info response, and it
202
+ // documents url_private as taking our bearer token. Where that URL leads is Slack's call; whether a
203
+ // redirect hop still carries the token is fetch's, which drops the header cross-origin.
214
204
  const download = async (file) => {
215
- let url = trustedDownloadUrl(fileDownloadUrl(file));
216
- for (let redirect = 0;; redirect++) {
217
- let response;
218
- try {
219
- const token = await currentToken();
220
- response = await fetch(url, {
221
- headers: { authorization: `Bearer ${token}` },
222
- redirect: "manual",
223
- signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
224
- });
225
- }
226
- catch (error) {
227
- throw new SlackApiError("file download", 0, String(error), undefined, { cause: error });
228
- }
229
- if (response.status >= 300 && response.status < 400) {
230
- const location = response.headers.get("location");
231
- if (!location || redirect >= MAX_REDIRECTS) {
232
- throw new SlackApiError("file download", response.status, "invalid or excessive redirect");
233
- }
234
- await response.body?.cancel().catch(() => { });
235
- url = trustedDownloadUrl(new URL(location, url).toString());
236
- continue;
237
- }
238
- if (!response.ok) {
239
- const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
240
- throw new SlackApiError("file download", response.status, detail);
241
- }
242
- return { bytes: await readBytesCapped(response), contentType: response.headers.get("content-type") ?? undefined };
205
+ let response;
206
+ try {
207
+ response = await fetch(fileDownloadUrl(file), {
208
+ headers: { authorization: `Bearer ${botToken}` },
209
+ signal: AbortSignal.timeout(FILE_TRANSFER_TIMEOUT_MS),
210
+ });
243
211
  }
212
+ catch (error) {
213
+ throw new SlackApiError("file download", 0, String(error), undefined, { cause: error });
214
+ }
215
+ if (!response.ok) {
216
+ const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
217
+ throw new SlackApiError("file download", response.status, detail);
218
+ }
219
+ return { bytes: await readBytesCapped(response), contentType: response.headers.get("content-type") ?? undefined };
220
+ };
221
+ const postMarkdown = async (target, markdown) => {
222
+ const data = await call("chat.postMessage", {
223
+ channel: target.channelId,
224
+ markdown_text: markdown,
225
+ ...(target.threadTs ? { thread_ts: target.threadTs } : {}),
226
+ unfurl_links: false,
227
+ unfurl_media: false,
228
+ });
229
+ if (!data.ts)
230
+ throw new SlackApiError("chat.postMessage", 200, "response carried no ts");
231
+ // The response's `channel` is what resolves a user-id target to its DM; a channel target echoes itself.
232
+ return { ts: data.ts, channelId: data.channel ?? target.channelId };
244
233
  };
245
234
  const api = {
246
235
  async authTest() {
@@ -260,16 +249,7 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
260
249
  return data.ts;
261
250
  },
262
251
  async postMarkdown(target, markdown) {
263
- const data = await call("chat.postMessage", {
264
- channel: target.channelId,
265
- markdown_text: markdown,
266
- ...(target.threadTs ? { thread_ts: target.threadTs } : {}),
267
- unfurl_links: false,
268
- unfurl_media: false,
269
- });
270
- if (!data.ts)
271
- throw new SlackApiError("chat.postMessage", 200, "response carried no ts");
272
- return data.ts;
252
+ return (await postMarkdown(target, markdown)).ts;
273
253
  },
274
254
  async updateMessage(channelId, ts, text) {
275
255
  try {
@@ -297,10 +277,11 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
297
277
  async sendMarkdown(target, markdown) {
298
278
  let first;
299
279
  for (const chunk of chunkSlackMarkdown(markdown)) {
300
- const ts = await api.postMarkdown(target, chunk);
301
- first ??= ts;
280
+ // Continuations go to the resolved channel: a user-id target must not reopen the DM per chunk.
281
+ const sent = await postMarkdown(first ? { ...target, channelId: first.channelId } : target, chunk);
282
+ first ??= sent;
302
283
  }
303
- return first;
284
+ return first; // chunkSlackMarkdown never yields zero chunks
304
285
  },
305
286
  async startStream(target, markdown) {
306
287
  if (!target.threadTs)
@@ -387,13 +368,59 @@ export function createSlackApi({ botToken, baseUrl = "https://slack.com/api" })
387
368
  },
388
369
  async fetchFile(file, channelId, filesDir) {
389
370
  const { bytes } = await download(file);
390
- const dir = join(filesDir, channelId);
371
+ // The id prefix keeps two same-named uploads in one channel apart.
372
+ const suggested = `${file.id ?? "slack"}-${file.name ?? file.title ?? file.id ?? "file"}`;
373
+ const { dir, name, path } = attachmentPath(filesDir, channelId, suggested);
391
374
  await mkdir(dir, { recursive: true });
392
- const name = safeFileName(file);
393
- const path = join(dir, name);
394
375
  await writeFile(path, bytes);
395
376
  return { path, name, size: bytes.byteLength };
396
377
  },
378
+ async uploadFile(target, path, options = {}) {
379
+ const info = await stat(path);
380
+ if (!info.isFile())
381
+ throw new Error(`${path} is not a regular file`);
382
+ const name = basename(path);
383
+ const upload = await call("files.getUploadURLExternal", {
384
+ filename: name,
385
+ length: info.size,
386
+ });
387
+ if (!upload.upload_url || !upload.file_id) {
388
+ throw new SlackApiError("files.getUploadURLExternal", 200, "response carried no upload_url/file_id");
389
+ }
390
+ // Where upload_url points is Slack's call, as with url_private above; the bytes carry no token.
391
+ const handle = await open(path, "r");
392
+ let response;
393
+ try {
394
+ response = await fetch(upload.upload_url, {
395
+ method: "POST",
396
+ headers: { "content-type": "application/octet-stream" },
397
+ body: handle.readableWebStream(),
398
+ signal: AbortSignal.timeout(FILE_TRANSFER_TIMEOUT_MS),
399
+ // Required by Node fetch for a streaming request body; not part of the browser RequestInit type.
400
+ duplex: "half",
401
+ });
402
+ }
403
+ catch (error) {
404
+ throw new SlackApiError("file upload", 0, String(error), undefined, { cause: error });
405
+ }
406
+ finally {
407
+ await handle.close().catch(() => { });
408
+ }
409
+ if (!response.ok) {
410
+ const detail = codePointPrefix(await response.text().catch(() => ""), 300) || "file bytes were rejected";
411
+ throw new SlackApiError("file upload", response.status, detail);
412
+ }
413
+ // At-least-once: if Slack commits this call but the response is lost, a retry may post a
414
+ // duplicate. Retrying here would hide that ambiguity, so only an explicit agent/user retry
415
+ // crosses this final side-effect boundary.
416
+ await call("files.completeUploadExternal", {
417
+ files: [{ id: upload.file_id, title: options.title ?? name }],
418
+ channel_id: target.channelId,
419
+ ...(options.initialComment ? { initial_comment: options.initialComment } : {}),
420
+ ...(target.threadTs ? { thread_ts: target.threadTs } : {}),
421
+ });
422
+ return { id: upload.file_id, name };
423
+ },
397
424
  };
398
425
  return api;
399
426
  }
@@ -8,16 +8,6 @@ export interface SlackChannelOptions {
8
8
  botToken: string;
9
9
  /** App signing secret used to verify the raw Events API request body. */
10
10
  signingSecret: string;
11
- /** Rotating OAuth credentials. Omit all four only for a manually managed long-lived bot token. */
12
- botRefreshToken?: string;
13
- clientId?: string;
14
- clientSecret?: string;
15
- botTokenExpiresAt?: number;
16
- /** `context` (default) subscribes to group message streams, which is what lets the channel HEAR a
17
- * thread: bare replies are then admitted by the participation rule (design/participant-model.md §3),
18
- * and other discussion is buffered. `mentions` answers only app_mention plus DMs for an explicit
19
- * least-privilege setup. */
20
- groupBehavior?: "context" | "mentions";
21
11
  /** `native` (default) uses Slack Agent streams for threaded replies. Its inline tool traces carry a
22
12
  * bounded summary of each call's first argument and cannot be retracted, so they stay in the
23
13
  * delivered message beside the answer. `classic` retains the compatibility renderer based on one
@@ -1,28 +1,32 @@
1
1
  /** First-party Slack HTTP Events API channel: signed ingress, durable turns/context, files, and edited previews. */
2
- import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { createHmac } from "node:crypto";
3
3
  import { isAbsolute, join } from "node:path";
4
4
  import { log } from "../../log.js";
5
5
  import { readBodyCapped } from "../body.js";
6
6
  import { text } from "../respond.js";
7
+ import { secretEquals } from "../secret.js";
7
8
  import { createSeenRing } from "../kit/seen.js";
9
+ import { signatureIsFresh } from "../kit/signature.js";
8
10
  import { createThreadParticipants } from "../kit/thread-participants.js";
9
11
  import { createTaskTracker } from "../kit/tasks.js";
10
12
  import { ensureStateHome } from "../kit/state.js";
11
13
  import { dispatchStop, isStopText } from "../kit/stop-command.js";
12
14
  import { codePointPrefix } from "../kit/text.js";
13
- import { createTurnQueue } from "../kit/turn-queue.js";
15
+ import { createTurnRunner } from "../kit/turn-runner.js";
14
16
  import { createTurnStore } from "../kit/turn-store.js";
15
- import { createSlackBotTokenProvider } from "./bot-auth.js";
17
+ import { discussionBlock } from "../kit/context-buffer.js";
16
18
  import { collectSlackBufferedFiles, createSlackContextBuffer } from "./context-buffer.js";
17
19
  import { invokeSlackTurn } from "./invoke-turn.js";
18
20
  import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, hasSlackMention, hasSlackUserMention, isSlackHumanMessage, mentionsSlackUser, stripSlackMentions, slackBufferText, slackEnvelope, slackFileIds, slackMessageText, slackPlaceKey, slackSenderLabel, slackTeamId, } from "./parse.js";
19
21
  import { defaultErrorMessage, settleSlackPreview, streamSlackReply, } from "./preview.js";
20
22
  import { resolveReactionEmojis, startSlackReaction } from "./reaction.js";
23
+ import { registerSlackApi } from "./shared-api.js";
21
24
  import { createSlackApi } from "./slack-api.js";
22
25
  import { createWelcomedUsers } from "./welcomed.js";
23
26
  export { defaultSlackRoute, slackEnvelope };
24
27
  const MAX_EVENT_BYTES = 1 << 20;
25
- const MAX_TURN_ATTEMPTS = 3;
28
+ /** Slack's own documented window — it re-signs every redelivery with a current timestamp, so a tight
29
+ * one costs nothing. The Feishu ingress reads hours off the same helper for the opposite reason. */
26
30
  const MAX_SIGNATURE_AGE_S = 5 * 60;
27
31
  const QUEUED_PLACEHOLDER = "⏳ Queued — I’ll start once the current task finishes.";
28
32
  const DEFERRED_PLACEHOLDER = "⏳ Delayed by a temporary system issue — I’ll retry automatically.";
@@ -45,15 +49,12 @@ function isStoredSlackTurn(value) {
45
49
  }
46
50
  /** Verify Slack's v0 HMAC over the exact raw body and reject timestamps outside the replay window. */
47
51
  export function verifySlackSignature(signingSecret, timestamp, signature, rawBody, nowMs = Date.now()) {
48
- if (!/^\d+$/.test(timestamp) || !/^v0=[a-f0-9]{64}$/i.test(signature))
52
+ if (!/^v0=[a-f0-9]{64}$/i.test(signature))
49
53
  return false;
50
- const seconds = Number(timestamp);
51
- if (!Number.isSafeInteger(seconds) || Math.abs(Math.floor(nowMs / 1000) - seconds) > MAX_SIGNATURE_AGE_S)
54
+ if (!signatureIsFresh(timestamp, MAX_SIGNATURE_AGE_S, nowMs))
52
55
  return false;
53
56
  const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${rawBody}`).digest("hex")}`;
54
- const actualBytes = Buffer.from(signature);
55
- const expectedBytes = Buffer.from(expected);
56
- return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
57
+ return secretEquals(signature, expected);
57
58
  }
58
59
  /** The (channel, ts) of the user's triggering message, encoded in the logical turn id `team:channel:ts`
59
60
  * (Slack ids never contain a colon, so a 3-part split is exact). Used to place the reaction ack. */
@@ -62,19 +63,7 @@ function messageRefOf(turnId) {
62
63
  return parts.length === 3 && parts[1] && parts[2] ? { channelId: parts[1], ts: parts[2] } : undefined;
63
64
  }
64
65
  export function slackChannel(options) {
65
- const { botToken, signingSecret, botRefreshToken, clientId, clientSecret, botTokenExpiresAt, groupBehavior = "context", rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
66
- // The participant model derives placement instead of selecting it: Slack has no quote primitive, so
67
- // answering in place means answering in a thread on the ask, whichever renderer draws it. An
68
- // upgraded workspace still passing one of the removed modes would otherwise start fine and silently
69
- // get a different placement AND a different memory boundary.
70
- const removedModes = ["directMessageSession", "groupMessageSession"].filter((name) => options[name] !== undefined);
71
- if (removedModes.length > 0) {
72
- throw new Error(`slackChannel no longer accepts ${removedModes.join(" / ")}: an answer goes in a thread on the ask, and ` +
73
- "that thread is the session — see docs/design/participant-model.md");
74
- }
75
- if (!["context", "mentions"].includes(groupBehavior)) {
76
- throw new Error('slackChannel groupBehavior must be "context" or "mentions"');
77
- }
66
+ const { botToken, signingSecret, rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
78
67
  if (!["native", "classic"].includes(rendering)) {
79
68
  throw new Error('slackChannel rendering must be "native" or "classic"');
80
69
  }
@@ -93,16 +82,8 @@ export function slackChannel(options) {
93
82
  const formatError = onError ?? defaultErrorMessage;
94
83
  const stateHome = join(stateRoot, "channels", "slack");
95
84
  ensureStateHome(stateHome);
96
- const currentBotToken = createSlackBotTokenProvider({
97
- statePath: join(stateHome, "bot-auth.json"),
98
- botToken,
99
- botRefreshToken,
100
- clientId,
101
- clientSecret,
102
- botTokenExpiresAt,
103
- apiBaseUrl,
104
- });
105
- const api = createSlackApi({ botToken: currentBotToken, baseUrl: apiBaseUrl });
85
+ const api = createSlackApi({ botToken, baseUrl: apiBaseUrl });
86
+ registerSlackApi(stateRoot, api); // the send tool delivers through this one (shared-api.ts)
106
87
  let authenticatedTeamId;
107
88
  let botUserId;
108
89
  let authenticationState = "pending";
@@ -134,6 +115,9 @@ export function slackChannel(options) {
134
115
  });
135
116
  });
136
117
  };
118
+ // Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle. Declared
119
+ // ahead of its consumers so no construction-time path can reach it in the temporal dead zone.
120
+ const sideTasks = createTaskTracker(label);
137
121
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
138
122
  const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
139
123
  /** A thread's participation is keyed by the SESSION it describes — "the agent answered here" is a
@@ -149,64 +133,66 @@ export function slackChannel(options) {
149
133
  order: (a, b) => a.seq - b.seq,
150
134
  });
151
135
  const decide = route ?? defaultSlackRoute;
152
- const toStored = (turn) => {
153
- const { previewTs: _preview, nativeQueueStatus: _status, ...intent } = turn;
154
- return { ...intent, attempts: 0 };
155
- };
156
136
  const targetOf = (turn) => ({
157
137
  channelId: turn.channelId,
158
138
  threadTs: turn.threadTs,
159
139
  recipientUserId: turn.requesterUserId,
160
140
  recipientTeamId: turn.teamId,
161
141
  });
162
- const notices = new Map();
163
- const queue = createTurnQueue({
142
+ const notifyDropped = (turn) => {
143
+ const target = targetOf(turn);
144
+ if (turn.nativeQueueStatus)
145
+ void api.setThreadStatus(target, "").catch(() => { });
146
+ void settleSlackPreview(api, target, turn.previewTs, "⚠️ I couldn’t complete an earlier request — please ask again.").catch((error) => log.warn(`${label} could not notify a dropped turn: ${String(error)}`));
147
+ };
148
+ const runner = createTurnRunner({
164
149
  label,
150
+ store,
151
+ buffer,
152
+ seen,
153
+ toStored: ({ previewTs: _preview, nativeQueueStatus: _status, ...intent }) => ({ ...intent, attempts: 0 }),
154
+ fromStored: ({ attempts: _attempts, ...intent }) => ({ ...intent }),
155
+ bufferKey: (turn) => turn.bufferKey,
156
+ where: (turn) => `channel=${turn.channelId}`,
165
157
  onQueuedBehind(turn) {
166
158
  const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
167
159
  if (nativeDmStatus)
168
160
  turn.nativeQueueStatus = true;
169
- notices.set(turn.id, authentication
170
- .then(() => nativeDmStatus
171
- ? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
172
- : api.postMessage(targetOf(turn), QUEUED_PLACEHOLDER).then((ts) => {
173
- turn.previewTs = ts;
174
- }))
175
- .catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)));
161
+ return {
162
+ done: authentication
163
+ .then(() => nativeDmStatus
164
+ ? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
165
+ : api.postMessage(targetOf(turn), QUEUED_PLACEHOLDER).then((ts) => {
166
+ turn.previewTs = ts;
167
+ }))
168
+ .catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)),
169
+ };
176
170
  },
177
- run: async (turn) => {
178
- await notices.get(turn.id);
179
- notices.delete(turn.id);
171
+ // Leave the intent untouched when Slack auth failed. A fixed token + restart replays it;
172
+ // running now would execute an Agent turn whose only customer-facing transport is known to be
173
+ // unavailable.
174
+ beforeRun: async (turn) => {
180
175
  try {
181
176
  await authentication;
177
+ return true;
182
178
  }
183
179
  catch (error) {
184
- // Leave the intent untouched. A fixed token + restart replays it; running now would execute an
185
- // Agent turn whose only customer-facing transport is known to be unavailable.
186
180
  log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
187
- return;
181
+ return false;
188
182
  }
189
- const attempt = store.startAttempt(turn.id, MAX_TURN_ATTEMPTS);
190
- if (attempt === "exceeded") {
191
- notifyDropped(turn);
192
- return;
183
+ },
184
+ onDeferred: (turn) => {
185
+ if (turn.nativeQueueStatus) {
186
+ void api
187
+ .setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
188
+ .catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
193
189
  }
194
- if (attempt === "defer") {
195
- if (turn.nativeQueueStatus) {
196
- void api
197
- .setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
198
- .catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
199
- }
200
- else if (turn.previewTs) {
201
- void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
202
- }
203
- return;
190
+ else if (turn.previewTs) {
191
+ void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
204
192
  }
205
- const startedAt = Date.now();
206
- log.info(`${label} turn start: turn=${turn.id} session=${turn.session} channel=${turn.channelId}`);
207
- const { text: recent, consumed } = buffer.peek(turn.bufferKey);
208
- const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${turn.baseText}` : turn.baseText;
209
- const buffered = collectSlackBufferedFiles(consumed, new Set(turn.fileIds));
193
+ },
194
+ notifyDropped,
195
+ execute: async (turn, discussion, onCompleted) => {
210
196
  const messageRef = messageRefOf(turn.id);
211
197
  const reaction = reactionEmojis && messageRef
212
198
  ? await startSlackReaction({
@@ -218,47 +204,25 @@ export function slackChannel(options) {
218
204
  })
219
205
  : undefined;
220
206
  try {
221
- await streamSlackReply(invokeSlackTurn(agent, turn.session, prompt, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, { primaryFileIds: turn.fileIds, buffered }, () => {
222
- store.remove(turn.id);
223
- buffer.commit(turn.bufferKey, consumed);
224
- }), api, targetOf(turn), formatError, {
207
+ await streamSlackReply(invokeSlackTurn(agent, turn.session, `${discussionBlock(discussion.text)}${turn.baseText}`, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, {
208
+ primaryFileIds: turn.fileIds,
209
+ buffered: collectSlackBufferedFiles(discussion.consumed, new Set(turn.fileIds)),
210
+ }, onCompleted), api, targetOf(turn), formatError, {
225
211
  rendering,
226
212
  initialPreviewTs: turn.previewTs,
227
213
  threadTitle: turn.threadTitle,
228
214
  disclaimer: aiDisclaimer,
229
215
  label,
230
216
  });
231
- log.info(`${label} turn done: turn=${turn.id} session=${turn.session} (${Date.now() - startedAt}ms)`);
232
- await reaction?.complete();
233
217
  }
234
218
  catch (error) {
235
- log.error(`${label} turn failed: turn=${turn.id} session=${turn.session}: ${String(error)}`);
236
219
  await reaction?.remove();
220
+ throw error;
237
221
  }
238
- finally {
239
- store.remove(turn.id);
240
- }
222
+ await reaction?.complete();
241
223
  },
242
224
  });
243
- const notifyDropped = (turn) => {
244
- const target = targetOf(turn);
245
- if (turn.nativeQueueStatus)
246
- void api.setThreadStatus(target, "").catch(() => { });
247
- void settleSlackPreview(api, target, turn.previewTs, "⚠️ I couldn’t complete an earlier request — please ask again.").catch((error) => log.warn(`${label} could not notify a dropped turn: ${String(error)}`));
248
- };
249
- const submit = (turn, persist) => {
250
- if (persist) {
251
- store.add(toStored(turn));
252
- seen.add(turn.id);
253
- }
254
- queue.accept(turn);
255
- };
256
- const recovered = store.recover();
257
- if (recovered.length)
258
- log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
259
- let seq = recovered.reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
260
- for (const { attempts: _attempts, ...intent } of recovered)
261
- submit({ ...intent }, false);
225
+ let seq = runner.recover().reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
262
226
  // Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the
263
227
  // delivery dedup ring alone is enough — there is no await for a duplicate delivery to race
264
228
  // through. The minutes-long Agent turn remains fire-and-forget.
@@ -285,7 +249,7 @@ export function slackChannel(options) {
285
249
  // Listening is not speaking: every message the channel can see refines who takes part in its
286
250
  // thread, whether or not it is answered. Humans only — a bot's own posts are recorded where they
287
251
  // are known — this channel answering.
288
- // Structural facts only, never `groupBehavior` or `route` — see thread-participants.ts. Slack
252
+ // Structural facts only, never `route` — see thread-participants.ts. Slack
289
253
  // adds no delta of its own here; its summon rule is the only consumer.
290
254
  if (group && event.thread_ts !== undefined) {
291
255
  threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
@@ -307,7 +271,6 @@ export function slackChannel(options) {
307
271
  // structural: it reads who THIS message addresses, which is the one thing an observation-only
308
272
  // store cannot know about someone it has never heard.
309
273
  if (!routed &&
310
- groupBehavior === "context" &&
311
274
  route === undefined &&
312
275
  group &&
313
276
  event.thread_ts !== undefined &&
@@ -321,7 +284,7 @@ export function slackChannel(options) {
321
284
  if (!routed) {
322
285
  if (route === undefined && group && botUserId === undefined && mightBeTheBot)
323
286
  return;
324
- if (groupBehavior === "context" && route === undefined && group) {
287
+ if (route === undefined && group) {
325
288
  const body = slackBufferText(slackMessageText(event));
326
289
  if (body) {
327
290
  const fileIds = slackFileIds(event);
@@ -365,7 +328,7 @@ export function slackChannel(options) {
365
328
  const threadTitle = direct && event.thread_ts === undefined
366
329
  ? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
367
330
  : undefined;
368
- submit({
331
+ runner.submit({
369
332
  id: logicalId,
370
333
  seq: ++seq,
371
334
  session: routed.session ?? defaultSession,
@@ -410,8 +373,6 @@ export function slackChannel(options) {
410
373
  });
411
374
  }
412
375
  };
413
- // Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
414
- const sideTasks = createTaskTracker();
415
376
  // First-run DM welcome: app_home_opened(tab="messages") signals a DM open. Post once per user.
416
377
  const welcomeInFlight = new Set();
417
378
  const maybeWelcome = (envelope) => {
@@ -486,7 +447,7 @@ export function slackChannel(options) {
486
447
  acceptEvent(envelope);
487
448
  return new Response(null, { status: 200 });
488
449
  };
489
- handler.turnsIdle = () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined);
450
+ handler.turnsIdle = () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined);
490
451
  return { "POST /slack": handler };
491
452
  };
492
453
  }
@@ -0,0 +1,4 @@
1
+ /** Shared SSE response lifecycle for invoke and session observation. Fetch-only, with no Node dependencies. */
2
+ /** The remote client's idle watchdog allows three missed heartbeats. */
3
+ export declare const SSE_HEARTBEAT_MS = 30000;
4
+ export declare function sseResponse<T>(events: AsyncIterable<T>, project?: (event: T) => unknown): Response;