@fastagent-sh/fastagent 0.14.0 → 0.15.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 (209) hide show
  1. package/README.md +3 -3
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +8 -11
  10. package/dist/channels/feishu/context-buffer.js +6 -67
  11. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  12. package/dist/channels/feishu/feishu-api.js +20 -2
  13. package/dist/channels/feishu/feishu.d.ts +28 -16
  14. package/dist/channels/feishu/feishu.js +210 -188
  15. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  16. package/dist/channels/feishu/invoke-turn.js +10 -62
  17. package/dist/channels/feishu/model.d.ts +2 -1
  18. package/dist/channels/feishu/normalize.d.ts +2 -1
  19. package/dist/channels/feishu/owned-threads.d.ts +2 -6
  20. package/dist/channels/feishu/parse.d.ts +1 -1
  21. package/dist/channels/feishu/preview.d.ts +4 -7
  22. package/dist/channels/feishu/preview.js +24 -140
  23. package/dist/channels/feishu/register-app.d.ts +2 -1
  24. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  25. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  26. package/dist/channels/feishu/setup-mode.js +2 -0
  27. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  28. package/dist/channels/feishu/ws-ingress.js +136 -0
  29. package/dist/channels/github/github.js +8 -6
  30. package/dist/channels/http.d.ts +14 -0
  31. package/dist/channels/http.js +35 -2
  32. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  33. package/dist/channels/invoke-turn-kit.js +87 -0
  34. package/dist/channels/lark/lark.d.ts +4 -2
  35. package/dist/channels/lark/lark.js +4 -1
  36. package/dist/channels/lark/onboard.d.ts +8 -4
  37. package/dist/channels/lark/onboard.js +8 -0
  38. package/dist/channels/preview-kit.d.ts +109 -0
  39. package/dist/channels/preview-kit.js +183 -0
  40. package/dist/channels/seen.d.ts +5 -0
  41. package/dist/channels/seen.js +35 -0
  42. package/dist/channels/slack/bot-auth.d.ts +15 -0
  43. package/dist/channels/slack/bot-auth.js +146 -0
  44. package/dist/channels/slack/config-api.d.ts +60 -0
  45. package/dist/channels/slack/config-api.js +149 -0
  46. package/dist/channels/slack/context-buffer.d.ts +24 -0
  47. package/dist/channels/slack/context-buffer.js +37 -0
  48. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  49. package/dist/channels/slack/invoke-turn.js +63 -0
  50. package/dist/channels/slack/manifest.d.ts +49 -0
  51. package/dist/channels/slack/manifest.js +69 -0
  52. package/dist/channels/slack/model.d.ts +67 -0
  53. package/dist/channels/slack/model.js +2 -0
  54. package/dist/channels/slack/onboard.d.ts +41 -0
  55. package/dist/channels/slack/onboard.js +120 -0
  56. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  57. package/dist/channels/slack/onboarding-state.js +69 -0
  58. package/dist/channels/slack/owned-threads.d.ts +6 -0
  59. package/dist/channels/slack/owned-threads.js +43 -0
  60. package/dist/channels/slack/parse.d.ts +23 -0
  61. package/dist/channels/slack/parse.js +81 -0
  62. package/dist/channels/slack/preview.d.ts +24 -0
  63. package/dist/channels/slack/preview.js +359 -0
  64. package/dist/channels/slack/reaction.d.ts +24 -0
  65. package/dist/channels/slack/reaction.js +62 -0
  66. package/dist/channels/slack/register-webhook.d.ts +10 -0
  67. package/dist/channels/slack/register-webhook.js +49 -0
  68. package/dist/channels/slack/scaffold/channel.ts +33 -0
  69. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  70. package/dist/channels/slack/setup-server.d.ts +17 -0
  71. package/dist/channels/slack/setup-server.js +103 -0
  72. package/dist/channels/slack/slack-api.d.ts +77 -0
  73. package/dist/channels/slack/slack-api.js +415 -0
  74. package/dist/channels/slack/slack.d.ts +58 -0
  75. package/dist/channels/slack/slack.js +451 -0
  76. package/dist/channels/slack/welcomed.d.ts +5 -0
  77. package/dist/channels/slack/welcomed.js +32 -0
  78. package/dist/channels/state.js +3 -3
  79. package/dist/channels/stop-command.d.ts +6 -0
  80. package/dist/channels/stop-command.js +36 -0
  81. package/dist/channels/tasks.d.ts +13 -0
  82. package/dist/channels/tasks.js +10 -0
  83. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  84. package/dist/channels/telegram/context-buffer.js +6 -85
  85. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  86. package/dist/channels/telegram/invoke-turn.js +11 -58
  87. package/dist/channels/telegram/preview.d.ts +4 -7
  88. package/dist/channels/telegram/preview.js +24 -142
  89. package/dist/channels/telegram/telegram.js +23 -9
  90. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  91. package/dist/channels/{feishu/text.js → text.js} +1 -1
  92. package/dist/channels/turn-queue.js +1 -1
  93. package/dist/channels/turn-store.d.ts +1 -1
  94. package/dist/channels/turn-store.js +2 -3
  95. package/dist/cli/add-feishu.d.ts +27 -0
  96. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  97. package/dist/cli/add-slack.d.ts +10 -0
  98. package/dist/cli/add-slack.js +204 -0
  99. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  100. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  101. package/dist/cli/commands/add.d.ts +4 -0
  102. package/dist/cli/commands/add.js +152 -20
  103. package/dist/cli/commands/attach.d.ts +82 -0
  104. package/dist/cli/commands/attach.js +559 -0
  105. package/dist/cli/commands/chat.d.ts +1 -0
  106. package/dist/cli/commands/chat.js +6 -1
  107. package/dist/cli/commands/deploy.d.ts +2 -0
  108. package/dist/cli/commands/deploy.js +78 -22
  109. package/dist/cli/commands/dev.js +11 -5
  110. package/dist/cli/commands/fire.js +3 -3
  111. package/dist/cli/commands/info.js +2 -2
  112. package/dist/cli/commands/invoke.js +2 -3
  113. package/dist/cli/commands/login.js +14 -25
  114. package/dist/cli/commands/models.js +1 -1
  115. package/dist/cli/commands/start.js +15 -8
  116. package/dist/cli/commands/tool.js +2 -1
  117. package/dist/cli/fail.d.ts +0 -2
  118. package/dist/cli/fail.js +1 -1
  119. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  120. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  121. package/dist/cli/kernel.d.ts +3 -2
  122. package/dist/cli/models-view.d.ts +21 -0
  123. package/dist/cli/models-view.js +66 -0
  124. package/dist/cli/program.js +79 -21
  125. package/dist/cli/serve.d.ts +38 -18
  126. package/dist/cli/serve.js +197 -39
  127. package/dist/cli/shared.d.ts +26 -6
  128. package/dist/cli/shared.js +163 -33
  129. package/dist/collect.d.ts +14 -3
  130. package/dist/collect.js +24 -0
  131. package/dist/core.d.ts +3 -1
  132. package/dist/core.js +2 -0
  133. package/dist/deploy/docker/plan.d.ts +2 -2
  134. package/dist/deploy/docker/plan.js +8 -6
  135. package/dist/deploy/fly/plan.d.ts +3 -1
  136. package/dist/deploy/fly/plan.js +12 -7
  137. package/dist/deploy/fly/run.d.ts +5 -4
  138. package/dist/deploy/fly/run.js +16 -5
  139. package/dist/deploy/preflight.d.ts +10 -4
  140. package/dist/deploy/preflight.js +53 -9
  141. package/dist/deploy/railway/plan.d.ts +3 -1
  142. package/dist/deploy/railway/plan.js +12 -4
  143. package/dist/deploy/railway/run.d.ts +5 -4
  144. package/dist/deploy/railway/run.js +15 -5
  145. package/dist/deploy/runner.d.ts +2 -1
  146. package/dist/deploy/secrets.d.ts +2 -1
  147. package/dist/deploy/secrets.js +23 -3
  148. package/dist/dev-supervisor.d.ts +0 -2
  149. package/dist/dev-supervisor.js +7 -3
  150. package/dist/engines/pi/channel.d.ts +22 -16
  151. package/dist/engines/pi/channel.js +90 -60
  152. package/dist/engines/pi/chat.d.ts +4 -16
  153. package/dist/engines/pi/chat.js +8 -261
  154. package/dist/engines/pi/config.d.ts +14 -9
  155. package/dist/engines/pi/config.js +33 -18
  156. package/dist/engines/pi/create.d.ts +22 -2
  157. package/dist/engines/pi/create.js +20 -14
  158. package/dist/engines/pi/harness.d.ts +49 -0
  159. package/dist/engines/pi/harness.js +90 -2
  160. package/dist/engines/pi/invoke.d.ts +55 -2
  161. package/dist/engines/pi/invoke.js +304 -19
  162. package/dist/engines/pi/login.d.ts +11 -0
  163. package/dist/engines/pi/login.js +16 -4
  164. package/dist/engines/pi/models.d.ts +56 -10
  165. package/dist/engines/pi/models.js +61 -23
  166. package/dist/engines/pi/session-builder.d.ts +16 -0
  167. package/dist/engines/pi/session-builder.js +308 -0
  168. package/dist/engines/pi/session-control.d.ts +50 -0
  169. package/dist/engines/pi/session-control.js +604 -0
  170. package/dist/engines/pi/sessions.d.ts +17 -2
  171. package/dist/engines/pi/sessions.js +9 -0
  172. package/dist/engines/pi/tool-context.d.ts +18 -11
  173. package/dist/engines/pi/tool-context.js +3 -9
  174. package/dist/engines/pi/tool.d.ts +6 -8
  175. package/dist/engines/pi/tool.js +6 -1
  176. package/dist/engines/pi/wake-tool.d.ts +0 -3
  177. package/dist/engines/pi/wake-tool.js +9 -7
  178. package/dist/engines/pi/workspace.d.ts +52 -0
  179. package/dist/engines/pi/workspace.js +72 -15
  180. package/dist/feishu.d.ts +1 -1
  181. package/dist/feishu.js +1 -1
  182. package/dist/host/node.d.ts +23 -6
  183. package/dist/host/node.js +5 -4
  184. package/dist/index.d.ts +1 -0
  185. package/dist/index.js +1 -0
  186. package/dist/lark.d.ts +1 -1
  187. package/dist/lark.js +1 -1
  188. package/dist/observe.js +3 -0
  189. package/dist/pi.d.ts +5 -4
  190. package/dist/pi.js +2 -1
  191. package/dist/scaffold/add-channel.d.ts +19 -6
  192. package/dist/scaffold/add-channel.js +126 -17
  193. package/dist/scaffold/templates/fastagent.config.mjs +4 -3
  194. package/dist/schedule/wakeups.d.ts +0 -3
  195. package/dist/schedule/wakeups.js +1 -1
  196. package/dist/session-remote.d.ts +53 -0
  197. package/dist/session-remote.js +336 -0
  198. package/dist/session.d.ts +265 -0
  199. package/dist/session.js +37 -0
  200. package/dist/slack.d.ts +2 -0
  201. package/dist/slack.js +2 -0
  202. package/dist/tunnel.d.ts +7 -4
  203. package/dist/tunnel.js +21 -10
  204. package/package.json +19 -7
  205. package/dist/channels/feishu/seen.d.ts +0 -5
  206. package/dist/channels/feishu/seen.js +0 -47
  207. package/dist/cli-add-feishu.d.ts +0 -8
  208. package/dist/cli-models.d.ts +0 -11
  209. package/dist/cli-models.js +0 -20
