@fastagent-sh/fastagent 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +5 -1
  2. package/dist/channels/agentcore-protocol.d.ts +112 -0
  3. package/dist/channels/agentcore-protocol.js +22 -0
  4. package/dist/channels/agentcore-service.d.ts +0 -4
  5. package/dist/channels/agentcore-service.js +1 -6
  6. package/dist/channels/agentcore-state.d.ts +5 -11
  7. package/dist/channels/agentcore-state.js +4 -1
  8. package/dist/channels/agentcore.d.ts +1 -67
  9. package/dist/channels/agentcore.js +90 -89
  10. package/dist/channels/control.d.ts +1 -1
  11. package/dist/channels/control.js +13 -62
  12. package/dist/channels/discover.d.ts +0 -1
  13. package/dist/channels/discover.js +1 -3
  14. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  15. package/dist/channels/feishu/context-buffer.js +0 -38
  16. package/dist/channels/feishu/crypto.d.ts +0 -2
  17. package/dist/channels/feishu/crypto.js +3 -8
  18. package/dist/channels/feishu/feishu-api.js +2 -4
  19. package/dist/channels/feishu/feishu.js +56 -115
  20. package/dist/channels/feishu/parse.d.ts +4 -3
  21. package/dist/channels/feishu/parse.js +5 -4
  22. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  23. package/dist/channels/feishu/register-webhook.js +43 -57
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  26. package/dist/channels/feishu/setup-mode.js +26 -0
  27. package/dist/channels/feishu/shared-api.d.ts +10 -0
  28. package/dist/channels/feishu/shared-api.js +38 -0
  29. package/dist/channels/http.d.ts +0 -8
  30. package/dist/channels/http.js +4 -56
  31. package/dist/channels/kit/turn-runner.d.ts +59 -0
  32. package/dist/channels/kit/turn-runner.js +84 -0
  33. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  34. package/dist/channels/registration.d.ts +36 -1
  35. package/dist/channels/registration.js +57 -1
  36. package/dist/channels/secret.d.ts +1 -0
  37. package/dist/channels/secret.js +16 -0
  38. package/dist/channels/slack/config-api.d.ts +35 -3
  39. package/dist/channels/slack/config-api.js +51 -9
  40. package/dist/channels/slack/manifest.js +5 -1
  41. package/dist/channels/slack/onboard.d.ts +5 -5
  42. package/dist/channels/slack/onboard.js +41 -20
  43. package/dist/channels/slack/register-webhook.d.ts +3 -2
  44. package/dist/channels/slack/register-webhook.js +34 -14
  45. package/dist/channels/slack/scaffold/channel.ts +3 -10
  46. package/dist/channels/slack/scaffold/slack-send.ts +18 -126
  47. package/dist/channels/slack/shared-api.d.ts +10 -0
  48. package/dist/channels/slack/shared-api.js +34 -0
  49. package/dist/channels/slack/slack-api.d.ts +20 -2
  50. package/dist/channels/slack/slack-api.js +81 -22
  51. package/dist/channels/slack/slack.d.ts +0 -10
  52. package/dist/channels/slack/slack.js +60 -99
  53. package/dist/channels/sse.d.ts +4 -0
  54. package/dist/channels/sse.js +66 -0
  55. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  56. package/dist/channels/telegram/register-webhook.js +44 -42
  57. package/dist/channels/telegram/telegram.js +47 -142
  58. package/dist/channels/wait-health.js +7 -4
  59. package/dist/cli/add-feishu.js +3 -10
  60. package/dist/cli/add-slack.js +7 -20
  61. package/dist/cli/commands/add.d.ts +0 -1
  62. package/dist/cli/commands/add.js +22 -31
  63. package/dist/cli/commands/chat.js +6 -12
  64. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  65. package/dist/cli/commands/deploy/agentcore.js +178 -0
  66. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  67. package/dist/cli/commands/deploy/docker.js +119 -0
  68. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  69. package/dist/cli/commands/deploy/fly.js +131 -0
  70. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  71. package/dist/cli/commands/deploy/railway.js +71 -0
  72. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  73. package/dist/cli/commands/deploy/shared.js +124 -0
  74. package/dist/cli/commands/deploy.d.ts +17 -49
  75. package/dist/cli/commands/deploy.js +39 -618
  76. package/dist/cli/commands/dev.js +17 -46
  77. package/dist/cli/commands/fire.js +6 -12
  78. package/dist/cli/commands/info.js +2 -1
  79. package/dist/cli/commands/invoke.js +4 -11
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +17 -46
  82. package/dist/cli/commands/tool.js +3 -8
  83. package/dist/cli/kernel.d.ts +0 -2
  84. package/dist/cli/kernel.js +0 -2
  85. package/dist/cli/program.js +7 -12
  86. package/dist/cli/serve.d.ts +36 -19
  87. package/dist/cli/serve.js +91 -7
  88. package/dist/cli/shared.d.ts +13 -18
  89. package/dist/cli/shared.js +20 -4
  90. package/dist/deploy/agentcore/forwarder.js +250 -0
  91. package/dist/deploy/agentcore/plan.d.ts +23 -2
  92. package/dist/deploy/agentcore/plan.js +29 -244
  93. package/dist/deploy/agentcore/run.d.ts +4 -20
  94. package/dist/deploy/agentcore/run.js +22 -7
  95. package/dist/deploy/channel-ingress.js +1 -1
  96. package/dist/deploy/docker/run.d.ts +17 -3
  97. package/dist/deploy/docker/run.js +30 -8
  98. package/dist/deploy/fly/plan.js +7 -0
  99. package/dist/deploy/fly/run.d.ts +27 -0
  100. package/dist/deploy/fly/run.js +102 -20
  101. package/dist/deploy/hosts.d.ts +5 -0
  102. package/dist/deploy/hosts.js +4 -0
  103. package/dist/deploy/preflight.js +3 -3
  104. package/dist/deploy/railway/plan.d.ts +5 -0
  105. package/dist/deploy/railway/plan.js +7 -0
  106. package/dist/deploy/railway/run.d.ts +2 -1
  107. package/dist/deploy/railway/run.js +5 -4
  108. package/dist/deploy/secrets.js +0 -17
  109. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  110. package/dist/engines/pi/agent-session-factory.js +109 -85
  111. package/dist/engines/pi/config.d.ts +1 -1
  112. package/dist/engines/pi/create.d.ts +22 -39
  113. package/dist/engines/pi/create.js +58 -73
  114. package/dist/engines/pi/definition.js +8 -8
  115. package/dist/engines/pi/invoke-session.js +13 -30
  116. package/dist/engines/pi/open.d.ts +2 -2
  117. package/dist/engines/pi/open.js +31 -41
  118. package/dist/engines/pi/retry-event.d.ts +6 -0
  119. package/dist/engines/pi/retry-event.js +15 -0
  120. package/dist/engines/pi/session-builder.js +26 -113
  121. package/dist/engines/pi/session-control.d.ts +10 -18
  122. package/dist/engines/pi/session-control.js +47 -81
  123. package/dist/engines/pi/session-settings.d.ts +1 -1
  124. package/dist/engines/pi/session-settings.js +1 -1
  125. package/dist/engines/pi/session-store.d.ts +12 -17
  126. package/dist/engines/pi/session-store.js +18 -40
  127. package/dist/engines/pi/tool-context.d.ts +3 -3
  128. package/dist/engines/pi/tool.d.ts +6 -9
  129. package/dist/engines/pi/tool.js +1 -0
  130. package/dist/feishu.d.ts +1 -0
  131. package/dist/feishu.js +1 -0
  132. package/dist/lark.d.ts +1 -0
  133. package/dist/lark.js +1 -0
  134. package/dist/loader.d.ts +2 -0
  135. package/dist/loader.js +5 -0
  136. package/dist/log.d.ts +9 -17
  137. package/dist/log.js +25 -30
  138. package/dist/paths.d.ts +7 -3
  139. package/dist/paths.js +17 -4
  140. package/dist/scaffold/add-channel.d.ts +6 -1
  141. package/dist/scaffold/add-channel.js +48 -65
  142. package/dist/schedule/wake-alarm.d.ts +1 -12
  143. package/dist/schedule/wake-alarm.js +2 -3
  144. package/dist/service.d.ts +23 -22
  145. package/dist/service.js +7 -73
  146. package/dist/session-remote.d.ts +5 -5
  147. package/dist/session-remote.js +25 -28
  148. package/dist/session.d.ts +1 -1
  149. package/dist/slack.d.ts +2 -0
  150. package/dist/slack.js +1 -0
  151. package/dist/tunnel.d.ts +16 -6
  152. package/dist/tunnel.js +53 -12
  153. package/package.json +5 -4
  154. package/dist/channels/slack/bot-auth.d.ts +0 -15
  155. package/dist/channels/slack/bot-auth.js +0 -135
