@fastagent-sh/fastagent 0.14.0 → 0.16.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 (277) hide show
  1. package/README.md +16 -6
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +258 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +35 -0
  13. package/dist/channels/context-buffer.js +103 -0
  14. package/dist/channels/control.d.ts +28 -0
  15. package/dist/channels/control.js +214 -0
  16. package/dist/channels/feishu/card.js +1 -1
  17. package/dist/channels/feishu/context-buffer.d.ts +15 -13
  18. package/dist/channels/feishu/context-buffer.js +62 -69
  19. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  20. package/dist/channels/feishu/feishu-api.js +24 -3
  21. package/dist/channels/feishu/feishu.d.ts +27 -27
  22. package/dist/channels/feishu/feishu.js +318 -207
  23. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  24. package/dist/channels/feishu/invoke-turn.js +44 -79
  25. package/dist/channels/feishu/model.d.ts +2 -2
  26. package/dist/channels/feishu/normalize.d.ts +2 -1
  27. package/dist/channels/feishu/normalize.js +0 -1
  28. package/dist/channels/feishu/parse.d.ts +22 -8
  29. package/dist/channels/feishu/parse.js +24 -7
  30. package/dist/channels/feishu/preview.d.ts +4 -7
  31. package/dist/channels/feishu/preview.js +27 -142
  32. package/dist/channels/feishu/register-app.d.ts +2 -1
  33. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  34. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  35. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  36. package/dist/channels/feishu/setup-mode.d.ts +37 -0
  37. package/dist/channels/feishu/setup-mode.js +28 -0
  38. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  39. package/dist/channels/feishu/ws-ingress.js +136 -0
  40. package/dist/channels/github/github.js +16 -7
  41. package/dist/channels/http.d.ts +14 -0
  42. package/dist/channels/http.js +36 -3
  43. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  44. package/dist/channels/invoke-turn-kit.js +87 -0
  45. package/dist/channels/lark/lark.d.ts +4 -2
  46. package/dist/channels/lark/lark.js +4 -1
  47. package/dist/channels/lark/onboard.d.ts +8 -4
  48. package/dist/channels/lark/onboard.js +8 -0
  49. package/dist/channels/lark/scaffold/channel.ts +9 -8
  50. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  51. package/dist/channels/preview-kit.d.ts +115 -0
  52. package/dist/channels/preview-kit.js +184 -0
  53. package/dist/channels/seen.d.ts +5 -0
  54. package/dist/channels/seen.js +35 -0
  55. package/dist/channels/slack/bot-auth.d.ts +15 -0
  56. package/dist/channels/slack/bot-auth.js +146 -0
  57. package/dist/channels/slack/config-api.d.ts +60 -0
  58. package/dist/channels/slack/config-api.js +149 -0
  59. package/dist/channels/slack/context-buffer.d.ts +24 -0
  60. package/dist/channels/slack/context-buffer.js +37 -0
  61. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  62. package/dist/channels/slack/invoke-turn.js +63 -0
  63. package/dist/channels/slack/manifest.d.ts +49 -0
  64. package/dist/channels/slack/manifest.js +69 -0
  65. package/dist/channels/slack/model.d.ts +67 -0
  66. package/dist/channels/slack/model.js +2 -0
  67. package/dist/channels/slack/onboard.d.ts +41 -0
  68. package/dist/channels/slack/onboard.js +120 -0
  69. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  70. package/dist/channels/slack/onboarding-state.js +69 -0
  71. package/dist/channels/slack/parse.d.ts +38 -0
  72. package/dist/channels/slack/parse.js +124 -0
  73. package/dist/channels/slack/preview.d.ts +23 -0
  74. package/dist/channels/slack/preview.js +403 -0
  75. package/dist/channels/slack/reaction.d.ts +24 -0
  76. package/dist/channels/slack/reaction.js +62 -0
  77. package/dist/channels/slack/register-webhook.d.ts +10 -0
  78. package/dist/channels/slack/register-webhook.js +49 -0
  79. package/dist/channels/slack/scaffold/channel.ts +33 -0
  80. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  81. package/dist/channels/slack/setup-server.d.ts +17 -0
  82. package/dist/channels/slack/setup-server.js +103 -0
  83. package/dist/channels/slack/slack-api.d.ts +57 -0
  84. package/dist/channels/slack/slack-api.js +399 -0
  85. package/dist/channels/slack/slack.d.ts +51 -0
  86. package/dist/channels/slack/slack.js +496 -0
  87. package/dist/channels/slack/welcomed.d.ts +5 -0
  88. package/dist/channels/slack/welcomed.js +32 -0
  89. package/dist/channels/state.d.ts +11 -4
  90. package/dist/channels/state.js +22 -15
  91. package/dist/channels/stop-command.d.ts +6 -0
  92. package/dist/channels/stop-command.js +36 -0
  93. package/dist/channels/tasks.d.ts +7 -0
  94. package/dist/channels/tasks.js +25 -0
  95. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  96. package/dist/channels/telegram/context-buffer.js +6 -85
  97. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  98. package/dist/channels/telegram/invoke-turn.js +11 -58
  99. package/dist/channels/telegram/parse.d.ts +0 -7
  100. package/dist/channels/telegram/parse.js +4 -2
  101. package/dist/channels/telegram/preview.d.ts +4 -7
  102. package/dist/channels/telegram/preview.js +24 -142
  103. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  104. package/dist/channels/telegram/telegram.js +24 -10
  105. package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
  106. package/dist/channels/{feishu/text.js → text.js} +15 -1
  107. package/dist/channels/thread-participants.d.ts +21 -0
  108. package/dist/channels/thread-participants.js +132 -0
  109. package/dist/channels/turn-queue.js +8 -1
  110. package/dist/channels/turn-store.d.ts +1 -1
  111. package/dist/channels/turn-store.js +2 -3
  112. package/dist/cli/add-feishu.d.ts +30 -0
  113. package/dist/cli/add-feishu.js +366 -0
  114. package/dist/cli/add-slack.d.ts +11 -0
  115. package/dist/cli/add-slack.js +199 -0
  116. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  117. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  118. package/dist/cli/commands/add.d.ts +4 -0
  119. package/dist/cli/commands/add.js +191 -60
  120. package/dist/cli/commands/attach.d.ts +82 -0
  121. package/dist/cli/commands/attach.js +563 -0
  122. package/dist/cli/commands/chat.d.ts +1 -0
  123. package/dist/cli/commands/chat.js +12 -7
  124. package/dist/cli/commands/deploy.d.ts +3 -1
  125. package/dist/cli/commands/deploy.js +384 -82
  126. package/dist/cli/commands/dev.js +34 -26
  127. package/dist/cli/commands/fire.js +17 -18
  128. package/dist/cli/commands/info.js +36 -29
  129. package/dist/cli/commands/init.d.ts +1 -1
  130. package/dist/cli/commands/init.js +65 -53
  131. package/dist/cli/commands/invoke.js +11 -9
  132. package/dist/cli/commands/login.js +48 -45
  133. package/dist/cli/commands/models.js +1 -1
  134. package/dist/cli/commands/schedule.js +6 -8
  135. package/dist/cli/commands/start.js +102 -39
  136. package/dist/cli/commands/tool.js +23 -18
  137. package/dist/cli/fail.d.ts +17 -2
  138. package/dist/cli/fail.js +25 -1
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +3 -2
  142. package/dist/cli/models-view.d.ts +21 -0
  143. package/dist/cli/models-view.js +66 -0
  144. package/dist/cli/program.js +135 -56
  145. package/dist/cli/serve.d.ts +60 -20
  146. package/dist/cli/serve.js +252 -47
  147. package/dist/cli/shared.d.ts +40 -7
  148. package/dist/cli/shared.js +180 -38
  149. package/dist/collect.d.ts +14 -3
  150. package/dist/collect.js +24 -0
  151. package/dist/core.d.ts +3 -1
  152. package/dist/core.js +2 -0
  153. package/dist/deploy/agentcore/plan.d.ts +117 -0
  154. package/dist/deploy/agentcore/plan.js +721 -0
  155. package/dist/deploy/agentcore/run.d.ts +73 -0
  156. package/dist/deploy/agentcore/run.js +412 -0
  157. package/dist/deploy/agentcore/zip.d.ts +17 -0
  158. package/dist/deploy/agentcore/zip.js +68 -0
  159. package/dist/deploy/container.d.ts +26 -25
  160. package/dist/deploy/container.js +93 -89
  161. package/dist/deploy/docker/plan.d.ts +3 -3
  162. package/dist/deploy/docker/plan.js +20 -23
  163. package/dist/deploy/fly/plan.d.ts +5 -1
  164. package/dist/deploy/fly/plan.js +39 -26
  165. package/dist/deploy/fly/run.d.ts +17 -5
  166. package/dist/deploy/fly/run.js +52 -7
  167. package/dist/deploy/preflight.d.ts +20 -8
  168. package/dist/deploy/preflight.js +267 -71
  169. package/dist/deploy/railway/plan.d.ts +10 -1
  170. package/dist/deploy/railway/plan.js +53 -20
  171. package/dist/deploy/railway/run.d.ts +13 -5
  172. package/dist/deploy/railway/run.js +22 -7
  173. package/dist/deploy/runner.d.ts +7 -3
  174. package/dist/deploy/runner.js +9 -3
  175. package/dist/deploy/secrets.d.ts +2 -1
  176. package/dist/deploy/secrets.js +23 -3
  177. package/dist/dev-supervisor.d.ts +11 -10
  178. package/dist/dev-supervisor.js +56 -50
  179. package/dist/engines/pi/auth.d.ts +8 -7
  180. package/dist/engines/pi/auth.js +12 -10
  181. package/dist/engines/pi/channel.d.ts +22 -16
  182. package/dist/engines/pi/channel.js +92 -62
  183. package/dist/engines/pi/chat.d.ts +4 -16
  184. package/dist/engines/pi/chat.js +8 -261
  185. package/dist/engines/pi/config.d.ts +20 -55
  186. package/dist/engines/pi/config.js +49 -121
  187. package/dist/engines/pi/create.d.ts +30 -10
  188. package/dist/engines/pi/create.js +33 -26
  189. package/dist/engines/pi/definition.d.ts +7 -26
  190. package/dist/engines/pi/definition.js +8 -54
  191. package/dist/engines/pi/harness.d.ts +49 -0
  192. package/dist/engines/pi/harness.js +90 -2
  193. package/dist/engines/pi/invoke.d.ts +55 -2
  194. package/dist/engines/pi/invoke.js +304 -19
  195. package/dist/engines/pi/login.d.ts +12 -1
  196. package/dist/engines/pi/login.js +16 -4
  197. package/dist/engines/pi/models.d.ts +59 -13
  198. package/dist/engines/pi/models.js +62 -24
  199. package/dist/engines/pi/open.d.ts +114 -0
  200. package/dist/engines/pi/open.js +127 -0
  201. package/dist/engines/pi/session-builder.d.ts +16 -0
  202. package/dist/engines/pi/session-builder.js +308 -0
  203. package/dist/engines/pi/session-control.d.ts +50 -0
  204. package/dist/engines/pi/session-control.js +604 -0
  205. package/dist/engines/pi/sessions.d.ts +17 -2
  206. package/dist/engines/pi/sessions.js +9 -0
  207. package/dist/engines/pi/tool-context.d.ts +18 -11
  208. package/dist/engines/pi/tool-context.js +3 -9
  209. package/dist/engines/pi/tool.d.ts +6 -8
  210. package/dist/engines/pi/tool.js +10 -1
  211. package/dist/engines/pi/wake-tool.d.ts +0 -3
  212. package/dist/engines/pi/wake-tool.js +9 -7
  213. package/dist/env.d.ts +16 -4
  214. package/dist/env.js +43 -5
  215. package/dist/feishu.d.ts +1 -1
  216. package/dist/feishu.js +1 -1
  217. package/dist/host/node.d.ts +25 -8
  218. package/dist/host/node.js +5 -4
  219. package/dist/index.d.ts +1 -0
  220. package/dist/index.js +1 -0
  221. package/dist/lark.d.ts +1 -1
  222. package/dist/lark.js +1 -1
  223. package/dist/loader.d.ts +2 -2
  224. package/dist/loader.js +3 -3
  225. package/dist/log.d.ts +1 -1
  226. package/dist/log.js +1 -1
  227. package/dist/observe.js +3 -0
  228. package/dist/paths.d.ts +138 -0
  229. package/dist/paths.js +326 -0
  230. package/dist/pi.d.ts +6 -5
  231. package/dist/pi.js +4 -3
  232. package/dist/runtime.d.ts +7 -5
  233. package/dist/runtime.js +2 -2
  234. package/dist/scaffold/add-channel.d.ts +26 -9
  235. package/dist/scaffold/add-channel.js +175 -40
  236. package/dist/scaffold/init.d.ts +32 -41
  237. package/dist/scaffold/init.js +161 -185
  238. package/dist/scaffold/templates/env.example +15 -6
  239. package/dist/scaffold/templates/fastagent.config.mjs +5 -4
  240. package/dist/scaffold/templates/gitignore +14 -6
  241. package/dist/scaffold/templates/persona.md +4 -2
  242. package/dist/scaffold/templates/secrets.gitignore +5 -0
  243. package/dist/scaffold/templates.d.ts +1 -7
  244. package/dist/scaffold/templates.js +3 -25
  245. package/dist/scaffold/vendor-skill.d.ts +2 -2
  246. package/dist/scaffold/vendor-skill.js +13 -13
  247. package/dist/schedule/discover.js +4 -4
  248. package/dist/schedule/scheduler.d.ts +40 -1
  249. package/dist/schedule/scheduler.js +89 -56
  250. package/dist/schedule/state.js +1 -1
  251. package/dist/schedule/wake-alarm.d.ts +47 -0
  252. package/dist/schedule/wake-alarm.js +136 -0
  253. package/dist/schedule/wakeups.d.ts +1 -3
  254. package/dist/schedule/wakeups.js +19 -1
  255. package/dist/session-remote.d.ts +53 -0
  256. package/dist/session-remote.js +336 -0
  257. package/dist/session.d.ts +265 -0
  258. package/dist/session.js +37 -0
  259. package/dist/slack.d.ts +2 -0
  260. package/dist/slack.js +2 -0
  261. package/dist/tunnel.d.ts +7 -4
  262. package/dist/tunnel.js +23 -12
  263. package/package.json +22 -7
  264. package/dist/channels/feishu/owned-threads.d.ts +0 -11
  265. package/dist/channels/feishu/owned-threads.js +0 -47
  266. package/dist/channels/feishu/seen.d.ts +0 -5
  267. package/dist/channels/feishu/seen.js +0 -47
  268. package/dist/cli-add-feishu.d.ts +0 -8
  269. package/dist/cli-add-feishu.js +0 -223
  270. package/dist/cli-models.d.ts +0 -11
  271. package/dist/cli-models.js +0 -20
  272. package/dist/engines/pi/workspace.d.ts +0 -56
  273. package/dist/engines/pi/workspace.js +0 -72
  274. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  275. package/dist/scaffold/templates/gitignore.kit +0 -2
  276. package/dist/workspace.d.ts +0 -9
  277. package/dist/workspace.js +0 -45