@@ -0,0 +1,451 @@
1
+ /** First-party Slack HTTP Events API channel: signed ingress, durable turns/context, files, and edited previews. */
2
+ import { createHmac, timingSafeEqual } from "node:crypto";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { log } from "../../log.js";
5
+ import { readBodyCapped } from "../body.js";
6
+ import { text } from "../respond.js";
7
+ import { createSeenRing } from "../seen.js";
8
+ import { createTaskTracker } from "../tasks.js";
9
+ import { ensureStateHome } from "../state.js";
10
+ import { dispatchStop, isStopText } from "../stop-command.js";
11
+ import { codePointPrefix } from "../text.js";
12
+ import { createTurnQueue } from "../turn-queue.js";
13
+ import { createTurnStore } from "../turn-store.js";
14
+ import { createSlackBotTokenProvider } from "./bot-auth.js";
15
+ import { collectSlackBufferedFiles, createSlackContextBuffer } from "./context-buffer.js";
16
+ import { invokeSlackTurn } from "./invoke-turn.js";
17
+ import { createOwnedSlackThreads } from "./owned-threads.js";
18
+ import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, isSlackHumanMessage, slackBufferText, slackEnvelope, slackFileIds, slackMessageText, slackPlaceKey, slackSenderLabel, slackTeamId, } from "./parse.js";
19
+ import { defaultErrorMessage, settleSlackPreview, streamSlackReply, } from "./preview.js";
20
+ import { resolveReactionEmojis, startSlackReaction } from "./reaction.js";
21
+ import { createSlackApi } from "./slack-api.js";
22
+ import { createWelcomedUsers } from "./welcomed.js";
23
+ export { defaultSlackRoute, slackEnvelope };
24
+ const MAX_EVENT_BYTES = 1 << 20;
25
+ const MAX_TURN_ATTEMPTS = 3;
26
+ const MAX_SIGNATURE_AGE_S = 5 * 60;
27
+ const QUEUED_PLACEHOLDER = "⏳ Queued — I’ll start once the current task finishes.";
28
+ const DEFERRED_PLACEHOLDER = "⏳ Delayed by a temporary system issue — I’ll retry automatically.";
29
+ const DEFAULT_WELCOME = "👋 Hi! I'm an AI agent here to help. Ask a question or describe a task and I'll get to work.";
30
+ function isStoredSlackTurn(value) {
31
+ const turn = value;
32
+ return (typeof turn?.id === "string" &&
33
+ typeof turn.seq === "number" &&
34
+ typeof turn.session === "string" &&
35
+ typeof turn.baseText === "string" &&
36
+ typeof turn.bufferKey === "string" &&
37
+ typeof turn.teamId === "string" &&
38
+ typeof turn.channelId === "string" &&
39
+ (turn.threadTs === undefined || typeof turn.threadTs === "string") &&
40
+ (turn.requesterUserId === undefined || typeof turn.requesterUserId === "string") &&
41
+ (turn.threadTitle === undefined || typeof turn.threadTitle === "string") &&
42
+ Array.isArray(turn.fileIds) &&
43
+ turn.fileIds.every((id) => typeof id === "string") &&
44
+ typeof turn.attempts === "number");
45
+ }
46
+ /** Verify Slack's v0 HMAC over the exact raw body and reject timestamps outside the replay window. */
47
+ export function verifySlackSignature(signingSecret, timestamp, signature, rawBody, nowMs = Date.now()) {
48
+ if (!/^\d+$/.test(timestamp) || !/^v0=[a-f0-9]{64}$/i.test(signature))
49
+ return false;
50
+ const seconds = Number(timestamp);
51
+ if (!Number.isSafeInteger(seconds) || Math.abs(Math.floor(nowMs / 1000) - seconds) > MAX_SIGNATURE_AGE_S)
52
+ return false;
53
+ 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
+ }
58
+ /** The (channel, ts) of the user's triggering message, encoded in the logical turn id `team:channel:ts`
59
+ * (Slack ids never contain a colon, so a 3-part split is exact). Used to place the reaction ack. */
60
+ function messageRefOf(turnId) {
61
+ const parts = turnId.split(":");
62
+ return parts.length === 3 && parts[1] && parts[2] ? { channelId: parts[1], ts: parts[2] } : undefined;
63
+ }
64
+ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientId, clientSecret, botTokenExpiresAt, directMessageSession = "threaded", groupMessageSession = "threaded", groupBehavior = "context", rendering = "native", taskDisplay = "plan", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", }) {
65
+ if (!["continuous", "threaded"].includes(directMessageSession)) {
66
+ throw new Error('slackChannel directMessageSession must be "continuous" or "threaded"');
67
+ }
68
+ if (!["continuous", "threaded"].includes(groupMessageSession)) {
69
+ throw new Error('slackChannel groupMessageSession must be "continuous" or "threaded"');
70
+ }
71
+ if (!["context", "mentions"].includes(groupBehavior)) {
72
+ throw new Error('slackChannel groupBehavior must be "context" or "mentions"');
73
+ }
74
+ if (!["native", "classic"].includes(rendering)) {
75
+ throw new Error('slackChannel rendering must be "native" or "classic"');
76
+ }
77
+ if (!["timeline", "plan", "dense"].includes(taskDisplay)) {
78
+ throw new Error('slackChannel taskDisplay must be "timeline", "plan", or "dense"');
79
+ }
80
+ if (welcome !== false && typeof welcome !== "string") {
81
+ throw new Error("slackChannel welcome must be a string or false");
82
+ }
83
+ const reactionEmojis = resolveReactionEmojis(reactionAck);
84
+ return ({ agent, stateRoot, control }) => {
85
+ if (!botToken)
86
+ throw new Error("slackChannel requires a non-empty botToken (Bot User OAuth Token)");
87
+ if (!signingSecret)
88
+ throw new Error("slackChannel requires a non-empty signingSecret (Basic Information → App Credentials)");
89
+ if (!isAbsolute(stateRoot))
90
+ throw new Error(`slackChannel requires an absolute ctx.stateRoot, got "${stateRoot}"`);
91
+ const label = "[slack]";
92
+ const formatError = onError ?? defaultErrorMessage;
93
+ const stateHome = join(stateRoot, "channels", "slack");
94
+ ensureStateHome(stateHome);
95
+ const currentBotToken = createSlackBotTokenProvider({
96
+ statePath: join(stateHome, "bot-auth.json"),
97
+ botToken,
98
+ botRefreshToken,
99
+ clientId,
100
+ clientSecret,
101
+ botTokenExpiresAt,
102
+ apiBaseUrl,
103
+ });
104
+ const api = createSlackApi({ botToken: currentBotToken, baseUrl: apiBaseUrl });
105
+ let authenticatedTeamId;
106
+ let botUserId;
107
+ let authenticationState = "pending";
108
+ const authentication = api.authTest().then((identity) => {
109
+ authenticatedTeamId = identity.teamId;
110
+ botUserId = identity.userId;
111
+ authenticationState = "ready";
112
+ log.info(`${label} authenticated${identity.teamId ? ` for workspace ${identity.teamId}` : ""}`);
113
+ }, (error) => {
114
+ authenticationState = "failed";
115
+ throw new Error(`Slack auth.test failed — fix SLACK_BOT_TOKEN before accepting events: ${String(error)}`, {
116
+ cause: error,
117
+ });
118
+ });
119
+ // Construction cannot await Web API IO. Keep the rejection observed; recovered turns and ingress
120
+ // both gate on the same promise/state below and surface the failure without ACKing new work.
121
+ void authentication.catch((error) => log.error(`${label} ${String(error)}`));
122
+ const waitForAuthentication = () => {
123
+ if (authenticationState !== "pending")
124
+ return authentication;
125
+ return new Promise((resolve, reject) => {
126
+ const timeout = setTimeout(() => reject(new Error("Slack auth.test did not finish inside the Events API ACK budget")), 2_000);
127
+ void authentication.then(() => {
128
+ clearTimeout(timeout);
129
+ resolve();
130
+ }, (error) => {
131
+ clearTimeout(timeout);
132
+ reject(error);
133
+ });
134
+ });
135
+ };
136
+ const seen = createSeenRing(join(stateHome, "seen.json"), label);
137
+ const ownedThreads = createOwnedSlackThreads(join(stateHome, "owned-threads.json"), label);
138
+ const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
139
+ const buffer = createSlackContextBuffer(join(stateHome, "buffers.json"), label);
140
+ const store = createTurnStore(join(stateHome, "turns.json"), {
141
+ label,
142
+ isRecord: isStoredSlackTurn,
143
+ order: (a, b) => a.seq - b.seq,
144
+ });
145
+ const decide = route ?? defaultSlackRoute;
146
+ const toStored = (turn) => {
147
+ const { previewTs: _preview, nativeQueueStatus: _status, ...intent } = turn;
148
+ return { ...intent, attempts: 0 };
149
+ };
150
+ const targetOf = (turn) => ({
151
+ channelId: turn.channelId,
152
+ threadTs: turn.threadTs,
153
+ recipientUserId: turn.requesterUserId,
154
+ recipientTeamId: turn.teamId,
155
+ });
156
+ const notices = new Map();
157
+ const queue = createTurnQueue({
158
+ label,
159
+ onQueuedBehind(turn) {
160
+ const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
161
+ if (nativeDmStatus)
162
+ turn.nativeQueueStatus = true;
163
+ notices.set(turn.id, authentication
164
+ .then(() => nativeDmStatus
165
+ ? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
166
+ : api.postMessage(targetOf(turn), QUEUED_PLACEHOLDER).then((ts) => {
167
+ turn.previewTs = ts;
168
+ }))
169
+ .catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)));
170
+ },
171
+ run: async (turn) => {
172
+ await notices.get(turn.id);
173
+ notices.delete(turn.id);
174
+ try {
175
+ await authentication;
176
+ }
177
+ catch (error) {
178
+ // Leave the intent untouched. A fixed token + restart replays it; running now would execute an
179
+ // Agent turn whose only customer-facing transport is known to be unavailable.
180
+ log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
181
+ return;
182
+ }
183
+ const attempt = store.startAttempt(turn.id, MAX_TURN_ATTEMPTS);
184
+ if (attempt === "exceeded") {
185
+ notifyDropped(turn);
186
+ return;
187
+ }
188
+ if (attempt === "defer") {
189
+ if (turn.nativeQueueStatus) {
190
+ void api
191
+ .setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
192
+ .catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
193
+ }
194
+ else if (turn.previewTs) {
195
+ void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
196
+ }
197
+ return;
198
+ }
199
+ const startedAt = Date.now();
200
+ log.info(`${label} turn start: turn=${turn.id} session=${turn.session} channel=${turn.channelId}`);
201
+ const { text: recent, consumed } = buffer.peek(turn.bufferKey);
202
+ const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${turn.baseText}` : turn.baseText;
203
+ const buffered = collectSlackBufferedFiles(consumed, new Set(turn.fileIds));
204
+ const messageRef = messageRefOf(turn.id);
205
+ const reaction = reactionEmojis && messageRef
206
+ ? await startSlackReaction({
207
+ api,
208
+ channelId: messageRef.channelId,
209
+ ts: messageRef.ts,
210
+ emojis: reactionEmojis,
211
+ label,
212
+ })
213
+ : undefined;
214
+ try {
215
+ await streamSlackReply(invokeSlackTurn(agent, turn.session, prompt, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, { primaryFileIds: turn.fileIds, buffered }, () => {
216
+ store.remove(turn.id);
217
+ buffer.commit(turn.bufferKey, consumed);
218
+ }), api, targetOf(turn), formatError, {
219
+ rendering,
220
+ initialPreviewTs: turn.previewTs,
221
+ threadTitle: turn.threadTitle,
222
+ disclaimer: aiDisclaimer,
223
+ taskDisplay,
224
+ label,
225
+ });
226
+ log.info(`${label} turn done: turn=${turn.id} session=${turn.session} (${Date.now() - startedAt}ms)`);
227
+ await reaction?.complete();
228
+ }
229
+ catch (error) {
230
+ log.error(`${label} turn failed: turn=${turn.id} session=${turn.session}: ${String(error)}`);
231
+ await reaction?.remove();
232
+ }
233
+ finally {
234
+ store.remove(turn.id);
235
+ }
236
+ },
237
+ });
238
+ const notifyDropped = (turn) => {
239
+ const target = targetOf(turn);
240
+ if (turn.nativeQueueStatus)
241
+ void api.setThreadStatus(target, "").catch(() => { });
242
+ 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)}`));
243
+ };
244
+ const submit = (turn, persist) => {
245
+ if (persist) {
246
+ store.add(toStored(turn));
247
+ seen.add(turn.id);
248
+ }
249
+ queue.accept(turn);
250
+ };
251
+ const recovered = store.recover();
252
+ if (recovered.length)
253
+ log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
254
+ let seq = recovered.reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
255
+ for (const { attempts: _attempts, ...intent } of recovered)
256
+ submit({ ...intent }, false);
257
+ const acceptEvent = (envelope) => {
258
+ const event = envelope.event;
259
+ if (!isSlackHumanMessage(event))
260
+ return;
261
+ if (botUserId && event.user === botUserId)
262
+ return;
263
+ const teamId = slackTeamId(envelope) ?? authenticatedTeamId;
264
+ if (!teamId) {
265
+ log.warn(`${label} ignored message ${event.ts}: event carried no workspace/enterprise identity`);
266
+ return;
267
+ }
268
+ const logicalId = `${teamId}:${event.channel}:${event.ts}`;
269
+ if (seen.has(logicalId)) {
270
+ log.debug(`${label} duplicate logical message ${logicalId} — skipping`);
271
+ return;
272
+ }
273
+ const group = isSlackGroupMessage(event);
274
+ const direct = isSlackDirectMessage(event);
275
+ const rootTs = event.thread_ts ?? event.ts;
276
+ const bufferKey = slackPlaceKey(teamId, event);
277
+ const threadedGroup = group && groupMessageSession === "threaded";
278
+ const managedContinuation = groupBehavior === "context" &&
279
+ route === undefined &&
280
+ threadedGroup &&
281
+ event.thread_ts !== undefined &&
282
+ ownedThreads.has(teamId, event.channel, event.thread_ts);
283
+ let routed = decide(envelope);
284
+ const hasUserMention = /<@[A-Z0-9]+>/i.test(event.text ?? "");
285
+ const structurallyMentionsBot = botUserId !== undefined && (event.text ?? "").includes(`<@${botUserId}>`);
286
+ // app_mention and message.* subscriptions can overlap. If message.* arrives first, structural bot
287
+ // identity routes it now; while auth.test is still unresolved, defer any mentioned message rather
288
+ // than buffer+dedup it and accidentally suppress the later app_mention callback.
289
+ if (!routed && route === undefined && group && event.type === "message" && structurallyMentionsBot)
290
+ routed = {};
291
+ if (!routed && managedContinuation && event.type !== "app_mention")
292
+ routed = {};
293
+ if (!routed) {
294
+ if (route === undefined && group && botUserId === undefined && hasUserMention)
295
+ return;
296
+ if (groupBehavior === "context" && route === undefined && group) {
297
+ const body = slackBufferText(slackMessageText(event));
298
+ if (body) {
299
+ const fileIds = slackFileIds(event);
300
+ buffer.push(bufferKey, {
301
+ sender: slackSenderLabel(event),
302
+ body,
303
+ messageId: event.ts,
304
+ replyTo: event.thread_ts,
305
+ fileIds: fileIds.length ? fileIds : undefined,
306
+ });
307
+ seen.add(logicalId);
308
+ log.debug(`${label} buffered unsummoned group message ${logicalId} (place ${bufferKey})`);
309
+ }
310
+ }
311
+ return;
312
+ }
313
+ const targetChannel = routed.channelId ?? event.channel;
314
+ const sameChannel = targetChannel === event.channel;
315
+ const defaultThread = group
316
+ ? (event.thread_ts ?? (groupMessageSession === "threaded" ? event.ts : undefined))
317
+ : (event.thread_ts ?? (directMessageSession === "threaded" ? event.ts : undefined));
318
+ const threadTs = routed.threadTs === null ? undefined : (routed.threadTs ?? (sameChannel ? defaultThread : undefined));
319
+ const continuousTopLevel = event.thread_ts === undefined;
320
+ const defaultSession = direct
321
+ ? directMessageSession === "continuous" && continuousTopLevel
322
+ ? `slack:${teamId}:${event.channel}`
323
+ : `slack:${teamId}:${event.channel}:${rootTs}`
324
+ : groupMessageSession === "continuous" && continuousTopLevel
325
+ ? `slack:${teamId}:${event.channel}`
326
+ : `slack:${teamId}:${event.channel}:${rootTs}`;
327
+ // Explicit user stop: a control action, never a turn — it must not queue behind the run it
328
+ // stops. Match the bare word after stripping the bot mention; record the logical id so a Slack
329
+ // redelivery doesn't double-abort or double-notify.
330
+ if (isStopText((event.text ?? "").replace(/<@[A-Z0-9]+>/gi, " "))) {
331
+ seen.add(logicalId);
332
+ const target = { channelId: event.channel, threadTs: event.thread_ts };
333
+ sideTasks.track(dispatchStop(control, routed.session ?? defaultSession, label)
334
+ .then((feedback) => api.postMessage(target, feedback).then(() => undefined))
335
+ .catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
336
+ return;
337
+ }
338
+ const fileIds = slackFileIds(event);
339
+ const baseText = routed.text ?? slackEnvelope(envelope);
340
+ if (!baseText.trim() && fileIds.length === 0)
341
+ return;
342
+ const threadTitle = direct && event.thread_ts === undefined
343
+ ? codePointPrefix(slackMessageText(event)
344
+ .replace(/<@[A-Z0-9]+>/gi, "")
345
+ .replace(/\s+/g, " ")
346
+ .trim(), 80)
347
+ : undefined;
348
+ // Match Feishu/Lark ownership in context mode: only a top-level summon that creates an
349
+ // Agent-managed thread owns the root. Mentioning the Agent inside an existing human thread
350
+ // answers once without adopting it; mention-only mode never consumes ownership state.
351
+ if (groupBehavior === "context" &&
352
+ route === undefined &&
353
+ threadedGroup &&
354
+ event.thread_ts === undefined &&
355
+ sameChannel) {
356
+ ownedThreads.add(teamId, event.channel, rootTs);
357
+ }
358
+ submit({
359
+ id: logicalId,
360
+ seq: ++seq,
361
+ session: routed.session ?? defaultSession,
362
+ baseText,
363
+ bufferKey,
364
+ teamId,
365
+ channelId: targetChannel,
366
+ threadTs,
367
+ requesterUserId: event.user,
368
+ threadTitle: threadTitle || undefined,
369
+ fileIds,
370
+ }, true);
371
+ };
372
+ // Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
373
+ const sideTasks = createTaskTracker();
374
+ // First-run DM welcome: app_home_opened(tab="messages") signals a DM open. Post once per user.
375
+ const welcomeInFlight = new Set();
376
+ const maybeWelcome = (envelope) => {
377
+ if (welcome === false)
378
+ return;
379
+ const body = welcome.trim();
380
+ if (!body)
381
+ return;
382
+ const event = envelope.event;
383
+ if (!event)
384
+ return;
385
+ if (event.type !== "app_home_opened" || event.tab !== "messages")
386
+ return;
387
+ const userId = event.user;
388
+ const channelId = event.channel;
389
+ if (!userId || !channelId)
390
+ return;
391
+ const teamId = slackTeamId(envelope) ?? authenticatedTeamId;
392
+ if (!teamId)
393
+ return;
394
+ const id = `${teamId}:${userId}`;
395
+ if (welcomed.has(teamId, userId) || welcomeInFlight.has(id))
396
+ return;
397
+ // Reserve in-memory so rapid re-opens don't double-post; persist to the durable set only on a
398
+ // successful post, so a failed post simply retries on the next open.
399
+ welcomeInFlight.add(id);
400
+ sideTasks.track(api
401
+ .postMarkdown({ channelId }, body)
402
+ .then(() => {
403
+ welcomed.add(teamId, userId);
404
+ log.info(`${label} sent first-run welcome to ${id}`);
405
+ })
406
+ .catch((error) => log.warn(`${label} could not send first-run welcome (retries on next open): ${String(error)}`))
407
+ .finally(() => {
408
+ welcomeInFlight.delete(id);
409
+ }));
410
+ };
411
+ const handler = async (request) => {
412
+ if (request.method !== "POST")
413
+ return text("POST only\n", 405);
414
+ const body = await readBodyCapped(request, MAX_EVENT_BYTES);
415
+ if ("tooLarge" in body)
416
+ return text("payload too large\n", 413);
417
+ const timestamp = request.headers.get("x-slack-request-timestamp") ?? "";
418
+ const signature = request.headers.get("x-slack-signature") ?? "";
419
+ if (!verifySlackSignature(signingSecret, timestamp, signature, body.text)) {
420
+ log.warn(`${label} rejected an event with an invalid/stale X-Slack-Signature`);
421
+ return text("invalid signature\n", 401);
422
+ }
423
+ let envelope;
424
+ try {
425
+ envelope = JSON.parse(body.text);
426
+ if (typeof envelope !== "object" || envelope === null)
427
+ throw new Error("not an object");
428
+ }
429
+ catch {
430
+ return text("invalid json\n", 400);
431
+ }
432
+ if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
433
+ return Response.json({ challenge: envelope.challenge });
434
+ }
435
+ if (envelope.type !== "event_callback")
436
+ return new Response(null, { status: 200 });
437
+ try {
438
+ await waitForAuthentication();
439
+ }
440
+ catch (error) {
441
+ log.error(`${label} refusing to ACK an event because Slack authentication is unavailable: ${String(error)}`);
442
+ return text("slack authentication unavailable\n", 503);
443
+ }
444
+ maybeWelcome(envelope);
445
+ acceptEvent(envelope);
446
+ return new Response(null, { status: 200 });
447
+ };
448
+ handler.turnsIdle = () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined);
449
+ return { "POST /slack": handler };
450
+ };
451
+ }
@@ -0,0 +1,5 @@
1
+ export interface WelcomedUsers {
2
+ has(teamId: string, userId: string): boolean;
3
+ add(teamId: string, userId: string): void;
4
+ }
5
+ export declare function createWelcomedUsers(path: string, label?: string): WelcomedUsers;
@@ -0,0 +1,32 @@
1
+ /** Durable set of users who have already received the first-run DM welcome, so it is sent at most once. */
2
+ import { log } from "../../log.js";
3
+ import { loadStateFile, saveStateFile } from "../state.js";
4
+ function key(teamId, userId) {
5
+ return `${teamId}:${userId}`;
6
+ }
7
+ export function createWelcomedUsers(path, label = "[slack]") {
8
+ const raw = loadStateFile(path);
9
+ let ids = new Set();
10
+ if (raw !== undefined) {
11
+ if (Array.isArray(raw) && raw.every((id) => typeof id === "string")) {
12
+ ids = new Set(raw);
13
+ }
14
+ else {
15
+ log.warn(`${label} unexpected shape in ${path} — starting with no welcomed users`);
16
+ }
17
+ }
18
+ return {
19
+ has(teamId, userId) {
20
+ return ids.has(key(teamId, userId));
21
+ },
22
+ add(teamId, userId) {
23
+ const id = key(teamId, userId);
24
+ if (ids.has(id))
25
+ return;
26
+ const next = new Set(ids);
27
+ next.add(id);
28
+ saveStateFile(path, [...next]);
29
+ ids = next;
30
+ },
31
+ };
32
+ }
@@ -2,9 +2,9 @@
2
2
  * Durable channel state for a SINGLE-PROCESS deployment (the supported production shape — no
3
3
  * cross-instance locking; two processes must not share a state dir). Small JSON files, written