@@ -1,9 +1,10 @@
1
1
  /** First-party Slack HTTP Events API channel: signed ingress, durable turns/context, files, and edited previews. */
2
- import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { createHmac } from "node:crypto";
3
3
  import { isAbsolute, join } from "node:path";
4
4
  import { log } from "../../log.js";
5
5
  import { readBodyCapped } from "../body.js";
6
6
  import { text } from "../respond.js";
7
+ import { secretEquals } from "../secret.js";
7
8
  import { createSeenRing } from "../kit/seen.js";
8
9
  import { signatureIsFresh } from "../kit/signature.js";
9
10
  import { createThreadParticipants } from "../kit/thread-participants.js";
@@ -11,15 +12,15 @@ import { createTaskTracker } from "../kit/tasks.js";
11
12
  import { ensureStateHome } from "../kit/state.js";
12
13
  import { dispatchStop, isStopText } from "../kit/stop-command.js";
13
14
  import { codePointPrefix } from "../kit/text.js";
14
- import { createTurnQueue } from "../kit/turn-queue.js";
15
- import { commitAnsweredTurn, createTurnStore } from "../kit/turn-store.js";
15
+ import { createTurnRunner } from "../kit/turn-runner.js";
16
+ import { createTurnStore } from "../kit/turn-store.js";
16
17
  import { discussionBlock } from "../kit/context-buffer.js";
