@fastagent-sh/fastagent 0.29.0 → 0.30.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.
Files changed (312) hide show
  1. package/README.md +9 -4
  2. package/dist/atomic-write.d.ts +4 -5
  3. package/dist/atomic-write.js +4 -5
  4. package/dist/authoring.d.ts +32 -15
  5. package/dist/authoring.js +147 -127
  6. package/dist/channels/agentcore-protocol.d.ts +8 -3
  7. package/dist/channels/agentcore-protocol.js +3 -1
  8. package/dist/channels/agentcore-service.d.ts +1 -0
  9. package/dist/channels/agentcore-service.js +14 -4
  10. package/dist/channels/agentcore.d.ts +9 -0
  11. package/dist/channels/agentcore.js +29 -4
  12. package/dist/channels/context.d.ts +2 -0
  13. package/dist/channels/context.js +5 -1
  14. package/dist/channels/feishu/feishu-api.d.ts +2 -1
  15. package/dist/channels/feishu/feishu.d.ts +0 -3
  16. package/dist/channels/feishu/feishu.js +10 -10
  17. package/dist/channels/feishu/register-app.js +3 -5
  18. package/dist/channels/feishu/scaffold/channel.ts +32 -34
  19. package/dist/channels/feishu/scaffold/feishu-send.ts +5 -5
  20. package/dist/channels/feishu/scaffold/feishu-threads.ts +3 -3
  21. package/dist/channels/feishu/setup-mode.d.ts +2 -12
  22. package/dist/channels/feishu/setup-mode.js +2 -22
  23. package/dist/channels/feishu/setup.d.ts +12 -0
  24. package/dist/channels/feishu/setup.js +197 -0
  25. package/dist/channels/kit/abortable.d.ts +8 -0
  26. package/dist/channels/kit/abortable.js +22 -0
  27. package/dist/channels/lark/lark.d.ts +0 -3
  28. package/dist/channels/lark/lark.js +0 -5
  29. package/dist/channels/lark/scaffold/channel.ts +33 -35
  30. package/dist/channels/lark/scaffold/lark-send.ts +5 -5
  31. package/dist/channels/lark/scaffold/lark-threads.ts +3 -3
  32. package/dist/channels/lark/setup.d.ts +9 -0
  33. package/dist/channels/lark/setup.js +57 -0
  34. package/dist/channels/setup.d.ts +112 -0
  35. package/dist/channels/setup.js +31 -0
  36. package/dist/channels/slack/history.js +1 -1
  37. package/dist/channels/slack/manifest.d.ts +17 -7
  38. package/dist/channels/slack/manifest.js +12 -16
  39. package/dist/channels/slack/scaffold/channel.ts +44 -30
  40. package/dist/channels/slack/scaffold/slack-send.ts +8 -8
  41. package/dist/channels/slack/scaffold/slack-threads.ts +4 -4
  42. package/dist/channels/slack/setup.d.ts +7 -0
  43. package/dist/channels/slack/setup.js +119 -0
  44. package/dist/channels/slack/slack-api.d.ts +2 -0
  45. package/dist/channels/slack/slack-api.js +4 -0
  46. package/dist/channels/slack/slack.d.ts +21 -2
  47. package/dist/channels/slack/slack.js +398 -338
  48. package/dist/channels/slack/socket.d.ts +57 -0
  49. package/dist/channels/slack/socket.js +305 -0
  50. package/dist/channels/telegram/polling.d.ts +27 -0
  51. package/dist/channels/telegram/polling.js +105 -0
  52. package/dist/channels/telegram/scaffold/channel.ts +52 -32
  53. package/dist/channels/telegram/scaffold/telegram-send.ts +9 -9
  54. package/dist/channels/telegram/setup.d.ts +2 -0
  55. package/dist/channels/telegram/setup.js +42 -0
  56. package/dist/channels/telegram/telegram-api.d.ts +27 -1
  57. package/dist/channels/telegram/telegram-api.js +7 -6
  58. package/dist/channels/telegram/telegram.d.ts +11 -3
  59. package/dist/channels/telegram/telegram.js +184 -157
  60. package/dist/channels/webhook-evidence.d.ts +11 -0
  61. package/dist/channels/webhook-evidence.js +71 -0
  62. package/dist/channels/webhooks.d.ts +37 -0
  63. package/dist/channels/webhooks.js +70 -0
  64. package/dist/child-env.d.ts +16 -0
  65. package/dist/child-env.js +30 -0
  66. package/dist/cli/box-login.js +4 -1
  67. package/dist/cli/channel-app.d.ts +3 -0
  68. package/dist/cli/channel-app.js +35 -0
  69. package/dist/cli/channel-setup.d.ts +38 -0
  70. package/dist/cli/channel-setup.js +195 -0
  71. package/dist/cli/commands/add.d.ts +6 -14
  72. package/dist/cli/commands/add.js +45 -303
  73. package/dist/cli/commands/auth.js +8 -2
  74. package/dist/cli/commands/build.d.ts +5 -0
  75. package/dist/cli/commands/build.js +16 -0
  76. package/dist/cli/commands/channels.d.ts +19 -0
  77. package/dist/cli/commands/channels.js +230 -0
  78. package/dist/cli/commands/content.d.ts +7 -3
  79. package/dist/cli/commands/content.js +16 -10
  80. package/dist/cli/commands/deploy/agentcore.js +55 -23
  81. package/dist/cli/commands/deploy/channel-apps.d.ts +17 -0
  82. package/dist/cli/commands/deploy/channel-apps.js +30 -0
  83. package/dist/cli/commands/deploy/docker.js +32 -65
  84. package/dist/cli/commands/deploy/fly.js +28 -12
  85. package/dist/cli/commands/deploy/railway.js +38 -13
  86. package/dist/cli/commands/deploy/secrets-step.d.ts +19 -0
  87. package/dist/cli/commands/deploy/secrets-step.js +66 -0
  88. package/dist/cli/commands/deploy/shared.d.ts +16 -5
  89. package/dist/cli/commands/deploy/shared.js +25 -8
  90. package/dist/cli/commands/deploy.js +53 -60
  91. package/dist/cli/commands/dev.d.ts +2 -0
  92. package/dist/cli/commands/dev.js +15 -3
  93. package/dist/cli/commands/env.js +1 -1
  94. package/dist/cli/commands/info.d.ts +2 -0
  95. package/dist/cli/commands/info.js +60 -25
  96. package/dist/cli/commands/init.js +3 -1
  97. package/dist/cli/commands/invoke.d.ts +2 -0
  98. package/dist/cli/commands/invoke.js +20 -7
  99. package/dist/cli/commands/login.d.ts +0 -7
  100. package/dist/cli/commands/login.js +22 -10
  101. package/dist/cli/commands/logout.js +1 -1
  102. package/dist/cli/commands/mcp.js +14 -6
  103. package/dist/cli/commands/models.js +3 -2
  104. package/dist/cli/commands/schedules.d.ts +4 -2
  105. package/dist/cli/commands/schedules.js +14 -10
  106. package/dist/cli/commands/secrets.d.ts +14 -0
  107. package/dist/cli/commands/secrets.js +213 -0
  108. package/dist/cli/commands/start.d.ts +14 -14
  109. package/dist/cli/commands/start.js +46 -60
  110. package/dist/cli/content-view.d.ts +4 -6
  111. package/dist/cli/content-view.js +5 -11
  112. package/dist/cli/first-run-channels.d.ts +3 -0
  113. package/dist/cli/first-run-channels.js +63 -0
  114. package/dist/cli/instance.d.ts +41 -0
  115. package/dist/cli/instance.js +155 -0
  116. package/dist/cli/invoke-stream.d.ts +5 -2
  117. package/dist/cli/invoke-stream.js +7 -2
  118. package/dist/cli/kernel.js +3 -0
  119. package/dist/cli/program.js +282 -110
  120. package/dist/cli/serve.d.ts +4 -5
  121. package/dist/cli/serve.js +4 -7
  122. package/dist/cli/shared.d.ts +11 -2
  123. package/dist/cli/shared.js +32 -19
  124. package/dist/cli/ticket.d.ts +7 -0
  125. package/dist/cli/ticket.js +106 -30
  126. package/dist/content/declare.d.ts +31 -37
  127. package/dist/content/declare.js +59 -60
  128. package/dist/content/file.d.ts +23 -12
  129. package/dist/content/file.js +48 -35
  130. package/dist/content/git.d.ts +0 -10
  131. package/dist/content/git.js +5 -39
  132. package/dist/content/resolve.d.ts +18 -22
  133. package/dist/content/resolve.js +32 -66
  134. package/dist/content/source.d.ts +7 -8
  135. package/dist/content/source.js +10 -28
  136. package/dist/credentials-page.d.ts +26 -0
  137. package/dist/credentials-page.js +185 -0
  138. package/dist/declared-secrets.d.ts +10 -3
  139. package/dist/declared-secrets.js +8 -4
  140. package/dist/deploy/agentcore/destroy.d.ts +4 -1
  141. package/dist/deploy/agentcore/destroy.js +25 -1
  142. package/dist/deploy/agentcore/forwarder.js +1 -1
  143. package/dist/deploy/agentcore/plan.d.ts +7 -21
  144. package/dist/deploy/agentcore/plan.js +47 -49
  145. package/dist/deploy/agentcore/run.d.ts +18 -10
  146. package/dist/deploy/agentcore/run.js +93 -70
  147. package/dist/deploy/agentcore/runtime-secrets.d.ts +7 -0
  148. package/dist/deploy/agentcore/runtime-secrets.js +108 -0
  149. package/dist/deploy/agentcore/shell.js +12 -19
  150. package/dist/deploy/agentcore/sigv4.d.ts +7 -0
  151. package/dist/deploy/agentcore/sigv4.js +15 -0
  152. package/dist/deploy/agentcore/slr.d.ts +18 -0
  153. package/dist/deploy/agentcore/slr.js +27 -0
  154. package/dist/deploy/agentcore/store.d.ts +7 -0
  155. package/dist/deploy/agentcore/store.js +115 -0
  156. package/dist/deploy/api.d.ts +14 -14
  157. package/dist/deploy/api.js +26 -24
  158. package/dist/deploy/box-shell.d.ts +3 -1
  159. package/dist/deploy/build-context.d.ts +4 -4
  160. package/dist/deploy/build-context.js +11 -15
  161. package/dist/deploy/channel-ingress.d.ts +35 -9
  162. package/dist/deploy/channel-ingress.js +119 -22
  163. package/dist/deploy/container.d.ts +35 -10
  164. package/dist/deploy/container.js +79 -37
  165. package/dist/deploy/delivered-text.d.ts +13 -0
  166. package/dist/deploy/delivered-text.js +86 -0
  167. package/dist/deploy/docker/plan.d.ts +7 -8
  168. package/dist/deploy/docker/plan.js +31 -52
  169. package/dist/deploy/docker/run.d.ts +5 -43
  170. package/dist/deploy/docker/run.js +9 -84
  171. package/dist/deploy/file-store.d.ts +2 -0
  172. package/dist/deploy/file-store.js +17 -0
  173. package/dist/deploy/fly/plan.d.ts +3 -2
  174. package/dist/deploy/fly/plan.js +20 -9
  175. package/dist/deploy/fly/run.d.ts +18 -8
  176. package/dist/deploy/fly/run.js +24 -42
  177. package/dist/deploy/fly/store.d.ts +12 -0
  178. package/dist/deploy/fly/store.js +83 -0
  179. package/dist/deploy/hosts.d.ts +1 -2
  180. package/dist/deploy/hosts.js +1 -2
  181. package/dist/deploy/preflight.d.ts +18 -5
  182. package/dist/deploy/preflight.js +135 -63
  183. package/dist/deploy/railway/ignore.d.ts +8 -0
  184. package/dist/deploy/railway/ignore.js +57 -0
  185. package/dist/deploy/railway/plan.d.ts +1 -2
  186. package/dist/deploy/railway/plan.js +8 -6
  187. package/dist/deploy/railway/run.d.ts +21 -15
  188. package/dist/deploy/railway/run.js +32 -78
  189. package/dist/deploy/railway/store.d.ts +29 -0
  190. package/dist/deploy/railway/store.js +143 -0
  191. package/dist/deploy/runner.d.ts +5 -2
  192. package/dist/deploy/runner.js +4 -3
  193. package/dist/deploy/secret-store.d.ts +63 -0
  194. package/dist/deploy/secret-store.js +98 -0
  195. package/dist/deploy/secrets.d.ts +29 -46
  196. package/dist/deploy/secrets.js +49 -122
  197. package/dist/deploy/workspace.d.ts +6 -12
  198. package/dist/deploy/workspace.js +18 -94
  199. package/dist/dev-supervisor.js +6 -8
  200. package/dist/environment/declare.js +3 -2
  201. package/dist/environment/mise.d.ts +3 -1
  202. package/dist/environment/mise.js +4 -2
  203. package/dist/feishu.d.ts +1 -1
  204. package/dist/feishu.js +1 -1
  205. package/dist/harnesses/models/catalog.d.ts +1 -0
  206. package/dist/harnesses/models/catalog.js +1 -2
  207. package/dist/harnesses/models/environment.d.ts +7 -2
  208. package/dist/harnesses/models/environment.js +9 -5
  209. package/dist/harnesses/models/host.d.ts +0 -7
  210. package/dist/harnesses/models/host.js +10 -1
  211. package/dist/harnesses/models/login.d.ts +0 -8
  212. package/dist/harnesses/models/login.js +5 -4
  213. package/dist/harnesses/models/models.d.ts +7 -2
  214. package/dist/harnesses/models/models.js +28 -4
  215. package/dist/harnesses/pi-durable/agent.js +59 -38
  216. package/dist/harnesses/pi-durable/build.d.ts +12 -0
  217. package/dist/harnesses/pi-durable/build.js +270 -0
  218. package/dist/harnesses/pi-durable/built.d.ts +24 -0
  219. package/dist/harnesses/pi-durable/built.js +136 -0
  220. package/dist/harnesses/pi-durable/config.d.ts +81 -0
  221. package/dist/harnesses/pi-durable/config.js +266 -0
  222. package/dist/harnesses/pi-durable/define.d.ts +63 -13
  223. package/dist/harnesses/pi-durable/define.js +23 -2
  224. package/dist/harnesses/pi-durable/definition.d.ts +72 -0
  225. package/dist/harnesses/pi-durable/definition.js +143 -0
  226. package/dist/harnesses/pi-durable/events.d.ts +4 -3
  227. package/dist/harnesses/pi-durable/events.js +179 -136
  228. package/dist/harnesses/pi-durable/execution-env.d.ts +17 -0
  229. package/dist/harnesses/pi-durable/execution-env.js +11 -0
  230. package/dist/harnesses/pi-durable/mcp.js +1 -3
  231. package/dist/harnesses/pi-durable/open.d.ts +35 -37
  232. package/dist/harnesses/pi-durable/open.js +91 -153
  233. package/dist/harnesses/pi-durable/reads.d.ts +0 -1
  234. package/dist/harnesses/pi-durable/reads.js +1 -3
  235. package/dist/harnesses/pi-durable/registry.d.ts +4 -6
  236. package/dist/harnesses/pi-durable/registry.js +29 -26
  237. package/dist/harnesses/pi-durable/release.d.ts +2 -2
  238. package/dist/harnesses/pi-durable/release.js +10 -7
  239. package/dist/harnesses/pi-durable/serve.d.ts +91 -15
  240. package/dist/harnesses/pi-durable/serve.js +109 -47
  241. package/dist/harnesses/pi-durable/sessions.js +15 -12
  242. package/dist/harnesses/pi-durable/text-sources.d.ts +30 -0
  243. package/dist/harnesses/pi-durable/text-sources.js +55 -0
  244. package/dist/harnesses/pi-durable/text.d.ts +16 -4
  245. package/dist/harnesses/pi-durable/text.js +115 -59
  246. package/dist/harnesses/pi-durable/wake.js +8 -8
  247. package/dist/http/client.js +9 -3
  248. package/dist/http/contract.d.ts +3 -3
  249. package/dist/http/server.d.ts +1 -1
  250. package/dist/http/server.js +1 -1
  251. package/dist/index.d.ts +8 -5
  252. package/dist/index.js +4 -1
  253. package/dist/instance-name.d.ts +7 -0
  254. package/dist/instance-name.js +26 -0
  255. package/dist/lark.d.ts +1 -1
  256. package/dist/lark.js +1 -1
  257. package/dist/mcp/client.js +11 -2
  258. package/dist/mcp/config.d.ts +70 -18
  259. package/dist/mcp/config.js +136 -53
  260. package/dist/mcp/host.d.ts +5 -2
  261. package/dist/mcp/host.js +14 -6
  262. package/dist/mcp/sign-in.js +2 -2
  263. package/dist/mcp-api.d.ts +14 -0
  264. package/dist/paths.d.ts +22 -8
  265. package/dist/paths.js +44 -11
  266. package/dist/runtime-websocket.d.ts +14 -0
  267. package/dist/runtime-websocket.js +39 -0
  268. package/dist/scaffold/add-channel.d.ts +9 -16
  269. package/dist/scaffold/add-channel.js +13 -83
  270. package/dist/scaffold/templates/env.example +4 -5
  271. package/dist/scaffold/templates/fastagent.config.ts +20 -14
  272. package/dist/scaffold/templates/gitignore +9 -6
  273. package/dist/scaffold/templates.d.ts +6 -2
  274. package/dist/scaffold/templates.js +7 -2
  275. package/dist/scaffold/vendor-skill.js +4 -2
  276. package/dist/service.d.ts +44 -6
  277. package/dist/service.js +110 -10
  278. package/dist/serving-command.d.ts +10 -0
  279. package/dist/serving-command.js +1 -0
  280. package/dist/slack.d.ts +1 -1
  281. package/dist/slack.js +1 -1
  282. package/dist/strip-edges.d.ts +10 -0
  283. package/dist/strip-edges.js +22 -0
  284. package/dist/telegram.d.ts +1 -1
  285. package/dist/telegram.js +1 -1
  286. package/dist/tunnel.d.ts +0 -1
  287. package/dist/tunnel.js +2 -8
  288. package/dist/value-file.d.ts +11 -6
  289. package/dist/value-file.js +21 -9
  290. package/dist/version.d.ts +7 -1
  291. package/dist/version.js +9 -3
  292. package/package.json +6 -5
  293. package/dist/channels/lark/credentials-page.d.ts +0 -25
  294. package/dist/channels/lark/credentials-page.js +0 -206
  295. package/dist/channels/lark/onboard.d.ts +0 -52
  296. package/dist/channels/lark/onboard.js +0 -86
  297. package/dist/channels/slack/config-api.d.ts +0 -69
  298. package/dist/channels/slack/config-api.js +0 -162
  299. package/dist/channels/slack/onboard.d.ts +0 -40
  300. package/dist/channels/slack/onboard.js +0 -136
  301. package/dist/channels/slack/onboarding-state.d.ts +0 -31
  302. package/dist/channels/slack/onboarding-state.js +0 -64
  303. package/dist/channels/slack/register-webhook.d.ts +0 -12
  304. package/dist/channels/slack/register-webhook.js +0 -67
  305. package/dist/channels/slack/setup-server.d.ts +0 -32
  306. package/dist/channels/slack/setup-server.js +0 -246
  307. package/dist/cli/add-feishu.d.ts +0 -41
  308. package/dist/cli/add-feishu.js +0 -391
  309. package/dist/cli/add-slack.d.ts +0 -18
  310. package/dist/cli/add-slack.js +0 -216
  311. package/dist/harnesses/pi-durable/parked.d.ts +0 -18
  312. package/dist/harnesses/pi-durable/parked.js +0 -27
