@fastagent-sh/fastagent 0.20.0 → 0.21.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 (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 +25 -37
  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
@@ -9,7 +9,7 @@
9
9
  * Every other concern lives in its own module, each owning its invariants:
10
10
  * - parse.ts pure message parsing: field extraction, prompt envelope, summon/route policy
11
11
  * - invoke-turn.ts run one turn: assemble inputs (resolve attachments) + stream `agent.invoke`
12
- * - turn-queue.ts in-memory per-session serial execution (FIFO; one turn at a time per session)
12
+ * - kit/turn-runner.ts the durable-turn lifecycle (accept dequeue execute end) over the queue + store
13
13
  * - turn-store.ts durable turn intent (L1): pre-ACK persist, replay a crash-surviving turn
14
14
  * - context-buffer.ts un-summoned group discussion, folded into the next answered turn
15
15
  * - preview.ts the live-preview pump ("💭 Thinking…" → edits → final answer) + terminal writes
@@ -22,11 +22,11 @@
22
22
  * Authored against the public `@fastagent-sh/fastagent` surface only (the contract + the channel-authoring
23
23
  * kit: readBodyCapped / text), so it is exactly what a third-party `fastagent-channel-*` package would write.
24
24
  */
25
- import { timingSafeEqual } from "node:crypto";
26
25
  import { isAbsolute, join } from "node:path";
27
26
  import { log } from "../../log.js";
28
27
  import { readBodyCapped } from "../body.js";
29
28
  import { text } from "../respond.js";
29
+ import { secretEquals } from "../secret.js";
30
30
  import { invokeTurn } from "./invoke-turn.js";
31
31
  import { collectAttachments, createContextBuffer } from "./context-buffer.js";
32
32
  import { attachmentSummary, defaultTelegramRoute, extractFiles, extractImages, fromLabel, messageText, ownFiles, ownImages, pickMessage, telegramEnvelope, telegramStop, } from "./parse.js";
@@ -34,20 +34,13 @@ import { defaultErrorMessage, streamReply } from "./preview.js";
34
34
  import { ensureStateHome } from "../kit/state.js";
35
35
  import { dispatchStop } from "../kit/stop-command.js";
36
36
  import { callApi, editMessageText, sendMessage } from "./telegram-api.js";
37
- import { createTurnQueue } from "../kit/turn-queue.js";
38
- import { commitAnsweredTurn } from "../kit/turn-store.js";
37
+ import { createTurnRunner } from "../kit/turn-runner.js";
39
38
  import { discussionBlock } from "../kit/context-buffer.js";
40
39
  import { createTurnStore } from "./turn-store.js";
41
40
  // Re-export the public surface authored elsewhere, so `@fastagent-sh/fastagent/telegram` keeps one entry point.
42
41
  export { defaultTelegramRoute, telegramEnvelope, telegramStop };
43
42
  /** Update body cap — Telegram updates are small JSON; 1 MiB is generous and guards a public endpoint. */
44
43
  const MAX_UPDATE_BYTES = 1 << 20;
45
- /** Constant-time compare so the secret-token check leaks no timing signal. */
46
- function tokenMatches(header, secret) {
47
- const a = Buffer.from(header);
48
- const b = Buffer.from(secret);
49
- return a.length === b.length && timingSafeEqual(a, b);
50
- }
51
44
  /**
52
45
  * Build a Telegram bot channel: policy options in, a {@link ChannelModule} out. The framework (or an
53
46
  * embedder) mounts it with the context — `telegramChannel(opts)` in `channels/telegram.ts` is the whole
@@ -111,152 +104,64 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
111
104
  // Durable turn intent (L1): persist an accepted turn pre-ACK, remove it when the turn ends; a crash
112
105
  // leaves it for replay on the next start. See turn-store.ts for the at-least-once semantics.
113
106
  const store = createTurnStore(join(stateHome, "turns.json"));
114
- const toStored = (r) => {
115
- const { previewId: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
116
- return { ...intent, attempts: 0 };
107
+ const targetOf = (r) => ({ chatId: r.chatId, threadId: r.threadId, replyTo: r.replyTo });
108
+ // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal, not
109
+ // just an operator log line. Take over the ⏳ "Queued" notice in place if the turn had one (else send
110
+ // fresh) — leaving it pinned at "Queued" while sending a separate failure would double-post. Best-
111
+ // effort, like the queue notices.
112
+ const notifyDropped = (r) => {
113
+ const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
114
+ const sent = r.previewId !== undefined
115
+ ? editMessageText(apiBaseUrl, botToken, targetOf(r), r.previewId, body, { html: false })
116
+ : sendMessage(apiBaseUrl, botToken, targetOf(r), body, { html: false }).then(() => { });
117
+ void sent.catch((e) => log.warn(`[telegram] could not notify a dropped turn (session=${r.session}): ${String(e)}`));
117
118
  };
118
- // In-memory: the in-flight "⏳ queued" notice per turn, awaited at dequeue so the turn reliably takes
119
- // the notice message over (rec.previewId) instead of racing it and orphaning a late-arriving notice.
120
- const notices = new Map();
121
- const queue = createTurnQueue({
119
+ const runner = createTurnRunner({
122
120
  label: "[telegram]",
121
+ store,
122
+ buffer,
123
+ toStored: ({ previewId: _live, ...intent }) => ({ ...intent, attempts: 0 }),
124
+ fromStored: ({ attempts: _a, ...intent }) => ({ ...intent, previewId: undefined }),
125
+ bufferKey: (rec) => rec.placeKey,
126
+ where: (rec) => `chat=${rec.chatId}${rec.threadId !== undefined ? ` thread=${rec.threadId}` : ""}`,
123
127
  // Queue feedback: when this session already has a turn running/queued, a silent wait reads as "the
124
128
  // bot ignored me" once the current turn runs long — tell the asker NOW (reply-quoted, so it is
125
129
  // clear whose ask is queued). Best-effort and post-ACK: a failed notice is a log line, never a
126
- // failed update. The turn's live preview then edits this same message in place.
127
- onQueuedBehind: (rec) => {
128
- const target = {
129
- chatId: rec.chatId,
130
- threadId: rec.threadId,
131
- replyTo: rec.replyTo,
132
- };
133
- notices.set(rec.id, sendMessage(apiBaseUrl, botToken, target, "⏳ Queued — I’ll start once the current task finishes.", {
130
+ // failed update. The turn's live preview then edits this same message in place. The runner holds
131
+ // this same `rec` object, so mutating it here (awaited at dequeue) hands the turn its preview id.
132
+ onQueuedBehind: (rec) => ({
133
+ done: sendMessage(apiBaseUrl, botToken, targetOf(rec), "⏳ Queued — I’ll start once the current task finishes.", {
134
134
  html: false,
135
- }).then(
136
- // The runner holds this same `rec` object, so mutating it here (gated by the notices await at
137
- // dequeue below) is what hands the turn its preview message id.
138
- (id) => {
135
+ }).then((id) => {
139
136
  if (id !== undefined)
140
137
  rec.previewId = id;
141
- }, (e) => log.warn(`[telegram] queue notice failed (the turn still runs): ${String(e)}`)));
142
- },
143
- run: async (rec) => {
144
- // Runs at DEQUEUE time (serialized), so the lifecycle log and engine turn reflect the actual
145
- // execution order rather than arrival.
146
- // Settle the queue notice (if any) so rec.previewId is final. NOT free: a slow (not failed)
147
- // notice delays this turn's start by up to the API timeout — accepted, because racing it would
148
- // orphan the ⏳ message and double-post a placeholder; in the common path the notice resolved
149
- // while the previous turn was still running, so this await is instant. BEFORE the ceiling check
150
- // so a dropped turn's notice is settled/cleared too (rec.previewId lets notifyDropped take it over).
151
- await notices.get(rec.id);
152
- notices.delete(rec.id);
153
- // Count this execution against the durable record (poison-turn ceiling) before running it again.
154
- const decision = store.startAttempt(rec.id);
155
- if (decision === "exceeded") {
156
- // Started the ceiling's worth of times without finishing (turn-store.ts MAX_TURN_ATTEMPTS) —
157
- // tell the asker (reusing its ⏳ notice if any), drop it.
158
- notifyDropped(rec);
159
- return;
160
- }
161
- if (decision === "defer") {
162
- // Couldn't record the attempt (disk failure): skip this cycle. A restart replays it, so no notify
163
- // (telling the asker to re-ask would double-answer once the deferred turn runs). Two accepted
164
- // edges of this rare disk-failure corner: (1) the session chain proceeds to the next queued turn,
165
- // so a deferred turn can replay AFTER its successors — a per-session FIFO reorder; (2) its ⏳ notice
166
- // (if it was queued behind another turn) now falsely reads "Queued", so delete it best-effort — the
167
- // eventual replay sends a fresh preview, and leaving it would orphan a stale message above that.
168
- if (rec.previewId !== undefined) {
169
- void callApi(apiBaseUrl, botToken, "deleteMessage", {
170
- chat_id: rec.chatId,
171
- message_id: rec.previewId,
172
- }).catch(() => { });
173
- }
174
- return;
138
+ }, (e) => log.warn(`[telegram] queue notice failed (the turn still runs): ${String(e)}`)),
139
+ }),
140
+ // Its ⏳ notice (if any) now falsely reads "Queued": delete it best-effort — the eventual replay
141
+ // sends a fresh preview, and leaving it would orphan a stale message above that.
142
+ onDeferred: (rec) => {
143
+ if (rec.previewId !== undefined) {
144
+ void callApi(apiBaseUrl, botToken, "deleteMessage", {
145
+ chat_id: rec.chatId,
146
+ message_id: rec.previewId,
147
+ }).catch(() => { });
175
148
  }
176
- const startedAt = Date.now();
177
- const where = `chat=${rec.chatId}${rec.threadId !== undefined ? ` thread=${rec.threadId}` : ""}`;
178
- log.info(`[telegram] turn start: turn=${rec.id} session=${rec.session} ${where}`);
179
- // Fold the un-summoned discussion since the last answered turn into the prompt; it is cleared
180
- // only when the turn COMPLETES (then it lives in the session).
181
- const { text: recent, consumed } = buffer.peek(rec.placeKey);
182
- const prompt = `${discussionBlock(recent)}${rec.baseText}`;
183
- const buffered = collectAttachments(consumed, {
149
+ },
150
+ notifyDropped,
151
+ execute: (rec, discussion, onCompleted) => streamReply(invokeTurn(agent, rec.session, `${discussionBlock(discussion.text)}${rec.baseText}`, { api: apiBaseUrl, botToken, chatId: rec.chatId, filesDir: join(stateHome, "files") }, {
152
+ primary: { imageFileIds: rec.imageFileIds, fileIds: rec.fileIds },
153
+ buffered: collectAttachments(discussion.consumed, {
184
154
  files: new Set(rec.fileIds),
185
155
  images: new Set(rec.imageFileIds),
186
- });
187
- const target = {
188
- chatId: rec.chatId,
189
- threadId: rec.threadId,
190
- replyTo: rec.replyTo,
191
- };
192
- try {
193
- await streamReply(invokeTurn(agent, rec.session, prompt, {
194
- api: apiBaseUrl,
195
- botToken,
196
- chatId: rec.chatId,
197
- filesDir: join(stateHome, "files"),
198
- }, {
199
- primary: {
200
- imageFileIds: rec.imageFileIds,
201
- fileIds: rec.fileIds,
202
- },
203
- buffered,
204
- }, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey: rec.placeKey, consumed })), apiBaseUrl, botToken, target, formatError, rec.previewId);
205
- log.info(`[telegram] turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
206
- }
207
- catch (error) {
208
- log.error(`[telegram] turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
209
- }
210
- finally {
211
- // Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
212
- // never reach the completed hook above, which is where a completed turn removes its intent in
213
- // order. Idempotent — a second remove after the completed hook is a no-op. Only an INTERRUPTED run
214
- // (this finally never runs — a crash or a SIGTERM deploy, no graceful drain) leaves the record for
215
- // replay; a transport throw is dropped, not retried (safe retry needs an L2 delivery key).
216
- store.remove(rec.id);
217
- }
218
- },
156
+ }),
157
+ }, onCompleted), apiBaseUrl, botToken, targetOf(rec), formatError, rec.previewId),
219
158
  });
220
- // Accept a turn: persist its intent before the ACK (durable), then enqueue it. Recovery re-enqueues a
221
- // crash-surviving turn WITHOUT re-persisting (it is already on disk with a bumped attempt count).
222
- const submit = (rec, persist) => {
223
- if (persist)
224
- store.add(toStored(rec)); // pre-ACK: a failed write throws → webhook 500 → redeliver
225
- queue.accept(rec);
226
- };
227
- // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal, not
228
- // just an operator log line. Take over the ⏳ "Queued" notice in place if the turn had one (else send
229
- // fresh) — leaving it pinned at "Queued" while sending a separate failure would double-post. Best-
230
- // effort, like the queue notices.
231
- const notifyDropped = (r) => {
232
- const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
233
- const target = {
234
- chatId: r.chatId,
235
- threadId: r.threadId,
236
- replyTo: r.replyTo,
237
- };
238
- const sent = r.previewId !== undefined
239
- ? editMessageText(apiBaseUrl, botToken, target, r.previewId, body, {
240
- html: false,
241
- })
242
- : sendMessage(apiBaseUrl, botToken, target, body, {
243
- html: false,
244
- }).then(() => { });
245
- void sent.catch((e) => log.warn(`[telegram] could not notify a dropped turn (session=${r.session}): ${String(e)}`));
246
- };
247
- // Re-enqueue turns a prior crash left mid-flight (ACKed but unfinished). Synchronous at construction:
248
- // the queue runs them on the next tick, once this factory returns and the event loop turns. The
249
- // execution ceiling is enforced per turn at dequeue (run), not here — a never-run turn keeps its budget.
250
- const recovered = store.recover();
251
- if (recovered.length > 0)
252
- log.info(`[telegram] recovering ${recovered.length} unfinished turn(s) from a prior run`);
253
- for (const { attempts: _a, ...intent } of recovered)
254
- submit({ ...intent, previewId: undefined }, false);
159
+ runner.recover();
255
160
  const handler = async (req) => {
256
161
  if (req.method !== "POST")
257
162
  return text("POST only\n", 405);
258
163
  // Fail closed: a missing/wrong secret token is 401, never routed.
259
- if (!tokenMatches(req.headers.get("x-telegram-bot-api-secret-token") ?? "", secretToken)) {
164
+ if (!secretEquals(req.headers.get("x-telegram-bot-api-secret-token"), secretToken)) {
260
165
  return text("invalid secret token\n", 401);
261
166
  }
262
167
  const body = await readBodyCapped(req, MAX_UPDATE_BYTES);
@@ -331,7 +236,7 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
331
236
  const fileIds = extractFiles(m);
332
237
  if (baseText.trim() !== "" || imageFileIds.length > 0 || fileIds.length > 0) {
333
238
  // Everything the turn needs, as a plain record; persisted pre-ACK then run serially per session.
334
- submit({
239
+ runner.submit({
335
240
  id: `${update.update_id}`,
336
241
  session,
337
242
  placeKey,
@@ -348,7 +253,7 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
348
253
  // Test/observability seam: await the fire-and-forget turns this handler enqueues. Inert in production
349
254
  // (nothing reads it; the runtime never drains — see turn-queue), it lets a test await a turn
350
255
  // deterministically instead of polling for side effects to settle.
351
- handler.turnsIdle = () => queue.idle();
256
+ handler.turnsIdle = () => runner.idle();
352
257
  return { "POST /telegram": handler };
353
258
  };
354
259
  }
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Readiness probe shared by the webhook registrars (telegram setWebhook, lark config PATCH): both
3
- * platforms VERIFY the URL at registration time, and a fresh deploy's container or a fresh tunnel's
4
- * DNS is not routable for some seconds — registering before the server actually serves would fail.
5
- * Tracking real readiness (not a fixed timer) is what fixes that race.
2
+ * Readiness probe for a server THIS process can reach directly the local port `deploy docker --run`
3
+ * just published, and the live probes' own origins.
4
+ *
5
+ * NOT a probe for a public URL a platform must reach: a freshly minted hostname (a quick tunnel, a
6
+ * fresh deploy) is routinely unreachable from here for a minute or more while the platform reaches it
7
+ * fine, and polling it hard from t+0 makes that worse (#421). The webhook registrars therefore let the
8
+ * platform's own URL verification be the probe, retried, instead of gating on this.
6
9
  */
7
10
  import { setTimeout as sleep } from "node:timers/promises";
8
11
  /**
@@ -12,7 +12,7 @@
12
12
  import { readFile } from "node:fs/promises";
13
13
  import { isCancel, log as clackLog, password, text as clackText } from "@clack/prompts";
14
14
  import { bootstrapFeishuVerificationToken } from "../channels/feishu/bootstrap-token.js";
15
- import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_CONTEXT_ONBOARDING_SCOPES, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "../channels/feishu/setup-mode.js";
15
+ import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_CONTEXT_ONBOARDING_SCOPES, FEISHU_MESSAGE_READ_SCOPE, feishuAppAddons, scopeSatisfied, } from "../channels/feishu/setup-mode.js";
16
16
  import { cloudFor } from "../channels/feishu/cloud.js";
17
17
  import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError, } from "../channels/feishu/feishu-api.js";
18
18
  import { registerFeishuApp } from "../channels/feishu/register-app.js";
@@ -233,16 +233,9 @@ async function createFeishuAppFlow(target, existing, ingress, groupBehavior) {
233
233
  const app = await registerFeishuApp({
234
234
  name: "{user}'s agent", // the platform expands {user} to the confirming user's name; editable on the page
235
235
  desc: "Served by fastagent",
236
- // The agent template alone is not enough to SERVE: v7 config PATCHes (webhook registration and
237
- // context-aware group scope setup) demand application:application:patch, and the app must subscribe
238
- // the receive event. Addons merge those BASE capabilities onto the confirm page; sensitive group
236
+ // The agent template alone is not enough to SERVE (see feishuAppAddons). Sensitive group
239
237
  // permission approval and version publishing remain explicit console work.
240
- addons: {
241
- ...(ingress === "webhook" || (groupBehavior.behavior === "context" && groupBehavior.explicit)
242
- ? { scopes: { tenant: ["application:application:patch"] } }
243
- : {}),
244
- events: { items: { tenant: ["im.message.receive_v1"] } },
245
- },
238
+ addons: feishuAppAddons(),
246
239
  onVerificationUrl: ({ url, expiresInS }) => {
247
240
  console.error(`\n Opening the confirmation link in your browser (or open it in Feishu / render it as a QR code) — valid for ${Math.round(expiresInS / 60)} minutes:\n\n ${url}\n\n waiting for confirmation… (keep this running — the credentials are delivered here)`);
248
241
  openExternalUrl(url); // best-effort, like `login` — the URL above is the fallback
@@ -1,7 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { basename } from "node:path";
3
3
  import { isCancel, log as clackLog, password, select, text as clackText } from "@clack/prompts";
4
- import { waitForHealth } from "../channels/wait-health.js";
5
4
  import { dotEnvPath, parseEnvContent } from "../env.js";
6
5
  import { openExternalUrl } from "../open-url.js";
7
6
  import { installProxyFetch } from "../proxy.js";
@@ -41,14 +40,7 @@ export async function onboardSlackInternalApp(input) {
41
40
  return new Map();
42
41
  throw error;
43
42
  });
44
- const missingRuntime = [
45
- "SLACK_BOT_TOKEN",
46
- "SLACK_BOT_REFRESH_TOKEN",
47
- "SLACK_BOT_TOKEN_EXPIRES_AT",
48
- "SLACK_CLIENT_ID",
49
- "SLACK_CLIENT_SECRET",
50
- "SLACK_SIGNING_SECRET",
51
- ].filter((name) => !((process.env[name] ?? env.get(name))?.trim() ?? ""));
43
+ const missingRuntime = ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"].filter((name) => !((process.env[name] ?? env.get(name))?.trim() ?? ""));
52
44
  if (missingRuntime.length > 0) {
53
45
  throw new Error(`Slack app ${state.appId ?? "(unknown)"} is installed but ${dotEnvPath(input.target)} is missing ` +
54
46
  `${missingRuntime.join(", ")} — ` +
@@ -168,20 +160,16 @@ export async function onboardSlackInternalApp(input) {
168
160
  const redirectUrl = `${tunnel.url}${server.redirectPath}`;
169
161
  console.error(`[fastagent] temporary Slack setup tunnel ready → ${tunnel.url}`);
170
162
  try {
171
- if (!(await waitForHealth(`${tunnel.url}/health`, 45_000, 500))) {
172
- throw new Error("the temporary Slack setup tunnel did not become reachable; no app was created");
173
- }
163
+ // No local readiness probe: Slack challenges requestUrl from ITS network during app creation, and
164
+ // that is the reachability that matters (#421) this machine often cannot reach a fresh tunnel
165
+ // hostname for a minute. onboardSlackApp retries the create while Slack cannot verify it yet.
174
166
  await onboardSlackApp({ stateRoot: input.stateRoot, state, requestUrl, redirectUrl }, {
175
167
  note: (message) => clackLog.info(message),
176
168
  openUrl: openExternalUrl,
177
169
  waitForOAuth: () => server.waitForOAuth(),
178
- writeRuntimeSecrets: async ({ botToken, botRefreshToken, botTokenExpiresAt, clientId, clientSecret, signingSecret, }) => {
170
+ writeRuntimeSecrets: async ({ botToken, signingSecret }) => {
179
171
  const values = {
180
172
  ...(botToken ? { SLACK_BOT_TOKEN: botToken } : {}),
181
- ...(botRefreshToken ? { SLACK_BOT_REFRESH_TOKEN: botRefreshToken } : {}),
182
- ...(botTokenExpiresAt ? { SLACK_BOT_TOKEN_EXPIRES_AT: String(botTokenExpiresAt) } : {}),
183
- ...(clientId ? { SLACK_CLIENT_ID: clientId } : {}),
184
- ...(clientSecret ? { SLACK_CLIENT_SECRET: clientSecret } : {}),
185
173
  ...(signingSecret ? { SLACK_SIGNING_SECRET: signingSecret } : {}),
186
174
  };
187
175
  if (Object.keys(values).length > 0) {
@@ -189,9 +177,8 @@ export async function onboardSlackInternalApp(input) {
189
177
  }
190
178
  },
191
179
  });
192
- console.error(`[fastagent] Slack app installed; rotating bot credentials and Signing Secret written to ${dotEnvPath(input.target)}`);
193
- console.error(`[fastagent] run \`fastagent dev --tunnel\` next FastAgent will rotate the config token and ` +
194
- "replace the temporary Events API URL automatically");
180
+ // What happened, once. What to do next is `add`'s "next steps" block.
181
+ console.error(`[fastagent] Slack app installed; credentials written to ${dotEnvPath(input.target)}`);
195
182
  }
196
183
  finally {
197
184
  tunnel.close();
@@ -1,7 +1,6 @@
1
1
  import { type ChannelKind } from "../../scaffold/add-channel.ts";
2
2
  /** `fastagent add <kind> [dir]`: scaffold `channels/<kind>.ts` — the adapter import plus a starter `on()`. */
3
3
  export declare function runAddChannel(channelKind: ChannelKind, dirArg: string, opts: {
4
- createApp?: boolean;
5
4
  ingress?: string;
6
5
  groupBehavior?: string;
7
6
  onboard?: boolean;
@@ -8,9 +8,9 @@ import { join, relative, resolve } from "node:path";
8
8
  import { isCancel, select } from "@clack/prompts";
9
9
  import { onboardFeishuCloudApp } from "../add-feishu.js";
10
10
  import { dotEnvPath, loadDotEnv } from "../../env.js";
11
- import { resolveStateRoot, SECRETS_DIRNAME, isUnderDir, displayPath, exists } from "../../paths.js";
11
+ import { resolveStateRoot, SECRETS_DIRNAME, isUnderDir, displayPath } from "../../paths.js";
12
12
  import { detectRuntime, readPackageJson } from "../../runtime.js";
13
- import { appendChannelDotEnv, appendChannelEnv, assertChannelReady, channelExists, channelSetup, scaffoldChannel, } from "../../scaffold/add-channel.js";
13
+ import { appendChannelDotEnv, appendChannelEnv, assertChannelReady, channelExists, channelSetup, scaffoldChannel, scaffoldCompanionTools, } from "../../scaffold/add-channel.js";
14
14
  import { vendorSkill } from "../../scaffold/vendor-skill.js";
15
15
  import { failStartup, failUsage, placementOrExit } from "../fail.js";
16
16
  /** `fastagent add <kind> [dir]`: scaffold `channels/<kind>.ts` — the adapter import plus a starter `on()`. */
@@ -33,41 +33,25 @@ export async function runAddChannel(channelKind, dirArg, opts) {
33
33
  const inAgent = (p) => (agentFromCwd === undefined ? p : join(agentFromCwd, p));
34
34
  const envPath = dotEnvPath(target);
35
35
  const envLabel = isUnderDir(envPath, target) ? inAgent(relative(target, envPath)) : envPath;
36
- // App creation is not a flag it is what `add feishu` IS (the scan-to-create flow is the default
37
- // and only path there). The retired --create-app spelling gets a pointer, not silence.
38
- if (opts.createApp) {
39
- if (channelKind === "feishu") {
40
- console.error(`[fastagent] note: --create-app is retired — \`add feishu\` creates the app by default`);
41
- }
42
- else if (channelKind === "lark") {
43
- failStartup(new Error("--create-app is retired — `add lark` now opens the developer console and guides credential setup by default"));
44
- }
45
- else {
46
- failStartup(new Error("--create-app is retired — app creation is the default behavior of `add feishu`"));
47
- }
48
- }
49
- // Preconditions before the write, so a refusal is side-effect-free. slack/feishu/lark are exceptions:
50
- // their add is scaffold + ONBOARD THE APP, so an existing scaffold skips the write and continues (a
51
- // failed/cancelled app or OAuth flow must be re-runnable without hand-deleting authored glue).
36
+ // An existing channel file is authored glue: kept, never rewritten. The add continues past it the
37
+ // companion tools below are refreshed, and slack/feishu/lark resume a failed/cancelled app or OAuth
38
+ // flow — so re-running `add <kind>` never requires hand-deleting the glue.
52
39
  const file = join(target, "channels", `${channelKind}.ts`);
53
- const slackToolFile = join(target, "tools", "slack-send.ts");
54
- const slackToolExisted = channelKind === "slack" ? await exists(slackToolFile) : false;
55
40
  const existsAlready = await channelExists(target, channelKind).catch(failStartup);
56
41
  const ingress = await resolveIngress(channelKind, file, existsAlready, opts.ingress);
57
42
  const groupBehavior = await resolveGroupBehavior(channelKind, opts.groupBehavior);
58
43
  if (existsAlready) {
59
- if (channelKind !== "slack" && channelKind !== "feishu" && channelKind !== "lark") {
60
- failStartup(new Error(`${relative(target, file)} already exists — edit it, or remove it to re-scaffold`));
61
- }
62
44
  console.error(`[fastagent] ${relative(target, file)} already exists — keeping it`);
63
45
  }
64
46
  else {
65
47
  await assertChannelReady(target).catch(failStartup);
66
- await scaffoldChannel(target, channelKind, { ingress, groupBehavior: groupBehavior.behavior }).catch(failStartup);
48
+ await scaffoldChannel(target, channelKind, { ingress }).catch(failStartup);
67
49
  console.error(`[fastagent] created ${relative(target, file)}`);
68
- if (channelKind === "slack") {
69
- console.error(`[fastagent] ${slackToolExisted ? "kept existing" : "created"} ${relative(target, slackToolFile)}`);
70
- }
50
+ }
51
+ // Companion tools are the package's, not authored glue: written on every add, so an upgraded
52
+ // package reaches an existing agent by re-running `add <kind>` (slack: `--no-onboard` skips the prompts).
53
+ for (const tool of await scaffoldCompanionTools(target, channelKind).catch(failStartup)) {
54
+ console.error(`[fastagent] wrote ${relative(target, tool)}`);
71
55
  }
72
56
  if (await appendChannelEnv(target, channelKind, ingress).catch(failStartup)) {
73
57
  console.error(`[fastagent] added ${channelKind} env vars to ${inAgent(join(SECRETS_DIRNAME, ".env.example"))}`);
@@ -93,9 +77,9 @@ export async function runAddChannel(channelKind, dirArg, opts) {
93
77
  const env = setup.env;
94
78
  const steps = channelKind === "slack" && opts.onboard !== false
95
79
  ? [
96
- `Slack internal app created/configured/installed through OAuth; runtime credentials are in ${envLabel}`,
97
- "run fastagent dev --tunnel to replace the temporary Events API URL automatically",
98
- "invite the app to every channel it should read",
80
+ // No line about the Request URL: the `dev --tunnel` line below is the whole instruction, and
81
+ // FastAgent sets that URL itself when the agent first runs.
82
+ "invite the app to each channel it should read",
99
83
  "the agent can send messages or files by calling the scaffolded {tools}/slack-send.ts tool",
100
84
  ]
101
85
  : setup.steps;
@@ -137,7 +121,7 @@ export async function runAddChannel(channelKind, dirArg, opts) {
137
121
  console.error(` fastagent dev # no public URL or tunnel required`);
138
122
  }
139
123
  else if (channelKind === "slack") {
140
- console.error(` fastagent dev --tunnel # serve locally; ${opts.onboard === false ? "print the Request URL for manual Slack setup" : "auto-update the onboarded Slack Request URL"}`);
124
+ console.error(` fastagent dev --tunnel # ${opts.onboard === false ? "serve locally + print the URL to paste into the Slack app's Event Subscriptions" : "start the agent then message it in Slack"}`);
141
125
  }
142
126
  else if (channelKind === "github") {
143
127
  console.error(` fastagent dev --tunnel # serve locally + print the URL for manual GitHub webhook setup`);
@@ -183,16 +167,20 @@ async function resolveIngress(kind, file, existsAlready, raw) {
183
167
  }
184
168
  const answer = await select({
185
169
  message: `How should ${kind === "feishu" ? "Feishu" : "Lark"} deliver events?`,
170
+ // The default must match the non-interactive branch above. Webhook is the cheaper side to be
171
+ // wrong on: its credentials are a superset of websocket's (App ID/Secret plus the Verification
172
+ // Token), and that token has no read API — a websocket app moving to webhook must re-acquire it.
173
+ initialValue: "webhook",
186
174
  options: [
187
- {
188
- value: "websocket",
189
- label: "WebSocket long connection",
190
- hint: "no public URL; requires an always-on process",
191
- },
192
175
  {
193
176
  value: "webhook",
194
177
  label: "Webhook endpoint",
195
- hint: "supports scale-to-zero; requires a public HTTPS URL",
178
+ hint: "works on every deploy target; supports scale-to-zero; requires a public HTTPS URL",
179
+ },
180
+ {
181
+ value: "websocket",
182
+ label: "WebSocket long connection",
183
+ hint: "no public URL; needs an always-on process (not on AgentCore)",
196
184
  },
197
185
  ],
198
186
  });
@@ -1,17 +1,11 @@
1
1
  /** `fastagent chat [dir]`: open the SAME assembled agent in pi's interactive TUI. */
2
- import { resolve } from "node:path";
3
- import { loadDotEnv } from "../../env.js";
4
- import { installProxyFetch } from "../../proxy.js";
5
- import { failStartup, placementOrExit } from "../fail.js";
6
- import { resolveFirstRunModel } from "../shared.js";
2
+ import { failStartup } from "../fail.js";
3
+ import { enterAgentCommand } from "../shared.js";
7
4
  export async function runChat(dirArg, opts) {
8
- const placement = placementOrExit(resolve(dirArg));
9
- loadDotEnv(placement.agentDir);
10
- installProxyFetch(); // model calls (and the login dialog) must go through the proxy too
11
- // First-run funnel, FULL picker: chat authenticates through fastagent's credential store like every
12
- // other command (the shared session builder injects it — see engines/pi/session-builder.ts), so the
13
- // credential-annotated catalog and inline login apply here too.
14
- await resolveFirstRunModel(placement.agentDir, { model: opts.model, authPath: opts.authPath });
5
+ // Chat authenticates through fastagent's credential store like every other command (the shared
6
+ // session builder injects it — see engines/pi/session-builder.ts), so the first-run picker and its
7
+ // inline login apply here too.
8
+ const placement = await enterAgentCommand(dirArg, opts);
15
9
  // Run the chat process AT the workspace: pi resolves a session's cwd as `header.cwd ?? process.cwd()`,
16
10
  // so aligning process.cwd() with the workspace keeps a cwd-less session on it. Paths are absolute.
17
11
  process.chdir(placement.workspace);
@@ -0,0 +1,2 @@
1
+ import { type HostDeploy } from "./shared.ts";
2
+ export declare const agentcoreHost: HostDeploy;