@@ -7,7 +7,7 @@ import { type FeishuCloudKind } from "./cloud.ts";
7
7
  *
8
8
  * Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
9
9
  */
10
- export interface FeishuManualRegistration {
10
+ interface FeishuManualRegistration {
11
11
  consoleUrl: string;
12
12
  requestUrl: string;
13
13
  }
@@ -20,3 +20,4 @@ export interface RegisterFeishuWebhookOptions {
20
20
  onManualRegistration?: (info: FeishuManualRegistration) => void;
21
21
  }
22
22
  export declare function registerFeishuWebhook(baseUrl: string, kind: FeishuCloudKind, opts?: RegisterFeishuWebhookOptions): Promise<RegistrationOutcome>;
23
+ export {};
@@ -8,8 +8,10 @@ import { feishuChannel } from "@fastagent-sh/fastagent/feishu";
8
8
  // 2. Permissions: add `im:message.p2p_msg:readonly` (direct messages), `im:message.group_at_msg:readonly`
9
9
  // (group @mentions), `im:message:send_as_bot` (reply), `im:resource` (attachments), and the
10
10
  // card scope ("Create and update card" — the live preview streams through a card). To answer bare
11
- // messages in Agent-managed threads and buffer other unsummoned group/thread context, also add the
12
- // sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version.
11
+ // messages in threads the Agent takes part in, and buffer other unsummoned group/thread context, also add the
12
+ // sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version. Add a
13
+ // message-read scope (e.g. `im:message:readonly`) too, so a thread's opening ask can carry the
14
+ // message it quotes; without it that quote degrades to a marker in the prompt.
13
15
  // 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
14
16
  // .env; RECOMMENDED: set an Encrypt Key there and mirror it in FEISHU_ENCRYPT_KEY
15
17
  // 4. the event Request URL (https://your.host/feishu) is registered AUTOMATICALLY by
@@ -23,18 +25,17 @@ export default feishuChannel({
23
25
  appSecret: process.env.FEISHU_APP_SECRET ?? "",
24
26
  verificationToken: process.env.FEISHU_VERIFICATION_TOKEN ?? "", // authenticates inbound events
25
27
  encryptKey: process.env.FEISHU_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
26
- // Direct and group chats default to one Agent session/thread per top-level ask. Opt out independently:
27
- // directMessageSession: "continuous",
28
- // groupMessageSession: "continuous",
28
+ // No session modes: a chat is one session and a thread is another, and where the answer goes follows
29
+ // from that (docs/design/participant-model.md).
29
30
  // Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
30
31
  // chat is customer-facing by default — for a public bot, drop this or return a neutral string;
31
32
  // full details always go to the server log regardless.
32
33
  onError: (failed) => `⚠️ ${failed.details}`,
33
34
  // The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
34
35
  // the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultFeishuRoute:
35
- // p2p chats always answer; groups answer on @this-bot, plus bare continuations in Agent-managed
36
- // threads. Other human group/thread discussion buffers until that place's next answered turn;
37
- // @other-only messages in managed threads buffer rather than triggering the Agent.
36
+ // p2p chats always answer; groups answer on @this-bot, plus bare messages in a thread where the
37
+ // Agent takes part and exactly ONE human does. Other human group/thread discussion buffers until
38
+ // that place's next answered turn; @other-only messages buffer rather than triggering the Agent.
38
39
  // Override to customise explicit routing, reusing the export:
39
40
  // route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
40
41
  // route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { text: `${feishuEnvelope(e)}\n[extra]` },
@@ -6,7 +6,7 @@ import { defineTool, z } from "@fastagent-sh/fastagent";
6
6
  // context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
7
7
  // the target chat id. tools/ is auto-discovered.
8
8
 
9
- // Standalone copy of the channel transport's discipline: a timeout so a wedged connection can't hang
9
+ // Embedded copy of the channel transport's discipline: a timeout so a wedged connection can't hang
10
10
  // the tool call (and the turn), named errors, and success gated on the body's own code===0.
11
11
  // Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
12
12
  // fail-fast beats a silently sleeping tool.
@@ -57,9 +57,11 @@ async function tenantToken(): Promise<string> {
57
57
  export default defineTool({
58
58
  description:
59
59
  "Send a message to a Feishu chat: plain `text`, or `markdown` (rendered as a card — headings, " +
60
- "bold, code blocks, links). Exactly one of the two. In a chat turn take chatId from the " +
61
- "[feishu: chat …] context line; in a scheduled/woken turn (no context line) the chat id must come " +
62
- "from your instruction.",
60
+ "bold, code blocks, links). Exactly one of the two. Use it for a turn NO channel is carrying — a " +
61
+ "scheduled or self-scheduled (wake) turn or to reach a chat OTHER than the one you are " +
62
+ "answering. In a normal chat turn the channel already delivers your reply, so do NOT call this to " +
63
+ "answer (it would post the message twice). chatId comes from the [feishu: chat …] context line in a " +
64
+ "chat turn; a scheduled/woken turn has no context line, so name the destination in your instruction.",
63
65
  input: z.object({
64
66
  chatId: z.string().describe("target chat id (oc_…)"),
65
67
  text: z.string().optional().describe("plain text message to send"),
@@ -0,0 +1,37 @@
1
+ /** Feishu/Lark app-level event subscription choice used by onboarding and scaffolding. */
2
+ export type FeishuSubscriptionMode = "webhook" | "websocket";
3
+ /** Onboarding choice for group visibility. `context` needs the tenant-wide group-message scope; the
4
+ * runtime remains capability-driven because the platform, not channel source, decides which events
5
+ * are delivered. */
6
+ export type FeishuGroupBehavior = "context" | "mentions";
7
+ /** The sensitive tenant scope behind both bare replies in the agent's threads and group context buffering. */
8
+ export declare const FEISHU_GROUP_CONTEXT_SCOPE = "im:message.group_msg";
9
+ /** Reading a QUOTED message by id, so an ask carries what it replies to (participant-model.md §8).
10
+ * INDEPENDENT of the delivery scope above and of the group posture: the read runs in every chat type
11
+ * (a p2p thread's opening ask, any quoted @mention in a group). It is also a softer dependency —
12
+ * without it everything still works, and an unreadable quote degrades to a marker in the prompt.
13
+ * Bundled into the context-aware request only because that path already needs an approval round; a
14
+ * mention-only app wanting referents must add it by hand. */
15
+ export declare const FEISHU_MESSAGE_READ_SCOPE = "im:message:readonly";
16
+ /** A scope the onboarding asks for, plus any BROADER spelling that already satisfies it. One concept,
17
+ * so adding a superset means editing the entry — not every call site that tests a scope. */
18
+ export interface FeishuScopeRequest {
19
+ /** What to add to the app draft when nothing satisfies it. Always counts as satisfying itself. */
20
+ request: string;
21
+ /** EXTRA spellings that also count — supersets. Optional; `request` is implicit. Modelled this way
22
+ * so a request that can never be satisfied (a list omitting its own `request`) cannot be
23
+ * written: onboarding would add the scope, the tenant would grant it, and it would still read as
24
+ * missing forever. */
25
+ supersets?: string[];
26
+ }
27
+ /** Whether `predicate` holds for any spelling that satisfies this request. */
28
+ export declare function scopeSatisfied(entry: FeishuScopeRequest, predicate: (name: string) => boolean): boolean;
29
+ /** Reading a quoted message: `im:message` is the read/write superset, so an app holding it can already
30
+ * do so — checking only the readonly spelling would warn a correctly configured app forever and push
31
+ * its author into a redundant approval round. Exported on its own because the serving-time capability
32
+ * report asks the same question, and a second mechanism there is what this type exists to prevent. */
33
+ export declare const FEISHU_MESSAGE_READ_REQUEST: FeishuScopeRequest;
34
+ /** What `--group-behavior context` REQUESTS in one approval round — not a dependency set. Only the
35
+ * delivery scope is required for the context path; the read scope rides along because it shares the
36
+ * round and its absence merely degrades quoted messages to a marker. */
37
+ export declare const FEISHU_CONTEXT_ONBOARDING_SCOPES: FeishuScopeRequest[];
@@ -0,0 +1,28 @@
1
+ /** The sensitive tenant scope behind both bare replies in the agent's threads and group context buffering. */
2
+ export const FEISHU_GROUP_CONTEXT_SCOPE = "im:message.group_msg";
3
+ /** Reading a QUOTED message by id, so an ask carries what it replies to (participant-model.md §8).
4
+ * INDEPENDENT of the delivery scope above and of the group posture: the read runs in every chat type
5
+ * (a p2p thread's opening ask, any quoted @mention in a group). It is also a softer dependency —
6
+ * without it everything still works, and an unreadable quote degrades to a marker in the prompt.
7
+ * Bundled into the context-aware request only because that path already needs an approval round; a
8
+ * mention-only app wanting referents must add it by hand. */
9
+ export const FEISHU_MESSAGE_READ_SCOPE = "im:message:readonly";
10
+ /** Whether `predicate` holds for any spelling that satisfies this request. */
11
+ export function scopeSatisfied(entry, predicate) {
12
+ return [entry.request, ...(entry.supersets ?? [])].some(predicate);
13
+ }
14
+ /** Reading a quoted message: `im:message` is the read/write superset, so an app holding it can already
15
+ * do so — checking only the readonly spelling would warn a correctly configured app forever and push
16
+ * its author into a redundant approval round. Exported on its own because the serving-time capability
17
+ * report asks the same question, and a second mechanism there is what this type exists to prevent. */
18
+ export const FEISHU_MESSAGE_READ_REQUEST = {
19
+ request: FEISHU_MESSAGE_READ_SCOPE,
20
+ supersets: ["im:message"],
21
+ };
22
+ /** What `--group-behavior context` REQUESTS in one approval round — not a dependency set. Only the
23
+ * delivery scope is required for the context path; the read scope rides along because it shares the
24
+ * round and its absence merely degrades quoted messages to a marker. */
25
+ export const FEISHU_CONTEXT_ONBOARDING_SCOPES = [
26
+ { request: FEISHU_GROUP_CONTEXT_SCOPE },
27
+ FEISHU_MESSAGE_READ_REQUEST,
28
+ ];
@@ -0,0 +1,28 @@
1
+ import type { LongConnection } from "../../host/node.ts";
2
+ import type { FeishuCloudKind } from "./cloud.ts";
3
+ import type { FeishuMessageEvent } from "./parse.ts";
4
+ interface FeishuWsClient {
5
+ start(): Promise<void>;
6
+ close(): void;
7
+ }
8
+ interface FeishuWsClientCallbacks {
9
+ onReady(): void;
10
+ onError(error: Error): void;
11
+ onReconnecting(): void;
12
+ onReconnected(): void;
13
+ onEvent(event: FeishuMessageEvent): void | Promise<void>;
14
+ }
15
+ export type CreateFeishuWsClient = (callbacks: FeishuWsClientCallbacks) => FeishuWsClient | Promise<FeishuWsClient>;
16
+ export interface FeishuWsConnectionOptions {
17
+ kind: FeishuCloudKind;
18
+ appId: string;
19
+ appSecret: string;
20
+ domain: string;
21
+ onEvent(event: FeishuMessageEvent): void | Promise<void>;
22
+ /** Internal SDK seam for deterministic tests. */
23
+ createClient?: CreateFeishuWsClient;
24
+ }
25
+ /** Open Feishu/Lark's official-SDK WebSocket connection. The SDK ACKs only after `onEvent` settles;
26
+ * a persistence throw therefore becomes a 500 response frame and the platform re-pushes the event. */
27
+ export declare function connectFeishuWs(options: FeishuWsConnectionOptions, signal: AbortSignal): LongConnection;
28
+ export {};
@@ -0,0 +1,136 @@
1
+ import { log } from "../../log.js";
2
+ function sdkLogger(label) {
3
+ return {
4
+ error: (...parts) => log.error(`${label} ${parts.map(String).join(" ")}`),
5
+ warn: (...parts) => log.warn(`${label} ${parts.map(String).join(" ")}`),
6
+ info: (...parts) => log.info(`${label} ${parts.map(String).join(" ")}`),
7
+ debug: (...parts) => log.debug(`${label} ${parts.map(String).join(" ")}`),
8
+ trace: (...parts) => log.debug(`${label} ${parts.map(String).join(" ")}`),
9
+ };
10
+ }
11
+ async function productionClient(options, callbacks) {
12
+ // Webhook-only users stay on the lightweight fetch path; load the proprietary-protocol SDK only
13
+ // when a WebSocket connection is actually opened.
14
+ const { EventDispatcher, LoggerLevel, WSClient } = await import("@larksuiteoapi/node-sdk");
15
+ const label = `[${options.kind}:ws]`;
16
+ const logger = sdkLogger(label);
17
+ const eventDispatcher = new EventDispatcher({ logger, loggerLevel: LoggerLevel.warn }).register({
18
+ "im.message.receive_v1": callbacks.onEvent,
19
+ });
20
+ const client = new WSClient({
21
+ appId: options.appId,
22
+ appSecret: options.appSecret,
23
+ domain: options.domain,
24
+ logger,
25
+ loggerLevel: LoggerLevel.warn,
26
+ autoReconnect: true,
27
+ source: "fastagent",
28
+ handshakeTimeoutMs: 15_000,
29
+ onReady: callbacks.onReady,
30
+ onError: callbacks.onError,
31
+ onReconnecting: callbacks.onReconnecting,
32
+ onReconnected: callbacks.onReconnected,
33
+ });
34
+ return {
35
+ start: () => client.start({ eventDispatcher }),
36
+ close: () => client.close(),
37
+ };
38
+ }
39
+ /** Open Feishu/Lark's official-SDK WebSocket connection. The SDK ACKs only after `onEvent` settles;
40
+ * a persistence throw therefore becomes a 500 response frame and the platform re-pushes the event. */
41
+ export function connectFeishuWs(options, signal) {
42
+ const label = `[${options.kind}:ws]`;
43
+ // The SDK only logs + returns for this case (no onError), which would leave readiness pending forever.
44
+ if (!/^cli_[0-9a-fA-F]{16}$/.test(options.appId)) {
45
+ throw new Error(`${options.kind} websocket requires an App ID shaped like cli_<16 hex characters>`);
46
+ }
47
+ let readySettled = false;
48
+ let closedSettled = false;
49
+ let resolveReady;
50
+ let rejectReady;
51
+ let resolveClosed;
52
+ let rejectClosed;
53
+ const ready = new Promise((resolve, reject) => {
54
+ resolveReady = resolve;
55
+ rejectReady = reject;
56
+ });
57
+ const closed = new Promise((resolve, reject) => {
58
+ resolveClosed = resolve;
59
+ rejectClosed = reject;
60
+ });
61
+ const fail = (error) => {
62
+ if (signal.aborted || closedSettled)
63
+ return;
64
+ // Settle-then-close: closedSettled first makes a close()-triggered SDK callback re-entry a no-op.
65
+ closedSettled = true;
66
+ // Terminal failure must release the transport here: the abort listener's close() no-ops once
67
+ // closedSettled is set, and only close() destroys SDK-held resources (e.g. its cache sweep timer).
68
+ if (client)
69
+ closeClient(client);
70
+ const failure = error instanceof Error ? error : new Error(String(error));
71
+ if (!readySettled) {
72
+ readySettled = true;
73
+ rejectReady(failure);
74
+ }
75
+ rejectClosed(failure);
76
+ };
77
+ const createClient = options.createClient ?? ((callbacks) => productionClient(options, callbacks));
78
+ let client;
79
+ const callbacks = {
80
+ onReady() {
81
+ if (readySettled || signal.aborted)
82
+ return;
83
+ readySettled = true;
84
+ log.info(`${label} connected`);
85
+ resolveReady();
86
+ },
87
+ onError(error) {
88
+ fail(new Error(`${error.message} — check that Events & Callbacks uses long connection and the published app version includes im.message.receive_v1`));
89
+ },
90
+ onReconnecting() {
91
+ if (!signal.aborted)
92
+ log.warn(`${label} disconnected — reconnecting…`);
93
+ },
94
+ onReconnected() {
95
+ if (!signal.aborted)
96
+ log.info(`${label} reconnected`);
97
+ },
98
+ onEvent: options.onEvent,
99
+ };
100
+ const closeClient = (target) => {
101
+ try {
102
+ target.close();
103
+ }
104
+ catch (error) {
105
+ log.warn(`${label} close failed: ${String(error)}`);
106
+ }
107
+ };
108
+ const close = () => {
109
+ if (closedSettled)
110
+ return;
111
+ closedSettled = true; // before closeClient, so a callback re-entry from close() is a no-op
112
+ if (client)
113
+ closeClient(client);
114
+ if (!readySettled) {
115
+ // Abort before the first connection: `ready` still settles, and resolution here means
116
+ // cancellation, not readiness (the LongConnection contract; serve skips ready-side effects).
117
+ readySettled = true;
118
+ resolveReady();
119
+ }
120
+ resolveClosed();
121
+ };
122
+ if (signal.aborted) {
123
+ close();
124
+ return { ready, closed };
125
+ }
126
+ signal.addEventListener("abort", close, { once: true });
127
+ void Promise.resolve(createClient(callbacks)).then((created) => {
128
+ client = created;
129
+ if (signal.aborted || closedSettled) {
130
+ closeClient(created);
131
+ return;
132
+ }
133
+ void created.start().catch(fail);
134
+ }, fail);
135
+ return { ready, closed };
136
+ }
@@ -7,6 +7,7 @@ import { verify } from "@octokit/webhooks-methods";
7
7
  import { collect } from "../../collect.js";
8
8
  import { log } from "../../log.js";
9
9
  import { readBodyCapped } from "../body.js";
10
+ import { beginWork } from "../busy.js";
10
11
  import { text } from "../respond.js";
11
12
  /** Raw body cap before verification — GitHub caps webhook payloads at 25 MB; reject larger early. */
12
13
  const MAX_WEBHOOK_BYTES = 25 << 20;
@@ -17,12 +18,7 @@ const MAX_WEBHOOK_BYTES = 25 << 20;
17
18
  * `(ctx) => ({ "POST /gh": githubChannel(opts)(ctx)["POST /webhook"]! })`.
18
19
  */
19
20
  export function githubChannel({ secret, on }) {
20
- // A non-empty secret is mandatory: verify() against an empty key accepts a signature anyone can
21
- // compute, so an unset secret must fail at construction, never silently run forgeable.
22
- if (!secret) {
23
- throw new Error("githubChannel requires a non-empty secret (the GitHub webhook secret, e.g. GITHUB_WEBHOOK_SECRET)");
24
- }
25
- return ({ agent }) => ({
21
+ const channel = ({ agent }) => ({
26
22
  "POST /webhook": async (req) => {
27
23
  if (req.method !== "POST")
28
24
  return text("POST only\n", 405);
@@ -72,9 +68,22 @@ export function githubChannel({ secret, on }) {
72
68
  const turn = `${event.deliveryId}#${i}`;
73
69
  log.info(`[github] turn start: turn=${turn} session=${session} event=${label}`);
74
70
  const startedAt = Date.now();
75
- void collect(agent.invoke({ session }, { text })).then(() => log.info(`[github] turn done: turn=${turn} session=${session} (${Date.now() - startedAt}ms)`), (error) => log.error(`[github] turn failed: turn=${turn} session=${session} (${Date.now() - startedAt}ms): ${String(error)}`));
71
+ // Post-ACK turns are process-wide in-flight work (busy.ts): a serving surface that must not
72
+ // idle mid-turn (the AgentCore /ping's HealthyBusy) has no other way to see them — and github
73
+ // turns have NO replay, so an idle reclaim here loses the review outright.
74
+ const workDone = beginWork();
75
+ void collect(agent.invoke({ session }, { text }))
76
+ .then(() => log.info(`[github] turn done: turn=${turn} session=${session} (${Date.now() - startedAt}ms)`), (error) => log.error(`[github] turn failed: turn=${turn} session=${session} (${Date.now() - startedAt}ms): ${String(error)}`))
77
+ .finally(workDone);
76
78
  }
77
79
  return new Response(null, { status: 202 });
78
80
  },
79
81
  });
82
+ return (ctx) => {
83
+ // Validate at activation so deploy may inspect the module shape before secrets are provisioned.
84
+ if (!secret) {
85
+ throw new Error("githubChannel requires a non-empty secret (the GitHub webhook secret, e.g. GITHUB_WEBHOOK_SECRET)");
86
+ }
87
+ return channel(ctx);
88
+ };
80
89
  }
@@ -1,5 +1,19 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Agent } from "../agent.ts";
3
+ /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
4
+ * dispatch imports it), so the two caps cannot drift apart. */
5
+ export declare const MAX_BODY_BYTES: number;
6
+ /** SSE comment-heartbeat interval, shared by every SSE surface (the control events route imports
7
+ * it, and the remote client sizes its dead-connection watchdog as a multiple of it). */
8
+ export declare const SSE_HEARTBEAT_MS = 30000;
9
+ /** The emitting half of the heartbeat contract (the client watchdog is the other): starts the
10
+ * `: ping` comment interval on an SSE stream controller and returns its stop function — ONE
11
+ * implementation for every SSE surface, so the emission side cannot regress on one route while
12
+ * the shared client watchdog keeps assuming it. Self-stops if the controller is already closed. */
13
+ export declare function sseHeartbeat(controller: ReadableStreamDefaultController<Uint8Array>): () => void;
14
+ /** A valid example request body for the invoke handler — lives HERE, next to the shape check it must
15
+ * satisfy, so the CLI's "try it" hint can't drift from the protocol. */
16
+ export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hello\"}";
3
17
  /**
4
18
  * Fetch-shaped invoke handler. Mount it at any route in the host app; it accepts POST only.
5
19
  * Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
@@ -6,15 +6,38 @@
6
6
  * web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
7
7
  * invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
8
8
  *
9
- * `nodeListener` is the thin node:http adapter for the standalone `fastagent dev/start` server.
9
+ * `nodeListener` is the thin node:http adapter for the embedded `fastagent dev/start` server.
10
10
  */
11
11
  import { Readable } from "node:stream";
12
12
  import { log } from "../log.js";
13
13
  import { readBodyCapped } from "./body.js";
14
14
  import { text, textHeaders } from "./respond.js";
15
- /** Request body cap (1 MiB). */
16
- const MAX_BODY_BYTES = 1 << 20;
15
+ /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
16
+ * dispatch imports it), so the two caps cannot drift apart. */
17
+ export const MAX_BODY_BYTES = 1 << 20;
17
18
  const encoder = new TextEncoder();
19
+ /** SSE comment-heartbeat interval, shared by every SSE surface (the control events route imports
20
+ * it, and the remote client sizes its dead-connection watchdog as a multiple of it). */
21
+ export const SSE_HEARTBEAT_MS = 30_000;
22
+ /** The emitting half of the heartbeat contract (the client watchdog is the other): starts the
23
+ * `: ping` comment interval on an SSE stream controller and returns its stop function — ONE
24
+ * implementation for every SSE surface, so the emission side cannot regress on one route while
25
+ * the shared client watchdog keeps assuming it. Self-stops if the controller is already closed. */
26
+ export function sseHeartbeat(controller) {
27
+ const encoder = new TextEncoder();
28
+ const timer = setInterval(() => {
29
+ try {
30
+ controller.enqueue(encoder.encode(": ping\n\n"));
31
+ }
32
+ catch {
33
+ clearInterval(timer);
34
+ }
35
+ }, SSE_HEARTBEAT_MS);
36
+ return () => clearInterval(timer);
37
+ }
38
+ /** A valid example request body for the invoke handler — lives HERE, next to the shape check it must
39
+ * satisfy, so the CLI's "try it" hint can't drift from the protocol. */
40
+ export const INVOKE_EXAMPLE_BODY = '{"session":"dev","text":"hello"}';
18
41
  /**
19
42
  * Fetch-shaped invoke handler. Mount it at any route in the host app; it accepts POST only.
20
43
  * Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
@@ -37,19 +60,29 @@ export function createInvokeHandler(agent) {
37
60
  if (typeof session !== "string" || typeof promptText !== "string") {
38
61
  return text('need { "session": string, "text": string }\n', 400);
39
62
  }
63
+ // ^ the request shape INVOKE_EXAMPLE_BODY (below) must keep satisfying.
40
64
  // Take the iterator explicitly so the stream's cancel() (consumer disconnect) can return() it and
41
65
  // run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
42
66
  const iterator = agent.invoke({ session }, { text: promptText })[Symbol.asyncIterator]();
67
+ // Heartbeats: a QUIET stream (a long tool call, no events) is normal here — remote consumers
68
+ // distinguish "quiet but alive" from a dead connection by byte arrival, so silence must not
69
+ // look identical to a black hole (SSE comments are ignored by spec-conforming parsers).
70
+ let stopHeartbeat = () => { };
43
71
  const stream = new ReadableStream({
72
+ start(controller) {
73
+ stopHeartbeat = sseHeartbeat(controller);
74
+ },
44
75
  async pull(controller) {
45
76
  const { value, done } = await iterator.next();
46
77
  if (done) {
78
+ stopHeartbeat();
47
79
  controller.close();
48
80
  return;
49
81
  }
50
82
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
51
83
  },
52
84
  async cancel() {
85
+ stopHeartbeat();
53
86
  await iterator.return?.();
54
87
  },
55
88
  });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared pieces of the channels' invoke-turn modules (telegram/feishu/slack `invoke-turn.ts`) — the
3
+ * halves that are channel-independent, so a retry-policy or prompt-wording change lands ONCE:
4
+ *
5
+ * - {@link streamTurnWithBusyRetry}: the busy-retry loop around `agent.invoke`, with the
6
+ * `onCompleted` durable-commit point;
7
+ * - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
8
+ * {@link missingAttachmentsNote}, {@link attributedFileName}.
9
+ *
10
+ * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
+ * message-scoped Feishu keys, Slack file objects) are real differences.
12
+ */
13
+ import { type Agent, type AgentEvent, type Prompt } from "../agent.ts";
14
+ /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
15
+ * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
16
+ * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
17
+ * always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
18
+ export interface BusyRetry {
19
+ delayMs: number;
20
+ maxWaitMs: number;
21
+ }
22
+ export declare const DEFAULT_BUSY_RETRY: BusyRetry;
23
+ /**
24
+ * Stream one Agent turn with the shared busy-wait. `onCompleted` (if given) fires on the turn's
25
+ * `completed` event — the durable-commit point: only then does the turn provably live in the session,
26
+ * so a failure or crash at ANY earlier point leaves the caller's pre-ACK state (turn intent, context
27
+ * buffer) intact for replay/the next summon. The caller uses it to remove the turn intent AND commit
28
+ * the context buffer, in that order, so a crash between the two clears cannot replay a
29
+ * context-stripped turn.
30
+ *
31
+ * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
32
+ * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user
33
+ * sees the channel's "Thinking…" placeholder while waiting (the mirror of the scheduler deferring a
34
+ * wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a FIRST-event
35
+ * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
36
+ * is ever re-run.
37
+ */
38
+ export declare function streamTurnWithBusyRetry(agent: Agent, session: string, prompt: Prompt, options: {
39
+ label: string;
40
+ onCompleted?: () => void;
41
+ busyRetry?: BusyRetry;
42
+ }): AsyncIterable<AgentEvent>;
43
+ /** What the attached-files manifest renders per file: display name, byte size, absolute local path. */
44
+ export interface ManifestFile {
45
+ name: string;
46
+ size: number;
47
+ path: string;
48
+ }
49
+ /** The downloaded-file manifest appended to the prompt — the agent reads the paths with its tools.
50
+ * Empty input renders nothing. */
51
+ export declare function attachedFilesManifest(files: readonly ManifestFile[]): string;
52
+ /** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
53
+ * the way the fold attributes text lines. */
54
+ export declare function attributedFileName(name: string, from: string, msg?: string | number): string;
55
+ /** The manifest attributing background vision images folded in from the earlier discussion — images
56
+ * carry no per-image label inline, so position ("appended after N primary") is the attribution.
57
+ * Channels whose image refs carry no attribution (telegram) simply don't render one. */
58
+ export declare function backgroundImagesManifest(primaryCount: number, refs: readonly {
59
+ from: string;
60
+ messageId: string;
61
+ }[]): string;
62
+ /** The prompt note counting EVERY background attachment the turn does not carry (load failures +
63
+ * cap-skipped) — without it, the model holds fold references it silently cannot open and may
64
+ * pretend it read them. Neutral wording (platforms differ on WHY: expired file_ids, deleted files). */
65
+ export declare function missingAttachmentsNote(missing: number): string;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared pieces of the channels' invoke-turn modules (telegram/feishu/slack `invoke-turn.ts`) — the
3
+ * halves that are channel-independent, so a retry-policy or prompt-wording change lands ONCE:
4
+ *
5
+ * - {@link streamTurnWithBusyRetry}: the busy-retry loop around `agent.invoke`, with the
6
+ * `onCompleted` durable-commit point;
7
+ * - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
8
+ * {@link missingAttachmentsNote}, {@link attributedFileName}.
9
+ *
10
+ * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
+ * message-scoped Feishu keys, Slack file objects) are real differences.
12
+ */
13
+ import { SESSION_BUSY_CODE } from "../agent.js";
14
+ import { log } from "../log.js";
15
+ // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
16
+ // free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
17
+ // real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
18
+ // this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
19
+ // turn forever.
20
+ export const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
21
+ /**
22
+ * Stream one Agent turn with the shared busy-wait. `onCompleted` (if given) fires on the turn's
23
+ * `completed` event — the durable-commit point: only then does the turn provably live in the session,
24
+ * so a failure or crash at ANY earlier point leaves the caller's pre-ACK state (turn intent, context
25
+ * buffer) intact for replay/the next summon. The caller uses it to remove the turn intent AND commit
26
+ * the context buffer, in that order, so a crash between the two clears cannot replay a
27
+ * context-stripped turn.
28
+ *
29
+ * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
30
+ * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user
31
+ * sees the channel's "Thinking…" placeholder while waiting (the mirror of the scheduler deferring a
32
+ * wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a FIRST-event
33
+ * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
34
+ * is ever re-run.
35
+ */
36
+ export async function* streamTurnWithBusyRetry(agent, session, prompt, options) {
37
+ const { label, onCompleted, busyRetry = DEFAULT_BUSY_RETRY } = options;
38
+ const deadline = Date.now() + busyRetry.maxWaitMs;
39
+ for (;;) {
40
+ let retryBusy = false;
41
+ let first = true;
42
+ for await (const e of agent.invoke({ session }, prompt)) {
43
+ if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
44
+ retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
45
+ break;
46
+ }
47
+ first = false;
48
+ if (e.type === "completed")
49
+ onCompleted?.(); // the turn is durably in the session — commit point
50
+ yield e;
51
+ }
52
+ if (!retryBusy)
53
+ return;
54
+ log.info(`${label} session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
55
+ await new Promise((r) => setTimeout(r, busyRetry.delayMs));
56
+ }
57
+ }
58
+ /** The downloaded-file manifest appended to the prompt — the agent reads the paths with its tools.
59
+ * Empty input renders nothing. */
60
+ export function attachedFilesManifest(files) {
61
+ return files.length
62
+ ? `\n\n[attached files — read them with your tools:\n${files.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
63
+ : "";
64
+ }
65
+ /** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
66
+ * the way the fold attributes text lines. */
67
+ export function attributedFileName(name, from, msg) {
68
+ return `${name} (from ${from}${msg !== undefined ? `, msg ${msg}` : ""}, earlier discussion)`;
69
+ }
70
+ /** The manifest attributing background vision images folded in from the earlier discussion — images
71
+ * carry no per-image label inline, so position ("appended after N primary") is the attribution.
72
+ * Channels whose image refs carry no attribution (telegram) simply don't render one. */
73
+ export function backgroundImagesManifest(primaryCount, refs) {
74
+ return refs.length
75
+ ? `\n\n[background vision images from earlier discussion — appended after ${primaryCount} primary image(s):\n${refs
76
+ .map((ref, index) => `- vision image ${primaryCount + index + 1}: from ${ref.from}, msg ${ref.messageId}`)
77
+ .join("\n")}\n]`
78
+ : "";
79
+ }
80
+ /** The prompt note counting EVERY background attachment the turn does not carry (load failures +
81
+ * cap-skipped) — without it, the model holds fold references it silently cannot open and may
82
+ * pretend it read them. Neutral wording (platforms differ on WHY: expired file_ids, deleted files). */
83
+ export function missingAttachmentsNote(missing) {
84
+ return missing > 0
85
+ ? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (no longer available, or older than the most recent few)]`
86
+ : "";
87
+ }
@@ -3,9 +3,10 @@
3
3
  * this module binds it to Lark's cloud profile and exposes natural Lark-branded public names. Lark's
4
4
  * weaker control-plane capabilities live in onboarding/registration, not in a fork of the turn engine.
5
5
  */
6
- import type { ChannelModule } from "../../host/node.ts";
7
- import { type FeishuChannelOptions, type FeishuFailure, type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, defaultFeishuRoute } from "../feishu/feishu.ts";
6
+ import type { ChannelModule, LongConnectionChannelModule } from "../../host/node.ts";
7
+ import { type FeishuChannelOptions, type FeishuFailure, type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, type FeishuWebSocketChannelOptions, defaultFeishuRoute } from "../feishu/feishu.ts";
8
8
  export type LarkChannelOptions = FeishuChannelOptions;
9
+ export type LarkWebSocketChannelOptions = FeishuWebSocketChannelOptions;
9
10
  export type LarkFailure = FeishuFailure;
10
11
  export type LarkMessage = FeishuMessage;
11
12
  export type LarkMessageEvent = FeishuMessageEvent;
@@ -13,3 +14,4 @@ export type LarkRoute = FeishuRoute;
13
14
  export declare const defaultLarkRoute: typeof defaultFeishuRoute;
14
15
  export declare function larkEnvelope(event: LarkMessageEvent): string;
15
16
  export declare function larkChannel(opts: LarkChannelOptions): ChannelModule;
17
+ export declare function larkWebSocketChannel(opts: LarkWebSocketChannelOptions): LongConnectionChannelModule;
@@ -1,5 +1,5 @@
1
1
  import { LARK_COMPAT_CLOUD } from "../feishu/cloud.js";
2
- import { buildFeishuChannel, defaultFeishuRoute, } from "../feishu/feishu.js";
2
+ import { buildFeishuChannel, buildFeishuWebSocketChannel, defaultFeishuRoute, } from "../feishu/feishu.js";
3
3
  import { cloudEnvelope } from "../feishu/parse.js";
4
4
  export const defaultLarkRoute = defaultFeishuRoute;
5
5
  export function larkEnvelope(event) {
@@ -8,3 +8,6 @@ export function larkEnvelope(event) {
8
8
  export function larkChannel(opts) {
9
9
  return buildFeishuChannel(LARK_COMPAT_CLOUD, opts, larkChannel.name);
10
10
  }
11
+ export function larkWebSocketChannel(opts) {
12
+ return buildFeishuWebSocketChannel(LARK_COMPAT_CLOUD, opts, larkWebSocketChannel.name);
13
+ }