17
- import { createSlackBotTokenProvider } from "./bot-auth.js";
18
18
  import { collectSlackBufferedFiles, createSlackContextBuffer } from "./context-buffer.js";
19
19
  import { invokeSlackTurn } from "./invoke-turn.js";
20
20
  import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, hasSlackMention, hasSlackUserMention, isSlackHumanMessage, mentionsSlackUser, stripSlackMentions, slackBufferText, slackEnvelope, slackFileIds, slackMessageText, slackPlaceKey, slackSenderLabel, slackTeamId, } from "./parse.js";
21
21
  import { defaultErrorMessage, settleSlackPreview, streamSlackReply, } from "./preview.js";
22
22
  import { resolveReactionEmojis, startSlackReaction } from "./reaction.js";
23
+ import { registerSlackApi } from "./shared-api.js";
23
24
  import { createSlackApi } from "./slack-api.js";
24
25
  import { createWelcomedUsers } from "./welcomed.js";
25
26
  export { defaultSlackRoute, slackEnvelope };
@@ -53,9 +54,7 @@ export function verifySlackSignature(signingSecret, timestamp, signature, rawBod
53
54
  if (!signatureIsFresh(timestamp, MAX_SIGNATURE_AGE_S, nowMs))
54
55
  return false;
55
56
  const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${rawBody}`).digest("hex")}`;
56
- const actualBytes = Buffer.from(signature);
57
- const expectedBytes = Buffer.from(expected);
58
- return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
57
+ return secretEquals(signature, expected);
59
58
  }
60
59
  /** The (channel, ts) of the user's triggering message, encoded in the logical turn id `team:channel:ts`
61
60
  * (Slack ids never contain a colon, so a 3-part split is exact). Used to place the reaction ack. */
@@ -64,19 +63,7 @@ function messageRefOf(turnId) {
64
63
  return parts.length === 3 && parts[1] && parts[2] ? { channelId: parts[1], ts: parts[2] } : undefined;
65
64
  }