@@ -1,4 +1,8 @@
1
- /** First-party Slack HTTP Events API channel: signed ingress, durable turns/context, files, and edited previews. */
1
+ /**
2
+ * First-party Slack channel: durable turns/context, files, and edited previews, over one runtime, with two ways in: the
3
+ * Events API's signed HTTP Request URL, and Socket Mode (socket.ts).
4
+ */
5
+ import { recordReached } from "../webhook-evidence.js";
2
6
  import { createHmac } from "node:crypto";
3
7
  import { join } from "node:path";
4
8
  import { log } from "../../log.js";
@@ -21,6 +25,7 @@ import { createTurnStore } from "../kit/turn-store.js";
21
25
  import { discussionBlock } from "../kit/context-buffer.js";
22
26
  import { collectFoldedFiles, createSlackPlaceHistory, slackHistoryKey, slackPlaceOf, } from "./history.js";
23
27
  import { slackTurnStream } from "./invoke-turn.js";
28
+ import { connectSlackSocket } from "./socket.js";
24
29
  import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, hasSlackMention, isSlackHumanMessage, mentionsSlackUser, stripSlackMentions, slackEnvelope, slackFileIds, slackMessageText, slackTeamId, } from "./parse.js";
25
30
  import { defaultErrorMessage, deliverSlackAnswer, settleSlackPreview, slackReply, } from "./preview.js";