4
4
  * atomically (tmp + rename), so a crash leaves the previous version on disk, never a torn file.
5
- * Writes are synchronous: the files are KB-sized and a write that completes BEFORE the webhook 200
6
- * is what makes the state actually durable (an ACKed webhook delivery is not redelivered).
7
- * Channel-neutral: every stateful channel (telegram, Feishu; Lark compatibility) derives its home from the ctx state root
5
+ * Writes are synchronous: the files are KB-sized and a write that completes BEFORE the transport ACK
6
+ * is what makes the state actually durable (an ACKed delivery is not redelivered).
7
+ * Channel-neutral: every stateful channel (Telegram, Feishu/Lark, Slack) derives its home from the ctx state root
8
8
  * and persists through these three primitives.
9
9
  *
10
10
  * Failure split: a CORRUPT file (bad JSON) degrades visibly — log.warn + start empty — because channel
@@ -0,0 +1,6 @@
1
+ import { type SessionControl } from "../session.ts";
2
+ /** Bare stop word for summon-body matching (Slack/Feishu); Telegram uses its native `/stop` command. */
3
+ export declare function isStopText(text: string): boolean;
4
+ /** Dispatch `abort` for the session and map the outcome to the customer-facing line. Never throws;
5
+ * full details go to the operator log. */
6
+ export declare function dispatchStop(control: SessionControl | undefined, session: string, label: string): Promise<string>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * SHARED user-facing stop command: the chat channels (telegram, feishu/lark, slack) map an explicit
3
+ * "stop" ask onto the session control plane's `abort`. Policy decisions (design session-control.md
4
+ * §15): the stop message is a CONTROL ACTION, never a turn (it must not queue behind the run it
5
+ * stops); only the ACTIVE run is aborted — queued durable turns are independent asks and keep their
6
+ * at-least-once floor; and the hub stays gated by `config.sessionControl`, so without it the command
7
+ * degrades to a visible "not enabled" notice, never a silent ignore.
8
+ */
9
+ import { log } from "../log.js";
10
+ import { NO_ACTIVE_RUN_CODE } from "../session.js";
11
+ /** Bare stop word for summon-body matching (Slack/Feishu); Telegram uses its native `/stop` command. */
12
+ export function isStopText(text) {
13
+ return /^(stop|cancel)[.!]?$/i.test(text.trim());
14
+ }
15
+ const STOPPED_NOTICE = "⏹ Stopped.";
16
+ const NOTHING_RUNNING_NOTICE = "Nothing is running.";
17
+ const STOP_UNAVAILABLE_NOTICE = "⚠️ Stop isn't enabled on this deployment (set sessionControl: true in fastagent.config).";
18
+ /** Dispatch `abort` for the session and map the outcome to the customer-facing line. Never throws;
19
+ * full details go to the operator log. */
20
+ export async function dispatchStop(control, session, label) {
21
+ if (!control)
22
+ return STOP_UNAVAILABLE_NOTICE;
23
+ try {
24
+ const result = await control.dispatch(session, { type: "abort" });
25
+ if (result.ok)
26
+ return STOPPED_NOTICE;
27
+ if (result.error.code === NO_ACTIVE_RUN_CODE)
28
+ return NOTHING_RUNNING_NOTICE;
29
+ log.warn(`${label} stop dispatch rejected for ${session}: ${result.error.code} — ${result.error.message}`);
30
+ return `⚠️ Could not stop (${result.error.code}).`;
31
+ }
32
+ catch (error) {
33
+ log.warn(`${label} stop dispatch failed for ${session}: ${String(error)}`);
34
+ return "⚠️ Could not stop — see the server logs.";
35
+ }
36
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
3
+ * feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
4
+ * (`turnsIdle`) — otherwise a reply in flight when the process exits is silently dropped. Error
5
+ * handling stays with the caller: track() only guarantees the drain sees the task settle.
6
+ */
7
+ export interface TaskTracker {
8
+ /** Track one task. The caller keeps its own `.catch` — rejections must already be handled. */
9
+ track(task: Promise<unknown>): void;
10
+ /** Resolves when every currently-tracked task has settled. */
11
+ drain(): Promise<void>;
12
+ }
13
+ export declare function createTaskTracker(): TaskTracker;
@@ -0,0 +1,10 @@
1
+ export function createTaskTracker() {
2
+ const tasks = new Set();
3
+ return {
4
+ track(task) {
5
+ tasks.add(task);
6
+ void task.finally(() => tasks.delete(task)).catch(() => { }); // the caller's chain owns the error
7
+ },
8
+ drain: () => Promise.all(tasks).then(() => undefined),
9
+ };
10
+ }
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Telegram's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
3
+ * the entry shape, its fold-line rendering, and buffered-attachment selection. Bucketed by Telegram
4
+ * "place" (chat[:thread]), not session: an un-summoned message has no route session, and the flush
5
+ * feeds whatever turn answers that place.
6
+ */
7
+ import { type ContextBuffer as GenericContextBuffer } from "../context-buffer.ts";
1
8
  /** One buffered un-summoned message (object identity is the commit key). Besides the sender label and
2
9
  * one-line body, it carries what a LATER summon needs to resolve references into the discussion:
3
10
  * message ids ("reply to the one Alex answered"), and attachment file_ids so "summarize the file from
@@ -39,21 +46,5 @@ export declare function collectAttachments(consumed: BufferEntry[], primary: {
39
46
  images: BufferedRef[];
40
47
  skipped: number;
41
48
  };
42
- export interface ContextBuffer {
43
- /** Record an un-summoned message. Persists BEFORE returning (pre-ACK: a throw becomes the webhook's
44
- * 500, and Telegram redelivers once the disk recovers) — staged on a copy and rolled back on a
45
- * failed write, so the redelivery does not double-append the entry already in memory. */
46
- push(placeKey: string, entry: BufferEntry): void;
47
- /** Render the fold text and snapshot the consumed entries (see the module header's consume protocol). */
48
- peek(placeKey: string): {
49
- text: string;
50
- consumed: BufferEntry[];
51
- };
52
- /** Remove exactly `consumed` (by identity) — call on the turn's `completed` event, when the folded
53
- * discussion provably lives in the durable session. Consumes entries WHOLE, including ones whose
54
- * attachments failed to load or were cap-skipped: their text is in the session (keeping them would
55
- * re-fold duplicate text), and the prompt note said what is missing; re-post an attachment to use
56
- * it. Post-ACK: a failed write is logged, never thrown (it must not abort the turn's delivery). */
57
- commit(placeKey: string, consumed: BufferEntry[]): void;
58
- }
49
+ export type ContextBuffer = GenericContextBuffer<BufferEntry>;
59
50
  export declare function createContextBuffer(path: string): ContextBuffer;