66
65
  export function slackChannel(options) {
67
- const { botToken, signingSecret, botRefreshToken, clientId, clientSecret, botTokenExpiresAt, groupBehavior = "context", rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
68
- // The participant model derives placement instead of selecting it: Slack has no quote primitive, so
69
- // answering in place means answering in a thread on the ask, whichever renderer draws it. An
70
- // upgraded workspace still passing one of the removed modes would otherwise start fine and silently
71
- // get a different placement AND a different memory boundary.
72
- const removedModes = ["directMessageSession", "groupMessageSession"].filter((name) => options[name] !== undefined);
73
- if (removedModes.length > 0) {
74
- throw new Error(`slackChannel no longer accepts ${removedModes.join(" / ")}: an answer goes in a thread on the ask, and ` +
75
- "that thread is the session — see docs/design/participant-model.md");
76
- }
77
- if (!["context", "mentions"].includes(groupBehavior)) {
78
- throw new Error('slackChannel groupBehavior must be "context" or "mentions"');
79
- }
66
+ const { botToken, signingSecret, rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
80
67
  if (!["native", "classic"].includes(rendering)) {
81
68
  throw new Error('slackChannel rendering must be "native" or "classic"');
82
69
  }
@@ -95,16 +82,8 @@ export function slackChannel(options) {
95
82
  const formatError = onError ?? defaultErrorMessage;
96
83
  const stateHome = join(stateRoot, "channels", "slack");
97
84
  ensureStateHome(stateHome);
98
- const currentBotToken = createSlackBotTokenProvider({
99
- statePath: join(stateHome, "bot-auth.json"),
100
- botToken,
101
- botRefreshToken,
102
- clientId,
103
- clientSecret,
104
- botTokenExpiresAt,
105
- apiBaseUrl,
106
- });
107
- const api = createSlackApi({ botToken: currentBotToken, baseUrl: apiBaseUrl });
85
+ const api = createSlackApi({ botToken, baseUrl: apiBaseUrl });
86
+ registerSlackApi(stateRoot, api); // the send tool delivers through this one (shared-api.ts)
108
87
  let authenticatedTeamId;
109
88
  let botUserId;
110
89
  let authenticationState = "pending";
@@ -154,64 +133,66 @@ export function slackChannel(options) {
154
133
  order: (a, b) => a.seq - b.seq,
155
134
  });
156
135
  const decide = route ?? defaultSlackRoute;
157
- const toStored = (turn) => {
158
- const { previewTs: _preview, nativeQueueStatus: _status, ...intent } = turn;
159
- return { ...intent, attempts: 0 };
160
- };
161
136
  const targetOf = (turn) => ({
162
137
  channelId: turn.channelId,
163
138
  threadTs: turn.threadTs,
164
139
  recipientUserId: turn.requesterUserId,
165
140
  recipientTeamId: turn.teamId,
166
141
  });
167
- const notices = new Map();
168
- const queue = createTurnQueue({
142
+ const notifyDropped = (turn) => {
143
+ const target = targetOf(turn);
144
+ if (turn.nativeQueueStatus)
145
+ void api.setThreadStatus(target, "").catch(() => { });
146
+ void settleSlackPreview(api, target, turn.previewTs, "⚠️ I couldn’t complete an earlier request — please ask again.").catch((error) => log.warn(`${label} could not notify a dropped turn: ${String(error)}`));
147
+ };
148
+ const runner = createTurnRunner({
169
149
  label,
150
+ store,
151
+ buffer,
152
+ seen,
153
+ toStored: ({ previewTs: _preview, nativeQueueStatus: _status, ...intent }) => ({ ...intent, attempts: 0 }),
154
+ fromStored: ({ attempts: _attempts, ...intent }) => ({ ...intent }),
155
+ bufferKey: (turn) => turn.bufferKey,
156
+ where: (turn) => `channel=${turn.channelId}`,
170
157
  onQueuedBehind(turn) {
171
158
  const nativeDmStatus = rendering === "native" && turn.threadTs && turn.channelId.startsWith("D");
172
159
  if (nativeDmStatus)
173
160
  turn.nativeQueueStatus = true;
174
- notices.set(turn.id, authentication
175
- .then(() => nativeDmStatus
176
- ? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
177
- : api.postMessage(targetOf(turn), QUEUED_PLACEHOLDER).then((ts) => {
178
- turn.previewTs = ts;
179
- }))
180
- .catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)));
161
+ return {
162
+ done: authentication
163
+ .then(() => nativeDmStatus
164
+ ? api.setThreadStatus(targetOf(turn), "is queued behind an earlier request…")
165
+ : api.postMessage(targetOf(turn), QUEUED_PLACEHOLDER).then((ts) => {
166
+ turn.previewTs = ts;
167
+ }))
168
+ .catch((error) => log.warn(`${label} queue preview failed (the turn stays durable): ${String(error)}`)),
169
+ };
181
170
  },
182
- run: async (turn) => {
183
- await notices.get(turn.id);
184
- notices.delete(turn.id);
171
+ // Leave the intent untouched when Slack auth failed. A fixed token + restart replays it;
172
+ // running now would execute an Agent turn whose only customer-facing transport is known to be
173
+ // unavailable.
174
+ beforeRun: async (turn) => {
185
175
  try {
186
176
  await authentication;
177
+ return true;
187
178
  }
188
179
  catch (error) {
189
- // Leave the intent untouched. A fixed token + restart replays it; running now would execute an
190
- // Agent turn whose only customer-facing transport is known to be unavailable.
191
180
  log.error(`${label} deferring durable turn ${turn.id} because Slack authentication failed: ${String(error)}`);
192
- return;
181
+ return false;
193
182
  }
194
- const attempt = store.startAttempt(turn.id);
195
- if (attempt === "exceeded") {
196
- notifyDropped(turn);
197
- return;
183
+ },
184
+ onDeferred: (turn) => {
185
+ if (turn.nativeQueueStatus) {
186
+ void api
187
+ .setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
188
+ .catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
198
189
  }
199
- if (attempt === "defer") {
200
- if (turn.nativeQueueStatus) {
201
- void api
202
- .setThreadStatus(targetOf(turn), "is delayed by a temporary system issue and will retry after restart…")
203
- .catch((error) => log.warn(`${label} could not update a deferred Agent status: ${String(error)}`));
204
- }
205
- else if (turn.previewTs) {
206
- void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
207
- }
208
- return;
190
+ else if (turn.previewTs) {
191
+ void settleSlackPreview(api, targetOf(turn), turn.previewTs, DEFERRED_PLACEHOLDER).catch((error) => log.warn(`${label} could not update a deferred queue preview: ${String(error)}`));
209
192
  }
210
- const startedAt = Date.now();
211
- log.info(`${label} turn start: turn=${turn.id} session=${turn.session} channel=${turn.channelId}`);
212
- const { text: recent, consumed } = buffer.peek(turn.bufferKey);
213
- const prompt = `${discussionBlock(recent)}${turn.baseText}`;
214
- const buffered = collectSlackBufferedFiles(consumed, new Set(turn.fileIds));
193
+ },
194
+ notifyDropped,
195
+ execute: async (turn, discussion, onCompleted) => {
215
196
  const messageRef = messageRefOf(turn.id);
216
197
  const reaction = reactionEmojis && messageRef
217
198
  ? await startSlackReaction({
@@ -223,44 +204,25 @@ export function slackChannel(options) {
223
204
  })
224
205
  : undefined;
225
206
  try {
226
- await streamSlackReply(invokeSlackTurn(agent, turn.session, prompt, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, { primaryFileIds: turn.fileIds, buffered }, () => commitAnsweredTurn(store, buffer, { id: turn.id, bufferKey: turn.bufferKey, consumed })), api, targetOf(turn), formatError, {
207
+ await streamSlackReply(invokeSlackTurn(agent, turn.session, `${discussionBlock(discussion.text)}${turn.baseText}`, { api, channelId: turn.channelId, filesDir: join(stateHome, "files"), label }, {
208
+ primaryFileIds: turn.fileIds,
209
+ buffered: collectSlackBufferedFiles(discussion.consumed, new Set(turn.fileIds)),
210
+ }, onCompleted), api, targetOf(turn), formatError, {
227
211
  rendering,
228
212
  initialPreviewTs: turn.previewTs,
229
213
  threadTitle: turn.threadTitle,
230
214
  disclaimer: aiDisclaimer,
231
215
  label,
232
216
  });
233
- log.info(`${label} turn done: turn=${turn.id} session=${turn.session} (${Date.now() - startedAt}ms)`);
234
- await reaction?.complete();
235
217
  }
236
218
  catch (error) {
237
- log.error(`${label} turn failed: turn=${turn.id} session=${turn.session}: ${String(error)}`);
238
219
  await reaction?.remove();
220
+ throw error;
239
221
  }
240
- finally {
241
- store.remove(turn.id);
242
- }
222
+ await reaction?.complete();
243
223
  },
244
224
  });
245
- const notifyDropped = (turn) => {
246
- const target = targetOf(turn);
247
- if (turn.nativeQueueStatus)
248
- void api.setThreadStatus(target, "").catch(() => { });
249
- 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)}`));
250
- };
251
- const submit = (turn, persist) => {
252
- if (persist) {
253
- store.add(toStored(turn));
254
- seen.add(turn.id);
255
- }
256
- queue.accept(turn);
257
- };
258
- const recovered = store.recover();
259
- if (recovered.length)
260
- log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
261
- let seq = recovered.reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
262
- for (const { attempts: _attempts, ...intent } of recovered)
263
- submit({ ...intent }, false);
225
+ let seq = runner.recover().reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
264
226
  // Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the
265
227
  // delivery dedup ring alone is enough — there is no await for a duplicate delivery to race
266
228
  // through. The minutes-long Agent turn remains fire-and-forget.
@@ -287,7 +249,7 @@ export function slackChannel(options) {
287
249
  // Listening is not speaking: every message the channel can see refines who takes part in its
288
250
  // thread, whether or not it is answered. Humans only — a bot's own posts are recorded where they
289
251
  // are known — this channel answering.
290
- // Structural facts only, never `groupBehavior` or `route` — see thread-participants.ts. Slack
252
+ // Structural facts only, never `route` — see thread-participants.ts. Slack
291
253
  // adds no delta of its own here; its summon rule is the only consumer.
292
254
  if (group && event.thread_ts !== undefined) {
293
255
  threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
@@ -309,7 +271,6 @@ export function slackChannel(options) {
309
271
  // structural: it reads who THIS message addresses, which is the one thing an observation-only
310
272
  // store cannot know about someone it has never heard.
311
273
  if (!routed &&
312
- groupBehavior === "context" &&
313
274
  route === undefined &&
314
275
  group &&
315
276
  event.thread_ts !== undefined &&
@@ -323,7 +284,7 @@ export function slackChannel(options) {
323
284
  if (!routed) {
324
285
  if (route === undefined && group && botUserId === undefined && mightBeTheBot)
325
286
  return;
326
- if (groupBehavior === "context" && route === undefined && group) {
287
+ if (route === undefined && group) {
327
288
  const body = slackBufferText(slackMessageText(event));
328
289
  if (body) {
329
290
  const fileIds = slackFileIds(event);
@@ -367,7 +328,7 @@ export function slackChannel(options) {
367
328
  const threadTitle = direct && event.thread_ts === undefined
368
329
  ? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
369
330
  : undefined;
370
- submit({
331
+ runner.submit({
371
332
  id: logicalId,
372
333
  seq: ++seq,
373
334
  session: routed.session ?? defaultSession,
@@ -486,7 +447,7 @@ export function slackChannel(options) {
486
447
  acceptEvent(envelope);
487
448
  return new Response(null, { status: 200 });
488
449
  };
489
- handler.turnsIdle = () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined);
450
+ handler.turnsIdle = () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined);
490
451
  return { "POST /slack": handler };
491
452
  };
492
453
  }
@@ -0,0 +1,4 @@
1
+ /** Shared SSE response lifecycle for invoke and session observation. Fetch-only, with no Node dependencies. */
2
+ /** The remote client's idle watchdog allows three missed heartbeats. */
3
+ export declare const SSE_HEARTBEAT_MS = 30000;
4
+ export declare function sseResponse<T>(events: AsyncIterable<T>, project?: (event: T) => unknown): Response;
@@ -0,0 +1,66 @@
1
+ /** Shared SSE response lifecycle for invoke and session observation. Fetch-only, with no Node dependencies. */
2
+ /** The remote client's idle watchdog allows three missed heartbeats. */
3
+ export const SSE_HEARTBEAT_MS = 30_000;
4
+ const encoder = new TextEncoder();
5
+ const heartbeat = encoder.encode(": ping\n\n");
6
+ export function sseResponse(events, project = (event) => event) {
7
+ const iterator = events[Symbol.asyncIterator]();
8
+ // Subscribe before headers; synchronous startup errors belong to the HTTP error boundary.
9
+ let pending = Promise.resolve(iterator.next());
10
+ // pull() surfaces asynchronous failures. Observe rejection even if the caller cancels before that pull.
11
+ pending.catch(() => { });
12
+ let closed = false;
13
+ let timer;
14
+ const stop = () => {
15
+ closed = true;
16
+ clearInterval(timer);
17
+ };
18
+ const cancel = async () => {
19
+ if (closed)
20
+ return;
21
+ stop();
22
+ await iterator.return?.();
23
+ };
24
+ const stream = new ReadableStream({
25
+ start(controller) {
26
+ timer = setInterval(() => controller.enqueue(heartbeat), SSE_HEARTBEAT_MS);
27
+ },
28
+ async pull(controller) {
29
+ try {
30
+ const next = await (pending ?? iterator.next());
31
+ pending = undefined;
32
+ // Cancellation can settle a pending next(); its value no longer has a reader.
33
+ if (closed)
34
+ return;
35
+ if (next.done) {
36
+ stop();
37
+ controller.close();
38
+ }
39
+ else {
40
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(project(next.value))}\n\n`));
41
+ }
42
+ }
43
+ catch (error) {
44
+ if (closed)
45
+ return;
46
+ // Errored streams never call cancel(), so iterator and timer cleanup belongs here too.
47
+ try {
48
+ await cancel();
49
+ }
50
+ catch (cleanupError) {
51
+ controller.error(new AggregateError([error, cleanupError], "SSE source and cleanup failed", { cause: error }));
52
+ return;
53
+ }
54
+ controller.error(error);
55
+ }
56
+ },
57
+ cancel,
58
+ });
59
+ return new Response(stream, {
60
+ headers: {
61
+ "content-type": "text/event-stream",
62
+ "cache-control": "no-cache",
63
+ connection: "keep-alive",
64
+ },
65
+ });
66
+ }
@@ -1,16 +1,13 @@
1
- import type { RegistrationOutcome } from "../registration.ts";
1
+ import { type RegistrationOutcome } from "../registration.ts";
2
2
  /**
3
- * Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Waits for the server to be
4
- * REACHABLE first polling `<baseUrl>/health` because Telegram VERIFIES the URL when you set it, and a
5
- * fresh deploy's container (healthcheck + routing) or a fresh tunnel's DNS is not routable for some
6
- * seconds after the deploy/tunnel command returns. Tracking real readiness (not a fixed timer) is what
7
- * fixes the race that made the first real deploy need a manual `setWebhook`. Missing tokens print the
8
- * manual instruction instead of failing. `opts` (timeouts) exist for tests; production uses the defaults.
3
+ * Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Missing tokens print the
4
+ * manual instruction instead of failing. `opts` carries the attempt budget: `--tunnel` takes the
5
+ * default, `deploy --run` passes `DEPLOY_REGISTRATION_ATTEMPTS` (a host starts slower than a tunnel),
6
+ * and tests shrink it.
9
7
  *
10
8
  * Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
11
9
  */