26
31
  import { completeSlackReaction, resolveReactionEmojis, startSlackReaction } from "./reaction.js";
@@ -109,351 +114,379 @@ function createSlackAuthentication(api, label) {
109
114
  }),
110
115
  };
111
116
  }
112
- export function slackChannel(options) {
113
- const { botToken, signingSecret, rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
117
+ /** The policy as the runtime reads it: defaults applied and what can be refused refused, at construction. */
118
+ function resolveSlackPolicy(options) {
119
+ const { rendering = "native", welcome = DEFAULT_WELCOME, reactionAck = {} } = options;
114
120
  if (!["native", "classic"].includes(rendering)) {
115
121
  throw new Error('slackChannel rendering must be "native" or "classic"');
116
122
  }
117
123
  if (welcome !== false && typeof welcome !== "string") {
118
124
  throw new Error("slackChannel welcome must be a string or false");
119
125
  }
120
- const reactionEmojis = resolveReactionEmojis(reactionAck);
121
- return ({ agent, sessionFor, recordedSession, stateDir: stateHome }) => {
122
- if (!botToken)
123
- throw new Error("slackChannel requires a non-empty botToken (Bot User OAuth Token)");
124
- if (!signingSecret)
125
- throw new Error("slackChannel requires a non-empty signingSecret (Basic Information → App Credentials)");
126
- const label = "[slack]";
127
- const formatError = onError ?? defaultErrorMessage;
128
- const api = createSlackApi({ botToken, baseUrl: apiBaseUrl });
129
- registerSlackApi(botToken, api); // the send tool delivers through this one (shared-api.ts)
130
- const auth = createSlackAuthentication(api, label);
131
- // Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
132
- const sideTasks = createTaskTracker(label);
133
- const seen = createSeenRing(join(stateHome, "seen.json"), label);
134
- const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
135
- /**
136
- * A thread's participation is keyed by the CONVERSATION it describes — "the agent answered here" is a claim about
137
- * a memory, so the two must not be re-keyable independently.
138
- */
139
- const threadKey = (teamId, channelId, threadTs) => `${teamId}:${channelId}:${threadTs}`;
140
- const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
141
- // Every message this channel took as input (a turn, a /stop) is in `seen`. A history read also leaves these out,
142
- // to shape the prompt only — what keeps a queued ask out of an earlier turn is the read's bound, not this ring.
143
- const history = createSlackPlaceHistory({
144
- api,
145
- self: () => ({ userId: auth.botUserId(), botId: auth.botId() }),
146
- label,
147
- path: join(stateHome, "history.json"),
148
- isTurnInput: (key, ts) => {
149
- const place = slackPlaceOf(key);
150
- return seen.has(`${place.teamId}:${place.channelId}:${ts}`);
151
- },
152
- });
153
- // A group turn's room, for the thread-reading tool (`slackThreads`): the channel it was asked in, and its threads.
154
- const rooms = createRoomThreads({
155
- list: (room) => history.threads(room),
156
- read: async (room, threadTs) => {
157
- if (!/^\d+\.\d+$/.test(threadTs)) {
158
- throw new Error(`"${threadTs}" is not a Slack thread id: pass a thread's id from the list (like 1712345678.123456)`);
159
- }
160
- const { teamId, channelId } = slackPlaceOf(room);
161
- const key = slackHistoryKey(teamId, { channelId, threadTs });
162
- return (await history.snapshot(key)).text || `(thread ${threadTs} shows no messages)`;
163
- },
164
- });
165
- registerRoomThreads("slack", stateHome, rooms);
166
- /**
167
- * The client a turn posts through: what it posts (answer, queue notice, stop feedback) is in the session already,
168
- * so the place it LANDS in records it, and that place's next read leaves it out — an answer to a channel's
169
- * top-level ask lands in the thread it opens. The send tool uses the plain client, so what the agent posts itself
170
- * stays discussion. A routed turn has no place and reads no history.
171
- */
172
- const placeApi = (turn) => turn.historyKey === undefined
173
- ? api
174
- : api.recordingSends((where, ts) => history.recordOutput(slackHistoryKey(turn.teamId, where), ts));
175
- const store = createTurnStore(join(stateHome, "turns.json"), {
176
- label,
177
- isRecord: isStoredSlackTurn,
178
- order: (a, b) => a.seq - b.seq,
179
- });
180
- const decide = route ?? defaultSlackRoute;
181
- /** A channel turn under the default routing reads its channel's threads; a DM has none to read. */
182
- const enterRoom = (turn) => {
183
- if (!turn.group || turn.historyKey === undefined)
184
- return {};
185
- const { teamId, channelId } = slackPlaceOf(turn.historyKey);
186
- const room = slackHistoryKey(teamId, { channelId });
187
- return { enter: (sessionId) => rooms.enter(sessionId, room) };
188
- };
189
- const targetOf = (turn) => ({
190
- channelId: turn.channelId,
191
- threadTs: turn.threadTs,
192
- recipientUserId: turn.requesterUserId,
193
- recipientTeamId: turn.teamId,
194
- });
195
- const notifyDropped = (turn) => {
196
- const target = targetOf(turn);
197
- if (turn.nativeQueueStatus)
198
- void api.setThreadStatus(target, "").catch(() => { });
199
- void settleSlackPreview(placeApi(turn), 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)}`));
200
- };
201
- const runner = createTurnRunner({
202
- label,
203
- store,
204
- discussion: history,
205
- seen,
206
- toStored: ({ previewTs: _preview, nativeQueueStatus: _status, ...intent }) => ({ ...intent, attempts: 0 }),
207
- fromStored: ({ attempts: _attempts, ...intent }) => ({ ...intent }),
208
- // The read ends at the ask (`messageRefOf`: the ts of the message that asked).
209
- discussionKey: (turn) => {
210
- const ask = messageRefOf(turn.id);
211
- return turn.historyKey === undefined || ask === undefined ? undefined : { key: turn.historyKey, until: ask.ts };
212
- },
213
- where: (turn) => `channel=${turn.channelId}`,
214
- onQueuedBehind(turn) {
215
- const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
216
- if (nativeDmStatus)
217
- turn.nativeQueueStatus = true;
218
- return {
219
- done: auth.settled
220
- .then(() => nativeDmStatus
221
- ? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
222
- : placeApi(turn)
223
- .postMessage(targetOf(turn), QUEUED_PLACEHOLDER)
224
- .then((ts) => {
225
- turn.previewTs = ts;
226
- }))
227
- .catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)),
228
- };
229
- },
230
- // Leave the intent untouched when Slack auth failed.
231
- beforeRun: async (turn) => {
232
- try {
233
- await auth.settled;
234
- return true;
235
- }
236
- catch (error) {
237
- log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
238
- return false;
239
- }
240
- },
241
- onDeferred: (turn) => {
242
- if (turn.nativeQueueStatus) {
243
- void api
244
- .setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
245
- .catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
246
- }
247
- else if (turn.previewTs) {
248
- void settleSlackPreview(placeApi(turn), targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
249
- }
250
- },
251
- notifyDropped,
252
- deliverAnswer: (turn, answer) => portJoin(async () => {
253
- // A queue notice THIS process put up outlives the run the answer came from, so it is settled here as it
254
- // would be by a turn that ran: the status line cleared, the compatibility message taken over.
255
- if (turn.nativeQueueStatus) {
256
- await api
257
- .setThreadStatus(targetOf(turn), "")
258
- .catch((error) => log.warn(`${label} could not clear a queued Agent status: ${String(error)}`));
259
- }
260
- await deliverSlackAnswer(placeApi(turn), targetOf(turn), answer, turn.previewTs);
261
- // The 👀 on the asker's message was added by the run that produced this answer, and `execute`'s
262
- // acquire/release — the only thing that turns it into ✅ — is not on this path.
263
- const messageRef = messageRefOf(turn.id);
264
- if (reactionEmojis && messageRef) {
265
- await completeSlackReaction({
266
- api,
267
- channelId: messageRef.channelId,
268
- ts: messageRef.ts,
269
- emojis: reactionEmojis,
270
- label,
271
- });
272
- }
273
- }),
274
- execute: (turn, discussion, onAnswered) => {
275
- const messageRef = messageRefOf(turn.id);
276
- return Effect.acquireUseRelease(portJoin(async () => reactionEmojis && messageRef
277
- ? startSlackReaction({
278
- api,
279
- channelId: messageRef.channelId,
280
- ts: messageRef.ts,
281
- emojis: reactionEmojis,
282
- label,
283
- })
284
- : undefined), () => Effect.scoped(slackReply(slackTurnStream(agent, {
285
- session: () => sessionFor(turn.conversation),
286
- ...enterRoom(turn),
287
- key: turn.id,
288
- text: `${discussionBlock(discussion.text)}${turn.baseText}`,
289
- }, { api, channelId: turn.channelId, filesDir: attachmentsDir(stateHome), label }, {
290
- primaryFileIds: turn.fileIds,
291
- buffered: collectFoldedFiles(discussion.consumed.flatMap((read) => read.folded), new Set(turn.fileIds)),
292
- }), placeApi(turn), targetOf(turn), formatError, {
293
- rendering,
294
- initialPreviewTs: turn.previewTs,
295
- threadTitle: turn.threadTitle,
296
- disclaimer: aiDisclaimer,
297
- label,
298
- onAnswered,
299
- })), (reaction, exit) => portJoin(async () => {
300
- if (Exit.isSuccess(exit))
301
- await reaction?.complete();
302
- else
303
- await reaction?.remove();
304
- }).pipe(
305
- // A release does not fail: its defect would make the turn's typed failure one too. Unreachable while
306
- // the reaction's `complete` and `remove` catch their own failures (reaction.ts).
307
- Effect.catchTag("PortFailure", (error) => Effect.sync(() => log.warn(`${label} could not settle the Slack reaction: ${String(error.cause)}`)))));
308
- },
309
- });
310
- let seq = runner.recover().reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
311
- // Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the delivery dedup ring
312
- // alone is enough.
313
- const acceptEvent = (envelope) => {
314
- const event = envelope.event;
315
- if (!isSlackHumanMessage(event))
316
- return;
317
- const botUserId = auth.botUserId();
318
- if (botUserId && event.user === botUserId)
319
- return;
320
- const teamId = slackTeamId(envelope) ?? auth.teamId();
321
- if (!teamId) {
322
- log.warn(`${label} ignored message ${event.ts}: event carried no workspace/enterprise identity`);
323
- return;
126
+ return {
127
+ botToken: options.botToken,
128
+ rendering,
129
+ welcome,
130
+ reactionEmojis: resolveReactionEmojis(reactionAck),
131
+ aiDisclaimer: options.aiDisclaimer,
132
+ route: options.route,
133
+ onError: options.onError,
134
+ apiBaseUrl: options.apiBaseUrl ?? "https://slack.com/api",
135
+ };
136
+ }
137
+ function createSlackRuntime(policy, { agent, sessionFor, recordedSession, stateDir: stateHome }) {
138
+ const { botToken, rendering, welcome, reactionEmojis, aiDisclaimer, route, onError, apiBaseUrl } = policy;
139
+ if (!botToken)
140
+ throw new Error("slackChannel requires a non-empty botToken (Bot User OAuth Token)");
141
+ const label = "[slack]";
142
+ const formatError = onError ?? defaultErrorMessage;
143
+ const api = createSlackApi({ botToken, baseUrl: apiBaseUrl });
144
+ registerSlackApi(botToken, api); // the send tool delivers through this one (shared-api.ts)
145
+ const auth = createSlackAuthentication(api, label);
146
+ // Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
147
+ const sideTasks = createTaskTracker(label);
148
+ const seen = createSeenRing(join(stateHome, "seen.json"), label);
149
+ const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
150
+ /**
151
+ * A thread's participation is keyed by the CONVERSATION it describes — "the agent answered here" is a claim about
152
+ * a memory, so the two must not be re-keyable independently.
153
+ */
154
+ const threadKey = (teamId, channelId, threadTs) => `${teamId}:${channelId}:${threadTs}`;
155
+ const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
156
+ // Every message this channel took as input (a turn, a /stop) is in `seen`. A history read also leaves these out,
157
+ // to shape the prompt only — what keeps a queued ask out of an earlier turn is the read's bound, not this ring.
158
+ const history = createSlackPlaceHistory({
159
+ api,
160
+ self: () => ({ userId: auth.botUserId(), botId: auth.botId() }),
161
+ label,
162
+ path: join(stateHome, "history.json"),
163
+ isTurnInput: (key, ts) => {
164
+ const place = slackPlaceOf(key);
165
+ return seen.has(`${place.teamId}:${place.channelId}:${ts}`);
166
+ },
167
+ });
168
+ // A group turn's room, for the thread-reading tool (`slackThreads`): the channel it was asked in, and its threads.
169
+ const rooms = createRoomThreads({
170
+ list: (room) => history.threads(room),
171
+ read: async (room, threadTs) => {
172
+ if (!/^\d+\.\d+$/.test(threadTs)) {
173
+ throw new Error(`"${threadTs}" is not a Slack thread id: pass a thread's id from the list (like 1712345678.123456)`);
324
174
  }
325
- const logicalId = `${teamId}:${event.channel}:${event.ts}`;
326
- if (seen.has(logicalId)) {
327
- log.debug(`${label} duplicate logical message ${logicalId} — skipping`);
328
- return;
175
+ const { teamId, channelId } = slackPlaceOf(room);
176
+ const key = slackHistoryKey(teamId, { channelId, threadTs });
177
+ return (await history.snapshot(key)).text || `(thread ${threadTs} shows no messages)`;
178
+ },
179
+ });
180
+ registerRoomThreads("slack", stateHome, rooms);
181
+ /**
182
+ * The client a turn posts through: what it posts (answer, queue notice, stop feedback) is in the session already,
183
+ * so the place it LANDS in records it, and that place's next read leaves it out — an answer to a channel's
184
+ * top-level ask lands in the thread it opens. The send tool uses the plain client, so what the agent posts itself
185
+ * stays discussion. A routed turn has no place and reads no history.
186
+ */
187
+ const placeApi = (turn) => turn.historyKey === undefined
188
+ ? api
189
+ : api.recordingSends((where, ts) => history.recordOutput(slackHistoryKey(turn.teamId, where), ts));
190
+ const store = createTurnStore(join(stateHome, "turns.json"), {
191
+ label,
192
+ isRecord: isStoredSlackTurn,
193
+ order: (a, b) => a.seq - b.seq,
194
+ });
195
+ const decide = route ?? defaultSlackRoute;
196
+ /** A channel turn under the default routing reads its channel's threads; a DM has none to read. */
197
+ const enterRoom = (turn) => {
198
+ if (!turn.group || turn.historyKey === undefined)
199
+ return {};
200
+ const { teamId, channelId } = slackPlaceOf(turn.historyKey);
201
+ const room = slackHistoryKey(teamId, { channelId });
202
+ return { enter: (sessionId) => rooms.enter(sessionId, room) };
203
+ };
204
+ const targetOf = (turn) => ({
205
+ channelId: turn.channelId,
206
+ threadTs: turn.threadTs,
207
+ recipientUserId: turn.requesterUserId,
208
+ recipientTeamId: turn.teamId,
209
+ });
210
+ const notifyDropped = (turn) => {
211
+ const target = targetOf(turn);
212
+ if (turn.nativeQueueStatus)
213
+ void api.setThreadStatus(target, "").catch(() => { });
214
+ void settleSlackPreview(placeApi(turn), 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)}`));
215
+ };
216
+ const runner = createTurnRunner({
217
+ label,
218
+ store,
219
+ discussion: history,
220
+ seen,
221
+ toStored: ({ previewTs: _preview, nativeQueueStatus: _status, ...intent }) => ({ ...intent, attempts: 0 }),
222
+ fromStored: ({ attempts: _attempts, ...intent }) => ({ ...intent }),
223
+ // The read ends at the ask (`messageRefOf`: the ts of the message that asked).
224
+ discussionKey: (turn) => {
225
+ const ask = messageRefOf(turn.id);
226
+ return turn.historyKey === undefined || ask === undefined ? undefined : { key: turn.historyKey, until: ask.ts };
227
+ },
228
+ where: (turn) => `channel=${turn.channelId}`,
229
+ onQueuedBehind(turn) {
230
+ const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
231
+ if (nativeDmStatus)
232
+ turn.nativeQueueStatus = true;
233
+ return {
234
+ done: auth.settled
235
+ .then(() => nativeDmStatus
236
+ ? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
237
+ : placeApi(turn)
238
+ .postMessage(targetOf(turn), QUEUED_PLACEHOLDER)
239
+ .then((ts) => {
240
+ turn.previewTs = ts;
241
+ }))
242
+ .catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)),
243
+ };
244
+ },
245
+ // Leave the intent untouched when Slack auth failed.
246
+ beforeRun: async (turn) => {
247
+ try {
248
+ await auth.settled;
249
+ return true;
329
250
  }
330
- const group = isSlackGroupMessage(event);
331
- const direct = isSlackDirectMessage(event);
332
- const rootTs = event.thread_ts ?? event.ts;
333
- // Listening is not speaking: every message the channel can see refines who takes part in its thread, whether or
334
- // not it is answered.
335
- if (group && event.thread_ts !== undefined) {
336
- threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
251
+ catch (error) {
252
+ log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
253
+ return false;
337
254
  }
338
- let routed = decide(envelope);
339
- const addressesSomeone = hasSlackMention(event.text ?? "");
340
- const structurallyMentionsBot = botUserId !== undefined && mentionsSlackUser(event.text ?? "", botUserId);
341
- // app_mention and message.* subscriptions can overlap.
342
- if (!routed && route === undefined && group && event.type === "message" && structurallyMentionsBot)
343
- routed = {};
344
- // The participant model's thread rule (§3): a bare message reaches the agent while it takes part and has not
345
- // heard a second human.
346
- if (!routed &&
347
- route === undefined &&
348
- group &&
349
- event.thread_ts !== undefined &&
350
- event.type !== "app_mention" &&
351
- // Mentioning only other people is targeted discussion, never an ask (§3) — the same guard Feishu applies with
352
- // `hasMentions`.
353
- !addressesSomeone &&
354
- threadParticipants.admitsBareMessage(threadKey(teamId, event.channel, event.thread_ts))) {
355
- routed = {};
255
+ },
256
+ onDeferred: (turn) => {
257
+ if (turn.nativeQueueStatus) {
258
+ void api
259
+ .setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
260
+ .catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
356
261
  }
357
- if (!routed) {
358
- // Not lost: the turn that is summoned here reads it from the place's history.
359
- log.debug(`${label} not summoned — message ${logicalId}`);
360
- return;
262
+ else if (turn.previewTs) {
263
+ void settleSlackPreview(placeApi(turn), targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
361
264
  }
362
- const targetChannel = routed.channelId ?? event.channel;
363
- const sameChannel = targetChannel === event.channel;
364
- // Answer where asked (participant model §4).
365
- const defaultThread = event.thread_ts ?? event.ts;
366
- const threadTs = routed.threadTs === null ? undefined : (routed.threadTs ?? (sameChannel ? defaultThread : undefined));
367
- const defaultConversation = threadKey(teamId, event.channel, rootTs);
368
- // Every place's discussion under the default routing, a DM's too (what a send tool posted there is in no session);
369
- // a routed session's place is the router's to decide.
370
- const historyKey = route === undefined && (group || direct)
371
- ? slackHistoryKey(teamId, { channelId: event.channel, threadTs: event.thread_ts })
372
- : undefined;
373
- // Explicit user stop: a control action, never a turn — it must not queue behind the run it stops.
374
- if (isStopText(stripSlackMentions(event.text ?? ""))) {
375
- seen.add(logicalId);
376
- const target = { channelId: event.channel, threadTs: event.thread_ts };
377
- sideTasks.track(dispatchStop(agent, recordedSession(routed.conversation ?? defaultConversation), label)
378
- .then((feedback) => placeApi({ historyKey, teamId })
379
- .postMessage(target, feedback)
380
- .then(() => undefined))
381
- .catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
382
- return;
265
+ },
266
+ notifyDropped,
267
+ deliverAnswer: (turn, answer) => portJoin(async () => {
268
+ // A queue notice THIS process put up outlives the run the answer came from, so it is settled here as it
269
+ // would be by a turn that ran: the status line cleared, the compatibility message taken over.
270
+ if (turn.nativeQueueStatus) {
271
+ await api
272
+ .setThreadStatus(targetOf(turn), "")
273
+ .catch((error) => log.warn(`${label} could not clear a queued Agent status: ${String(error)}`));
383
274
  }
384
- const fileIds = slackFileIds(event);
385
- const baseText = routed.text ?? slackEnvelope(envelope);
386
- if (!baseText.trim() && fileIds.length === 0)
387
- return;
388
- const threadTitle = direct && event.thread_ts === undefined
389
- ? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
390
- : undefined;
391
- runner.submit({
392
- id: logicalId,
393
- seq: ++seq,
394
- conversation: routed.conversation ?? defaultConversation,
395
- baseText,
396
- ...(historyKey !== undefined ? { historyKey } : {}),
397
- group,
398
- teamId,
399
- channelId: targetChannel,
400
- threadTs,
401
- requesterUserId: event.user,
402
- threadTitle: threadTitle || undefined,
403
- fileIds,
404
- }, true);
405
- // Answering inside a GROUP thread makes the agent a participant of it, which is what lets the NEXT bare message
406
- // address it without a mention.
407
- if (group &&
408
- threadTs !== undefined &&
409
- sameChannel &&
410
- routed.conversation === undefined &&
411
- routed.threadTs === undefined) {
412
- // The ASKER counts as heard in this thread too, and both halves are written together so the record can never
413
- // say "the agent takes part and nobody has spoken".
414
- threadParticipants.merge(threadKey(teamId, event.channel, threadTs), {
415
- agentSpoke: true,
416
- humans: [event.user],
275
+ await deliverSlackAnswer(placeApi(turn), targetOf(turn), answer, turn.previewTs);
276
+ // The 👀 on the asker's message was added by the run that produced this answer, and `execute`'s
277
+ // acquire/release — the only thing that turns it into ✅ — is not on this path.
278
+ const messageRef = messageRefOf(turn.id);
279
+ if (reactionEmojis && messageRef) {
280
+ await completeSlackReaction({
281
+ api,
282
+ channelId: messageRef.channelId,
283
+ ts: messageRef.ts,
284
+ emojis: reactionEmojis,
285
+ label,
417
286
  });
418
287
  }
419
- };
420
- // First-run DM welcome: app_home_opened(tab="messages") signals a DM open. Post once per user.
421
- const welcomeInFlight = new Set();
422
- const maybeWelcome = (envelope) => {
423
- if (welcome === false)
424
- return;
425
- const body = welcome.trim();
426
- if (!body)
427
- return;
428
- const event = envelope.event;
429
- if (!event)
430
- return;
431
- if (event.type !== "app_home_opened" || event.tab !== "messages")
432
- return;
433
- const userId = event.user;
434
- const channelId = event.channel;
435
- if (!userId || !channelId)
436
- return;
437
- const teamId = slackTeamId(envelope) ?? auth.teamId();
438
- if (!teamId)
439
- return;
440
- const id = `${teamId}:${userId}`;
441
- if (welcomed.has(teamId, userId) || welcomeInFlight.has(id))
442
- return;
443
- // Reserve in-memory so rapid re-opens don't double-post; persist to the durable set only on a successful post,
444
- // so a failed post simply retries on the next open.
445
- welcomeInFlight.add(id);
446
- sideTasks.track(api
447
- .postMarkdown({ channelId }, body)
448
- .then(() => {
449
- welcomed.add(teamId, userId);
450
- log.info(`${label} sent first-run welcome to ${id}`);
451
- })
452
- .catch((error) => log.warn(`${label} could not send first-run welcome (retries on next open): ${String(error)}`))
453
- .finally(() => {
454
- welcomeInFlight.delete(id);
455
- }));
456
- };
288
+ }),
289
+ execute: (turn, discussion, onAnswered) => {
290
+ const messageRef = messageRefOf(turn.id);
291
+ return Effect.acquireUseRelease(portJoin(async () => reactionEmojis && messageRef
292
+ ? startSlackReaction({
293
+ api,
294
+ channelId: messageRef.channelId,
295
+ ts: messageRef.ts,
296
+ emojis: reactionEmojis,
297
+ label,
298
+ })
299
+ : undefined), () => Effect.scoped(slackReply(slackTurnStream(agent, {
300
+ session: () => sessionFor(turn.conversation),
301
+ ...enterRoom(turn),
302
+ key: turn.id,
303
+ text: `${discussionBlock(discussion.text)}${turn.baseText}`,
304
+ }, { api, channelId: turn.channelId, filesDir: attachmentsDir(stateHome), label }, {
305
+ primaryFileIds: turn.fileIds,
306
+ buffered: collectFoldedFiles(discussion.consumed.flatMap((read) => read.folded), new Set(turn.fileIds)),
307
+ }), placeApi(turn), targetOf(turn), formatError, {
308
+ rendering,
309
+ initialPreviewTs: turn.previewTs,
310
+ threadTitle: turn.threadTitle,
311
+ disclaimer: aiDisclaimer,
312
+ label,
313
+ onAnswered,
314
+ })), (reaction, exit) => portJoin(async () => {
315
+ if (Exit.isSuccess(exit))
316
+ await reaction?.complete();
317
+ else
318
+ await reaction?.remove();
319
+ }).pipe(
320
+ // A release does not fail: its defect would make the turn's typed failure one too. Unreachable while
321
+ // the reaction's `complete` and `remove` catch their own failures (reaction.ts).
322
+ Effect.catchTag("PortFailure", (error) => Effect.sync(() => log.warn(`${label} could not settle the Slack reaction: ${String(error.cause)}`)))));
323
+ },
324
+ });
325
+ let seq = runner.recover().reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
326
+ // Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the delivery dedup ring
327
+ // alone is enough.
328
+ const acceptEvent = (envelope) => {
329
+ const event = envelope.event;
330
+ if (!isSlackHumanMessage(event))
331
+ return;
332
+ const botUserId = auth.botUserId();
333
+ if (botUserId && event.user === botUserId)
334
+ return;
335
+ const teamId = slackTeamId(envelope) ?? auth.teamId();
336
+ if (!teamId) {
337
+ log.warn(`${label} ignored message ${event.ts}: event carried no workspace/enterprise identity`);
338
+ return;
339
+ }
340
+ const logicalId = `${teamId}:${event.channel}:${event.ts}`;
341
+ if (seen.has(logicalId)) {
342
+ log.debug(`${label} duplicate logical message ${logicalId} — skipping`);
343
+ return;
344
+ }
345
+ const group = isSlackGroupMessage(event);
346
+ const direct = isSlackDirectMessage(event);
347
+ const rootTs = event.thread_ts ?? event.ts;
348
+ // Listening is not speaking: every message the channel can see refines who takes part in its thread, whether or
349
+ // not it is answered.
350
+ if (group && event.thread_ts !== undefined) {
351
+ threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
352
+ }
353
+ let routed = decide(envelope);
354
+ const addressesSomeone = hasSlackMention(event.text ?? "");
355
+ const structurallyMentionsBot = botUserId !== undefined && mentionsSlackUser(event.text ?? "", botUserId);
356
+ // app_mention and message.* subscriptions can overlap.
357
+ if (!routed && route === undefined && group && event.type === "message" && structurallyMentionsBot)
358
+ routed = {};
359
+ // The participant model's thread rule (§3): a bare message reaches the agent while it takes part and has not
360
+ // heard a second human.
361
+ if (!routed &&
362
+ route === undefined &&
363
+ group &&
364
+ event.thread_ts !== undefined &&
365
+ event.type !== "app_mention" &&
366
+ // Mentioning only other people is targeted discussion, never an ask (§3) — the same guard Feishu applies with
367
+ // `hasMentions`.
368
+ !addressesSomeone &&
369
+ threadParticipants.admitsBareMessage(threadKey(teamId, event.channel, event.thread_ts))) {
370
+ routed = {};
371
+ }
372
+ if (!routed) {
373
+ // Not lost: the turn that is summoned here reads it from the place's history.
374
+ log.debug(`${label} not summoned — message ${logicalId}`);
375
+ return;
376
+ }
377
+ const targetChannel = routed.channelId ?? event.channel;
378
+ const sameChannel = targetChannel === event.channel;
379
+ // Answer where asked (participant model §4).
380
+ const defaultThread = event.thread_ts ?? event.ts;
381
+ const threadTs = routed.threadTs === null ? undefined : (routed.threadTs ?? (sameChannel ? defaultThread : undefined));
382
+ const defaultConversation = threadKey(teamId, event.channel, rootTs);
383
+ // Every place's discussion under the default routing, a DM's too (what a send tool posted there is in no session);
384
+ // a routed session's place is the router's to decide.
385
+ const historyKey = route === undefined && (group || direct)
386
+ ? slackHistoryKey(teamId, { channelId: event.channel, threadTs: event.thread_ts })
387
+ : undefined;
388
+ // Explicit user stop: a control action, never a turn — it must not queue behind the run it stops.
389
+ if (isStopText(stripSlackMentions(event.text ?? ""))) {
390
+ seen.add(logicalId);
391
+ const target = { channelId: event.channel, threadTs: event.thread_ts };
392
+ sideTasks.track(dispatchStop(agent, recordedSession(routed.conversation ?? defaultConversation), label)
393
+ .then((feedback) => placeApi({ historyKey, teamId })
394
+ .postMessage(target, feedback)
395
+ .then(() => undefined))
396
+ .catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
397
+ return;
398
+ }
399
+ const fileIds = slackFileIds(event);
400
+ const baseText = routed.text ?? slackEnvelope(envelope);
401
+ if (!baseText.trim() && fileIds.length === 0)
402
+ return;
403
+ const threadTitle = direct && event.thread_ts === undefined
404
+ ? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
405
+ : undefined;
406
+ runner.submit({
407
+ id: logicalId,
408
+ seq: ++seq,
409
+ conversation: routed.conversation ?? defaultConversation,
410
+ baseText,
411
+ ...(historyKey !== undefined ? { historyKey } : {}),
412
+ group,
413
+ teamId,
414
+ channelId: targetChannel,
415
+ threadTs,
416
+ requesterUserId: event.user,
417
+ threadTitle: threadTitle || undefined,
418
+ fileIds,
419
+ }, true);
420
+ // Answering inside a GROUP thread makes the agent a participant of it, which is what lets the NEXT bare message
421
+ // address it without a mention.
422
+ if (group &&
423
+ threadTs !== undefined &&
424
+ sameChannel &&
425
+ routed.conversation === undefined &&
426
+ routed.threadTs === undefined) {
427
+ // The ASKER counts as heard in this thread too, and both halves are written together so the record can never
428
+ // say "the agent takes part and nobody has spoken".
429
+ threadParticipants.merge(threadKey(teamId, event.channel, threadTs), {
430
+ agentSpoke: true,
431
+ humans: [event.user],
432
+ });
433
+ }
434
+ };
435
+ // First-run DM welcome: app_home_opened(tab="messages") signals a DM open. Post once per user.
436
+ const welcomeInFlight = new Set();
437
+ const maybeWelcome = (envelope) => {
438
+ if (welcome === false)
439
+ return;
440
+ const body = welcome.trim();
441
+ if (!body)
442
+ return;
443
+ const event = envelope.event;
444
+ if (!event)
445
+ return;
446
+ if (event.type !== "app_home_opened" || event.tab !== "messages")
447
+ return;
448
+ const userId = event.user;
449
+ const channelId = event.channel;
450
+ if (!userId || !channelId)
451
+ return;
452
+ const teamId = slackTeamId(envelope) ?? auth.teamId();
453
+ if (!teamId)
454
+ return;
455
+ const id = `${teamId}:${userId}`;
456
+ if (welcomed.has(teamId, userId) || welcomeInFlight.has(id))
457
+ return;
458
+ // Reserve in-memory so rapid re-opens don't double-post; persist to the durable set only on a successful post,
459
+ // so a failed post simply retries on the next open.
460
+ welcomeInFlight.add(id);
461
+ sideTasks.track(api
462
+ .postMarkdown({ channelId }, body)
463
+ .then(() => {
464
+ welcomed.add(teamId, userId);
465
+ log.info(`${label} sent first-run welcome to ${id}`);
466
+ })
467
+ .catch((error) => log.warn(`${label} could not send first-run welcome (retries on next open): ${String(error)}`))
468
+ .finally(() => {
469
+ welcomeInFlight.delete(id);
470
+ }));
471
+ };
472
+ return {
473
+ ingest: async (envelope) => {
474
+ await auth.ready();
475
+ maybeWelcome(envelope);
476
+ acceptEvent(envelope);
477
+ },
478
+ turnsIdle: () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined),
479
+ };
480
+ }
481
+ /** Build a Slack channel that receives by the Events API's HTTP Request URL: signed requests in, durable turns out. */
482
+ export function slackChannel(options) {
483
+ const { signingSecret } = options;
484
+ const policy = resolveSlackPolicy(options);
485
+ return (ctx) => {
486
+ if (!signingSecret)
487
+ throw new Error("slackChannel requires a non-empty signingSecret (Basic Information → App Credentials)");
488
+ const runtime = createSlackRuntime(policy, ctx);
489
+ const { stateDir: stateHome } = ctx;
457
490
  const handler = async (request) => {
458
491
  if (request.method !== "POST")
459
492
  return text("POST only\n", 405);
@@ -463,9 +496,11 @@ export function slackChannel(options) {
463
496
  const timestamp = request.headers.get("x-slack-request-timestamp") ?? "";
464
497
  const signature = request.headers.get("x-slack-signature") ?? "";
465
498
  if (!verifySlackSignature(signingSecret, timestamp, signature, body.text)) {
466
- log.warn(`${label} rejected an event with an invalid/stale X-Slack-Signature`);
499
+ log.warn("[slack] rejected an event with an invalid/stale X-Slack-Signature");
467
500
  return text("invalid signature\n", 401);
468
501
  }
502
+ // Slack signed it: the Request URL a person set reaches this instance, at the address it was sent to.
503
+ recordReached(stateHome, request);
469
504
  let envelope;
470
505
  try {
471
506
  envelope = JSON.parse(body.text);
@@ -481,17 +516,42 @@ export function slackChannel(options) {
481
516
  if (envelope.type !== "event_callback")
482
517
  return new Response(null, { status: 200 });
483
518
  try {
484
- await auth.ready();
519
+ await runtime.ingest(envelope);
485
520
  }
486
521
  catch (error) {
487
- log.error(`${label} refusing to ACK an event because Slack authentication is unavailable: ${String(error)}`);
522
+ log.error(`[slack] refusing to ACK an event because Slack authentication is unavailable: ${String(error)}`);
488
523
  return text("slack authentication unavailable\n", 503);
489
524
  }
490
- maybeWelcome(envelope);
491
- acceptEvent(envelope);
492
525
  return new Response(null, { status: 200 });
493
526
  };
494
- handler.turnsIdle = () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined);
527
+ handler.turnsIdle = runtime.turnsIdle;
495
528
  return { "POST /slack": handler };
496
529
  };
497
530
  }
531
+ /**
532
+ * Build a Slack channel that receives by Socket Mode: the process opens a WebSocket to Slack with the app-level token
533
+ * and takes its events there, so no public URL and no signing secret are involved. An app in Socket Mode receives over
534
+ * the socket alone: it has no Request URL to point anywhere else.
535
+ */
536
+ export function slackSocketChannel(options,
537
+ /** Internal seam for deterministic tests: the socket the connection opens. */
538
+ deps = {}) {
539
+ const policy = resolveSlackPolicy(options);
540
+ const { appToken } = options;
541
+ return {
542
+ name: "slack socket mode",
543
+ connect(ctx, signal) {
544
+ if (!/^xapp-/.test(appToken)) {
545
+ throw new Error("slackSocketChannel requires an app-level token (xapp-…, scope connections:write: Basic Information → " +
546
+ "App-Level Tokens), not a bot token");
547
+ }
548
+ const runtime = createSlackRuntime(policy, ctx);
549
+ return connectSlackSocket({
550
+ apiBaseUrl: policy.apiBaseUrl,
551
+ appToken,
552
+ ingest: runtime.ingest,
553
+ ...(deps.openSocket === undefined ? {} : { openSocket: deps.openSocket }),
554
+ }, signal);
555
+ },
556
+ };
557
+ }