12
10
  export declare function registerTelegramWebhook(baseUrl: string, opts?: {
13
- readyTimeoutMs?: number;
14
- readyIntervalMs?: number;
11
+ attempts?: number;
15
12
  retryMs?: number;
16
13
  }): Promise<RegistrationOutcome>;
@@ -2,18 +2,36 @@
2
2
  * Telegram webhook registration — the TELEGRAM-domain step both `--tunnel` (dev, tunnel.ts) and
3
3
  * `deploy … --run` (the host runners' post-deploy step) invoke. What "registering telegram" means lives
4
4
  * here, beside the channel it serves; it reads the same .env tokens the channel uses.
5
+ *
6
+ * READINESS IS setWebhook'S OWN VERDICT, not a local probe. Telegram VERIFIES the URL during the call,
7
+ * from Telegram's network — which is the only network that has to reach it. This used to poll
8
+ * `<baseUrl>/health` from here first, and that probe answered a different question badly: a freshly
9
+ * minted hostname (a quick tunnel, a fresh deploy) is routinely unreachable from the machine running
10
+ * the CLI for a minute or more — behind a proxy, or while its own resolver still says ENOTFOUND — long
11
+ * after Telegram can reach it (#421). So the retry loop below IS the wait: Telegram's reachability
12
+ * verdicts ("Failed to resolve host", "Wrong response from the webhook") are retried, and only a
13
+ * configuration error the operator must fix is reported once.
5
14
  */
6
- import { setTimeout as sleep } from "node:timers/promises";
7
15
  import { log } from "../../log.js";
8
- import { waitForHealth } from "../wait-health.js";
16
+ import { retryWhile } from "../registration.js";
9
17
  import { callApi } from "./telegram-api.js";
10
18
  /**
11
- * Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Waits for the server to be
12
- * REACHABLE first polling `<baseUrl>/health` because Telegram VERIFIES the URL when you set it, and a
13
- * fresh deploy's container (healthcheck + routing) or a fresh tunnel's DNS is not routable for some
14
- * seconds after the deploy/tunnel command returns. Tracking real readiness (not a fixed timer) is what
15
- * fixes the race that made the first real deploy need a manual `setWebhook`. Missing tokens print the
16
- * manual instruction instead of failing. `opts` (timeouts) exist for tests; production uses the defaults.
19
+ * Whether a setWebhook failure is the URL still warming up rather than a configuration error. Two
20
+ * families: transport failures reaching Telegram at all, and Telegram's own verdict after it fails to
21
+ * reach the webhook which carries its connection layer's words verbatim (`Bad Request: bad webhook:
22
+ * Connection timed out` / `Connection refused` for a host whose DNS answers before anything listens,
23
+ * `Wrong response from the webhook: 530` for a tunnel edge without its origin). This is the ONLY gate
24
+ * between "spend the caller's retry budget" and "fail now", so it is wide on the reachability side.
25
+ * A permanent "bad webhook" — an http:// URL, an unsupported port — matches nothing here and is reported.
26
+ */
27
+ function isTransientRegistrationError(error) {
28
+ return /resolve host|getaddrinfo|ENOTFOUND|fetch failed|ECONNRESET|timeout|timed out|connection refused|can't connect|connection to the host|wrong response from the webhook/i.test(error);
29
+ }
30
+ /**
31
+ * Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Missing tokens print the
32
+ * manual instruction instead of failing. `opts` carries the attempt budget: `--tunnel` takes the
33
+ * default, `deploy --run` passes `DEPLOY_REGISTRATION_ATTEMPTS` (a host starts slower than a tunnel),
34
+ * and tests shrink it.
17
35
  *
18
36
  * Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
19
37
  */
@@ -25,40 +43,24 @@ export async function registerTelegramWebhook(baseUrl, opts = {}) {
25
43
  log.info(`[fastagent] telegram: set TELEGRAM_BOT_TOKEN + TELEGRAM_SECRET_TOKEN in .env, then re-run to auto-register. Webhook URL: ${webhookUrl}`);
26
44
  return "manual";
27
45
  }
28
- // Align registration with the server actually serving. Don't setWebhook against a URL Telegram can't
29
- // yet reach — it would fail, and a fixed retry window guesses the readiness delay (the deploy race).
30
- log.info(`[fastagent] telegram: waiting for ${baseUrl} to be reachable before registering the webhook…`);
31
- const ready = await waitForHealth(`${baseUrl}/health`, opts.readyTimeoutMs ?? 120_000, opts.readyIntervalMs ?? 3_000);
32
- if (!ready) {
33
- // Terminal for this run (registration will not be retried) error, not warn: the webhook is NOT
34
- // registered and the operator must act. Same taxonomy as the permanent setWebhook failure below.
35
- log.error(`[fastagent] telegram: ${baseUrl}/health did not come up in time — the app may still be starting. ` +
36
- `Register the webhook manually once it's up: curl "https://api.telegram.org/bot<token>/setWebhook" -d url=${webhookUrl} -d secret_token=<secret>`);
37
- return "failed";
46
+ log.info(`[fastagent] telegram: registering the webhook Telegram verifies ${webhookUrl} as it does…`);
47
+ try {
48
+ await retryWhile(() => callApi("https://api.telegram.org", botToken, "setWebhook", { url: webhookUrl, secret_token: secret }), (error) => isTransientRegistrationError(String(error)), {
49
+ attempts: opts.attempts,
50
+ retryMs: opts.retryMs,
51
+ onRetry: ({ attempt, attempts }) => log.info(`[fastagent] telegram: Telegram cannot reach ${webhookUrl} yet (attempt ${attempt}/${attempts}); retrying…`),
52
+ });
53
+ log.info(`[fastagent] telegram: webhook registered → ${webhookUrl}`);
54
+ return "registered";
38
55
  }
39
- // Reachable → register. A short retry backstops Telegram's resolver lagging /health by a moment; only
40
- // network-transient errors retry (a permanent "bad webhook" config error is reported, not retried).
41
- let lastTransientError = "unknown transport error";
42
- for (let attempt = 0; attempt < 3; attempt++) {
43
- if (attempt > 0)
44
- await sleep(opts.retryMs ?? 2000);
45
- try {
46
- await callApi("https://api.telegram.org", botToken, "setWebhook", { url: webhookUrl, secret_token: secret });
47
- log.info(`[fastagent] telegram: webhook registered → ${webhookUrl}`);
48
- return "registered";
49
- }
50
- catch (e) {
51
- const error = String(e);
52
- if (!/resolve host|getaddrinfo|ENOTFOUND|fetch failed|ECONNRESET|timeout/i.test(error)) {
53
- log.error(`[fastagent] telegram: setWebhook failed (${error}). Register manually with url=${webhookUrl}`);
54
- return "failed";
55
- }
56
- lastTransientError = error;
57
- }
56
+ catch (e) {
57
+ // Both endings leave the webhook unregistered and the operator with work to do, so both are ERROR;
58
+ // they differ only in what to do about it.
59
+ const error = String(e);
60
+ log.error(isTransientRegistrationError(error)
61
+ ? `[fastagent] telegram: Telegram could not reach ${webhookUrl} after retries (last error: ${error}). ` +
62
+ `Once it is up, register manually: curl "https://api.telegram.org/bot<token>/setWebhook" -d url=${webhookUrl} -d secret_token=<secret>`
63
+ : `[fastagent] telegram: setWebhook failed (${error}). Register manually with url=${webhookUrl}`);
64
+ return "failed";
58
65
  }
59
- // Exhausted retries end in the same state as a permanent error (webhook not registered, manual
60
- // action required) — report at the same level.
61
- log.error(`[fastagent] telegram: setWebhook still failing after retries (last error: ${lastTransientError}). ` +
62
- `Register manually with url=${webhookUrl}`);
63
- return "failed";
64
66
  }