@fastagent-sh/fastagent 0.21.1 → 0.22.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 (388) hide show
  1. package/README.md +9 -12
  2. package/dist/agent.d.ts +24 -37
  3. package/dist/agent.js +7 -15
  4. package/dist/atomic-write.d.ts +13 -17
  5. package/dist/atomic-write.js +18 -29
  6. package/dist/bind.d.ts +12 -22
  7. package/dist/bind.js +18 -27
  8. package/dist/channel.d.ts +11 -33
  9. package/dist/channels/agentcore-limits.d.ts +2 -13
  10. package/dist/channels/agentcore-limits.js +2 -13
  11. package/dist/channels/agentcore-protocol.d.ts +67 -58
  12. package/dist/channels/agentcore-protocol.js +23 -15
  13. package/dist/channels/agentcore-service.d.ts +19 -33
  14. package/dist/channels/agentcore-service.js +176 -59
  15. package/dist/channels/agentcore.d.ts +39 -33
  16. package/dist/channels/agentcore.js +90 -224
  17. package/dist/channels/body.d.ts +15 -2
  18. package/dist/channels/body.js +24 -2
  19. package/dist/channels/busy.d.ts +1 -21
  20. package/dist/channels/busy.js +2 -39
  21. package/dist/channels/control.d.ts +17 -62
  22. package/dist/channels/control.js +74 -182
  23. package/dist/channels/define-channel.d.ts +39 -0
  24. package/dist/channels/define-channel.js +9 -0
  25. package/dist/channels/discover.d.ts +10 -31
  26. package/dist/channels/discover.js +37 -31
  27. package/dist/channels/feishu/bootstrap-token.d.ts +1 -8
  28. package/dist/channels/feishu/bootstrap-token.js +7 -23
  29. package/dist/channels/feishu/card.d.ts +14 -28
  30. package/dist/channels/feishu/card.js +16 -30
  31. package/dist/channels/feishu/cloud.d.ts +1 -5
  32. package/dist/channels/feishu/context-buffer.d.ts +4 -23
  33. package/dist/channels/feishu/context-buffer.js +8 -31
  34. package/dist/channels/feishu/crypto.d.ts +10 -5
  35. package/dist/channels/feishu/crypto.js +12 -19
  36. package/dist/channels/feishu/feishu-api.d.ts +2 -1
  37. package/dist/channels/feishu/feishu-api.js +5 -4
  38. package/dist/channels/feishu/feishu.d.ts +4 -10
  39. package/dist/channels/feishu/feishu.js +113 -227
  40. package/dist/channels/feishu/invoke-turn.d.ts +5 -4
  41. package/dist/channels/feishu/invoke-turn.js +36 -54
  42. package/dist/channels/feishu/model.d.ts +4 -21
  43. package/dist/channels/feishu/model.js +1 -8
  44. package/dist/channels/feishu/normalize.d.ts +2 -10
  45. package/dist/channels/feishu/normalize.js +7 -42
  46. package/dist/channels/feishu/parse.d.ts +9 -29
  47. package/dist/channels/feishu/parse.js +8 -26
  48. package/dist/channels/feishu/preview.d.ts +12 -14
  49. package/dist/channels/feishu/preview.js +108 -207
  50. package/dist/channels/feishu/register-app.d.ts +8 -23
  51. package/dist/channels/feishu/register-app.js +7 -20
  52. package/dist/channels/feishu/register-webhook.d.ts +2 -9
  53. package/dist/channels/feishu/register-webhook.js +9 -33
  54. package/dist/channels/feishu/scaffold/channel.ts +27 -19
  55. package/dist/channels/feishu/scaffold/channel.websocket.ts +31 -0
  56. package/dist/channels/feishu/setup-mode.d.ts +11 -36
  57. package/dist/channels/feishu/setup-mode.js +7 -26
  58. package/dist/channels/feishu/ws-ingress.d.ts +1 -2
  59. package/dist/channels/feishu/ws-ingress.js +6 -8
  60. package/dist/channels/http.d.ts +15 -15
  61. package/dist/channels/http.js +19 -9
  62. package/dist/channels/kit/attachment-path.d.ts +4 -5
  63. package/dist/channels/kit/attachment-path.js +10 -34
  64. package/dist/channels/kit/context-buffer.d.ts +14 -21
  65. package/dist/channels/kit/context-buffer.js +25 -38
  66. package/dist/channels/kit/delivery.d.ts +35 -0
  67. package/dist/channels/kit/delivery.js +129 -0
  68. package/dist/channels/kit/event-stream.d.ts +5 -0
  69. package/dist/channels/kit/event-stream.js +29 -0
  70. package/dist/channels/kit/invoke-turn-kit.d.ts +53 -26
  71. package/dist/channels/kit/invoke-turn-kit.js +89 -32
  72. package/dist/channels/kit/preview-kit.d.ts +18 -72
  73. package/dist/channels/kit/preview-kit.js +22 -88
  74. package/dist/channels/kit/signature.d.ts +3 -9
  75. package/dist/channels/kit/signature.js +3 -9
  76. package/dist/channels/kit/state.d.ts +1 -2
  77. package/dist/channels/kit/state.js +5 -14
  78. package/dist/channels/kit/stop-command.d.ts +1 -2
  79. package/dist/channels/kit/stop-command.js +6 -9
  80. package/dist/channels/kit/tasks.d.ts +1 -1
  81. package/dist/channels/kit/tasks.js +8 -21
  82. package/dist/channels/kit/text.d.ts +2 -16
  83. package/dist/channels/kit/text.js +23 -31
  84. package/dist/channels/kit/thread-participants.d.ts +4 -17
  85. package/dist/channels/kit/thread-participants.js +8 -55
  86. package/dist/channels/kit/transport.d.ts +26 -0
  87. package/dist/channels/kit/transport.js +20 -0
  88. package/dist/channels/kit/turn-queue.d.ts +7 -11
  89. package/dist/channels/kit/turn-queue.js +21 -39
  90. package/dist/channels/kit/turn-runner.d.ts +32 -18
  91. package/dist/channels/kit/turn-runner.js +98 -53
  92. package/dist/channels/kit/turn-store.d.ts +32 -32
  93. package/dist/channels/kit/turn-store.js +45 -88
  94. package/dist/channels/lark/lark.d.ts +1 -5
  95. package/dist/channels/lark/onboard.d.ts +3 -12
  96. package/dist/channels/lark/onboard.js +3 -13
  97. package/dist/channels/lark/scaffold/channel.ts +27 -19
  98. package/dist/channels/lark/scaffold/channel.websocket.ts +31 -0
  99. package/dist/channels/registration.d.ts +9 -35
  100. package/dist/channels/registration.js +6 -37
  101. package/dist/channels/secret.js +2 -5
  102. package/dist/channels/serve.d.ts +47 -44
  103. package/dist/channels/serve.js +165 -101
  104. package/dist/channels/slack/config-api.d.ts +4 -27
  105. package/dist/channels/slack/config-api.js +5 -34
  106. package/dist/channels/slack/context-buffer.d.ts +2 -3
  107. package/dist/channels/slack/context-buffer.js +5 -5
  108. package/dist/channels/slack/invoke-turn.d.ts +3 -1
  109. package/dist/channels/slack/invoke-turn.js +25 -34
  110. package/dist/channels/slack/manifest.js +1 -3
  111. package/dist/channels/slack/model.d.ts +1 -2
  112. package/dist/channels/slack/model.js +1 -1
  113. package/dist/channels/slack/onboard.d.ts +4 -3
  114. package/dist/channels/slack/onboard.js +8 -11
  115. package/dist/channels/slack/onboarding-state.d.ts +3 -5
  116. package/dist/channels/slack/onboarding-state.js +9 -6
  117. package/dist/channels/slack/parse.d.ts +4 -13
  118. package/dist/channels/slack/parse.js +7 -28
  119. package/dist/channels/slack/preview.d.ts +12 -2
  120. package/dist/channels/slack/preview.js +292 -269
  121. package/dist/channels/slack/reaction.d.ts +11 -3
  122. package/dist/channels/slack/reaction.js +19 -8
  123. package/dist/channels/slack/register-webhook.js +4 -7
  124. package/dist/channels/slack/scaffold/channel.ts +20 -13
  125. package/dist/channels/slack/scaffold/slack-send.ts +2 -2
  126. package/dist/channels/slack/setup-server.d.ts +1 -5
  127. package/dist/channels/slack/setup-server.js +1 -5
  128. package/dist/channels/slack/shared-api.d.ts +1 -4
  129. package/dist/channels/slack/shared-api.js +2 -13
  130. package/dist/channels/slack/slack-api.d.ts +2 -1
  131. package/dist/channels/slack/slack-api.js +6 -4
  132. package/dist/channels/slack/slack.d.ts +11 -14
  133. package/dist/channels/slack/slack.js +121 -122
  134. package/dist/channels/sse.d.ts +1 -1
  135. package/dist/channels/sse.js +1 -1
  136. package/dist/channels/telegram/context-buffer.d.ts +7 -22
  137. package/dist/channels/telegram/context-buffer.js +6 -23
  138. package/dist/channels/telegram/invoke-turn.d.ts +7 -7
  139. package/dist/channels/telegram/invoke-turn.js +24 -43
  140. package/dist/channels/telegram/parse.d.ts +24 -51
  141. package/dist/channels/telegram/parse.js +32 -84
  142. package/dist/channels/telegram/preview.d.ts +10 -1
  143. package/dist/channels/telegram/preview.js +79 -100
  144. package/dist/channels/telegram/register-webhook.js +5 -23
  145. package/dist/channels/telegram/scaffold/channel.ts +25 -18
  146. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -4
  147. package/dist/channels/telegram/telegram-api.d.ts +4 -3
  148. package/dist/channels/telegram/telegram-api.js +9 -7
  149. package/dist/channels/telegram/telegram.d.ts +4 -18
  150. package/dist/channels/telegram/telegram.js +38 -94
  151. package/dist/channels/telegram/turn-store.d.ts +11 -14
  152. package/dist/channels/telegram/turn-store.js +8 -6
  153. package/dist/channels/wait-health.d.ts +4 -6
  154. package/dist/channels/wait-health.js +10 -14
  155. package/dist/cli/add-feishu.d.ts +2 -12
  156. package/dist/cli/add-feishu.js +24 -71
  157. package/dist/cli/add-slack.d.ts +5 -3
  158. package/dist/cli/add-slack.js +53 -76
  159. package/dist/cli/auth-view.d.ts +2 -16
  160. package/dist/cli/auth-view.js +2 -16
  161. package/dist/cli/commands/add.js +25 -40
  162. package/dist/cli/commands/chat.d.ts +0 -1
  163. package/dist/cli/commands/chat.js +5 -6
  164. package/dist/cli/commands/deploy/agentcore.js +60 -65
  165. package/dist/cli/commands/deploy/docker.js +50 -23
  166. package/dist/cli/commands/deploy/fly.js +23 -61
  167. package/dist/cli/commands/deploy/railway.js +23 -18
  168. package/dist/cli/commands/deploy/shared.d.ts +38 -57
  169. package/dist/cli/commands/deploy/shared.js +40 -59
  170. package/dist/cli/commands/deploy.d.ts +10 -27
  171. package/dist/cli/commands/deploy.js +38 -60
  172. package/dist/cli/commands/destroy.d.ts +4 -0
  173. package/dist/cli/commands/destroy.js +56 -0
  174. package/dist/cli/commands/dev.d.ts +2 -1
  175. package/dist/cli/commands/dev.js +20 -23
  176. package/dist/cli/commands/info.d.ts +0 -2
  177. package/dist/cli/commands/info.js +83 -49
  178. package/dist/cli/commands/init.d.ts +1 -2
  179. package/dist/cli/commands/init.js +16 -55
  180. package/dist/cli/commands/invoke.d.ts +0 -1
  181. package/dist/cli/commands/invoke.js +5 -7
  182. package/dist/cli/commands/login.d.ts +2 -1
  183. package/dist/cli/commands/login.js +33 -38
  184. package/dist/cli/commands/logs.js +3 -4
  185. package/dist/cli/commands/models.js +1 -5
  186. package/dist/cli/commands/routine-run.d.ts +6 -0
  187. package/dist/cli/commands/routine-run.js +47 -0
  188. package/dist/cli/commands/routine.d.ts +25 -0
  189. package/dist/cli/commands/routine.js +122 -0
  190. package/dist/cli/commands/start.d.ts +31 -3
  191. package/dist/cli/commands/start.js +131 -81
  192. package/dist/cli/commands/tool.js +26 -12
  193. package/dist/cli/fail.d.ts +13 -23
  194. package/dist/cli/fail.js +20 -23
  195. package/dist/cli/invoke-stream.d.ts +1 -8
  196. package/dist/cli/kernel.d.ts +6 -38
  197. package/dist/cli/kernel.js +14 -77
  198. package/dist/cli/models-view.d.ts +1 -9
  199. package/dist/cli/models-view.js +11 -22
  200. package/dist/cli/program.d.ts +5 -6
  201. package/dist/cli/program.js +120 -157
  202. package/dist/cli/serve.d.ts +57 -41
  203. package/dist/cli/serve.js +94 -121
  204. package/dist/cli/shared.d.ts +15 -61
  205. package/dist/cli/shared.js +55 -181
  206. package/dist/cli.js +1 -8
  207. package/dist/collect.d.ts +6 -0
  208. package/dist/collect.js +6 -0
  209. package/dist/core.d.ts +4 -1
  210. package/dist/core.js +9 -7
  211. package/dist/declared-secrets.d.ts +55 -0
  212. package/dist/declared-secrets.js +88 -0
  213. package/dist/deploy/agentcore/aws-cli.d.ts +91 -0
  214. package/dist/deploy/agentcore/aws-cli.js +64 -0
  215. package/dist/deploy/agentcore/destroy.d.ts +51 -0
  216. package/dist/deploy/agentcore/destroy.js +273 -0
  217. package/dist/deploy/agentcore/forwarder.js +28 -79
  218. package/dist/deploy/agentcore/logs.d.ts +2 -18
  219. package/dist/deploy/agentcore/logs.js +27 -51
  220. package/dist/deploy/agentcore/plan.d.ts +85 -105
  221. package/dist/deploy/agentcore/plan.js +152 -314
  222. package/dist/deploy/agentcore/run.d.ts +28 -28
  223. package/dist/deploy/agentcore/run.js +227 -306
  224. package/dist/deploy/agentcore/zip.d.ts +2 -15
  225. package/dist/deploy/agentcore/zip.js +3 -17
  226. package/dist/deploy/build-context.d.ts +21 -0
  227. package/dist/deploy/build-context.js +137 -0
  228. package/dist/deploy/channel-ingress.d.ts +26 -39
  229. package/dist/deploy/channel-ingress.js +57 -28
  230. package/dist/deploy/container.d.ts +20 -34
  231. package/dist/deploy/container.js +49 -100
  232. package/dist/deploy/docker/plan.d.ts +12 -11
  233. package/dist/deploy/docker/plan.js +43 -24
  234. package/dist/deploy/docker/run.d.ts +22 -25
  235. package/dist/deploy/docker/run.js +39 -37
  236. package/dist/deploy/fly/plan.d.ts +15 -44
  237. package/dist/deploy/fly/plan.js +41 -73
  238. package/dist/deploy/fly/run.d.ts +16 -51
  239. package/dist/deploy/fly/run.js +42 -95
  240. package/dist/deploy/hosts.d.ts +24 -3
  241. package/dist/deploy/hosts.js +24 -3
  242. package/dist/deploy/preflight.d.ts +47 -41
  243. package/dist/deploy/preflight.js +282 -383
  244. package/dist/deploy/railway/plan.d.ts +16 -42
  245. package/dist/deploy/railway/plan.js +42 -60
  246. package/dist/deploy/railway/run.d.ts +30 -68
  247. package/dist/deploy/railway/run.js +86 -100
  248. package/dist/deploy/registration-gate.d.ts +6 -13
  249. package/dist/deploy/residency.d.ts +37 -0
  250. package/dist/deploy/residency.js +24 -0
  251. package/dist/deploy/runner.d.ts +10 -9
  252. package/dist/deploy/runner.js +7 -18
  253. package/dist/deploy/secrets.d.ts +40 -41
  254. package/dist/deploy/secrets.js +117 -103
  255. package/dist/deploy/workspace.d.ts +50 -0
  256. package/dist/deploy/workspace.js +197 -0
  257. package/dist/dev-supervisor.d.ts +1 -9
  258. package/dist/dev-supervisor.js +21 -64
  259. package/dist/effect-port.d.ts +50 -0
  260. package/dist/effect-port.js +77 -0
  261. package/dist/engines/pi/agent-session-factory.d.ts +50 -69
  262. package/dist/engines/pi/agent-session-factory.js +249 -146
  263. package/dist/engines/pi/auth.d.ts +15 -11
  264. package/dist/engines/pi/auth.js +96 -99
  265. package/dist/engines/pi/chat.d.ts +1 -5
  266. package/dist/engines/pi/chat.js +2 -14
  267. package/dist/engines/pi/config.d.ts +53 -51
  268. package/dist/engines/pi/config.js +83 -92
  269. package/dist/engines/pi/create.d.ts +60 -75
  270. package/dist/engines/pi/create.js +108 -141
  271. package/dist/engines/pi/definition.d.ts +14 -58
  272. package/dist/engines/pi/definition.js +21 -85
  273. package/dist/engines/pi/invoke-session.d.ts +5 -25
  274. package/dist/engines/pi/invoke-session.js +233 -244
  275. package/dist/engines/pi/login.d.ts +95 -35
  276. package/dist/engines/pi/login.js +259 -107
  277. package/dist/engines/pi/machine.d.ts +31 -0
  278. package/dist/engines/pi/machine.js +106 -0
  279. package/dist/engines/pi/models.d.ts +70 -89
  280. package/dist/engines/pi/models.js +188 -81
  281. package/dist/engines/pi/open.d.ts +65 -53
  282. package/dist/engines/pi/open.js +129 -69
  283. package/dist/engines/pi/report.d.ts +3 -12
  284. package/dist/engines/pi/report.js +2 -16
  285. package/dist/engines/pi/search-tools.d.ts +1 -9
  286. package/dist/engines/pi/search-tools.js +26 -57
  287. package/dist/engines/pi/service.d.ts +2 -16
  288. package/dist/engines/pi/service.js +2 -16
  289. package/dist/engines/pi/session-builder.d.ts +5 -9
  290. package/dist/engines/pi/session-builder.js +44 -82
  291. package/dist/engines/pi/session-control.js +286 -269
  292. package/dist/engines/pi/session-effects.d.ts +14 -0
  293. package/dist/engines/pi/session-effects.js +30 -0
  294. package/dist/engines/pi/session-inheritance.d.ts +9 -18
  295. package/dist/engines/pi/session-inheritance.js +103 -160
  296. package/dist/engines/pi/session-markers.d.ts +31 -30
  297. package/dist/engines/pi/session-markers.js +26 -25
  298. package/dist/engines/pi/session-settings.d.ts +6 -22
  299. package/dist/engines/pi/session-settings.js +10 -21
  300. package/dist/engines/pi/session-store.d.ts +26 -92
  301. package/dist/engines/pi/session-store.js +96 -310
  302. package/dist/engines/pi/tool-context.d.ts +17 -45
  303. package/dist/engines/pi/tool-context.js +7 -26
  304. package/dist/engines/pi/tool.d.ts +45 -36
  305. package/dist/engines/pi/tool.js +43 -57
  306. package/dist/engines/pi/turn-kit.d.ts +14 -52
  307. package/dist/engines/pi/turn-kit.js +18 -67
  308. package/dist/engines/pi/wake-tool.d.ts +2 -10
  309. package/dist/engines/pi/wake-tool.js +7 -27
  310. package/dist/env.d.ts +27 -28
  311. package/dist/env.js +45 -50
  312. package/dist/index.js +0 -2
  313. package/dist/lark.d.ts +1 -2
  314. package/dist/lark.js +1 -2
  315. package/dist/loader.d.ts +14 -55
  316. package/dist/loader.js +24 -43
  317. package/dist/log.d.ts +2 -12
  318. package/dist/log.js +3 -24
  319. package/dist/node.d.ts +2 -15
  320. package/dist/node.js +2 -18
  321. package/dist/observe.d.ts +2 -8
  322. package/dist/open-url.d.ts +1 -1
  323. package/dist/open-url.js +1 -1
  324. package/dist/paths.d.ts +70 -134
  325. package/dist/paths.js +165 -232
  326. package/dist/pi.d.ts +4 -6
  327. package/dist/pi.js +3 -3
  328. package/dist/proxy.d.ts +16 -6
  329. package/dist/proxy.js +52 -16
  330. package/dist/runtime.d.ts +18 -12
  331. package/dist/runtime.js +18 -11
  332. package/dist/scaffold/add-channel.d.ts +14 -31
  333. package/dist/scaffold/add-channel.js +48 -112
  334. package/dist/scaffold/init.d.ts +9 -31
  335. package/dist/scaffold/init.js +45 -138
  336. package/dist/scaffold/templates/env.example +6 -4
  337. package/dist/scaffold/templates/fastagent.config.ts +36 -0
  338. package/dist/scaffold/templates.d.ts +3 -3
  339. package/dist/scaffold/templates.js +5 -8
  340. package/dist/scaffold/vendor-skill.d.ts +2 -5
  341. package/dist/scaffold/vendor-skill.js +19 -29
  342. package/dist/schedule/cron.d.ts +9 -5
  343. package/dist/schedule/cron.js +9 -11
  344. package/dist/schedule/discover.d.ts +12 -12
  345. package/dist/schedule/discover.js +43 -34
  346. package/dist/schedule/routine.d.ts +43 -0
  347. package/dist/schedule/routine.js +32 -0
  348. package/dist/schedule/run.d.ts +55 -0
  349. package/dist/schedule/run.js +146 -0
  350. package/dist/schedule/scheduler.d.ts +44 -55
  351. package/dist/schedule/scheduler.js +291 -265
  352. package/dist/schedule/state.d.ts +105 -10
  353. package/dist/schedule/state.js +293 -19
  354. package/dist/schedule/wake-alarm.d.ts +10 -23
  355. package/dist/schedule/wake-alarm.js +91 -121
  356. package/dist/schedule/wakeups.d.ts +23 -39
  357. package/dist/schedule/wakeups.js +33 -75
  358. package/dist/secrets-gate.d.ts +32 -0
  359. package/dist/secrets-gate.js +33 -0
  360. package/dist/service.d.ts +137 -98
  361. package/dist/service.js +250 -252
  362. package/dist/session-remote.d.ts +33 -44
  363. package/dist/session-remote.js +203 -192
  364. package/dist/session.d.ts +166 -229
  365. package/dist/session.js +33 -55
  366. package/dist/tunnel.d.ts +8 -36
  367. package/dist/tunnel.js +24 -65
  368. package/dist/version.d.ts +2 -2
  369. package/dist/version.js +2 -2
  370. package/package.json +10 -16
  371. package/dist/channels/agentcore-state.d.ts +0 -77
  372. package/dist/channels/agentcore-state.js +0 -276
  373. package/dist/channels/github/github.d.ts +0 -31
  374. package/dist/channels/github/github.js +0 -89
  375. package/dist/channels/github/scaffold/channel.ts +0 -25
  376. package/dist/cli/commands/attach.d.ts +0 -108
  377. package/dist/cli/commands/attach.js +0 -637
  378. package/dist/cli/commands/fire.d.ts +0 -7
  379. package/dist/cli/commands/fire.js +0 -38
  380. package/dist/cli/commands/schedule.d.ts +0 -12
  381. package/dist/cli/commands/schedule.js +0 -87
  382. package/dist/github.d.ts +0 -3
  383. package/dist/github.js +0 -2
  384. package/dist/scaffold/templates/fastagent.config.mjs +0 -23
  385. package/dist/schedule/audit.d.ts +0 -18
  386. package/dist/schedule/audit.js +0 -57
  387. package/dist/schedule/schedule.d.ts +0 -35
  388. package/dist/schedule/schedule.js +0 -23
@@ -139,7 +139,8 @@ export function createFeishuApi(opts) {
139
139
  return cached.token;
140
140
  };
141
141
  /** The pipeline: one JSON API call, carrying every transport invariant (module header). */
142
- const call = async (label, method, path, body) => {
142
+ const call = async (label, method, path, body, opts = {}) => {
143
+ const retries = opts.retries ?? RETRIES;
143
144
  let refreshedAuth = false;
144
145
  for (let attempt = 0;;) {
145
146
  const token = await tenantToken();
@@ -178,7 +179,7 @@ export function createFeishuApi(opts) {
178
179
  cached = undefined;
179
180
  continue;
180
181
  }
181
- if ((res.status === 429 || code === RATE_LIMIT_CODE) && attempt < RETRIES) {
182
+ if ((res.status === 429 || code === RATE_LIMIT_CODE) && attempt < retries) {
182
183
  attempt++;
183
184
  await wait(attempt * 1000);
184
185
  continue;
@@ -327,8 +328,8 @@ export function createFeishuApi(opts) {
327
328
  throw new FeishuApiError(kind, "createCard", 200, 0, "response carried no card_id");
328
329
  return id;
329
330
  },
330
- async updateCardElement(cardId, elementId, content, sequence) {
331
- await call("updateCardElement", "PUT", `/open-apis/cardkit/v1/cards/${encodeURIComponent(cardId)}/elements/${encodeURIComponent(elementId)}/content`, { content, sequence });
331
+ async updateCardElement(cardId, elementId, content, sequence, opts) {
332
+ await call("updateCardElement", "PUT", `/open-apis/cardkit/v1/cards/${encodeURIComponent(cardId)}/elements/${encodeURIComponent(elementId)}/content`, { content, sequence }, opts);
332
333
  },
333
334
  async updateCard(cardId, cardJson, sequence) {
334
335
  await call("updateCard", "PUT", `/open-apis/cardkit/v1/cards/${encodeURIComponent(cardId)}`, {
@@ -10,24 +10,18 @@ interface FeishuChannelBaseOptions {
10
10
  appId: string;
11
11
  /** App Secret (same page) — drives both ingress authentication and outbound API calls. */
12
12
  appSecret: string;
13
- /** Policy: whether/where to answer an event (return null to ignore). Defaults to {@link defaultFeishuRoute}. */
13
+ /** Policy: whether/where to answer an event (return null to ignore). */
14
14
  route?: (event: FeishuMessageEvent) => FeishuRoute | null;
15
- /** Customer-facing failure text for the chat (the dev-facing full `details` always go to the operator
16
- * log). Return a string to send it, or undefined/"" to stay silent. Default: a neutral message keyed
17
- * on `retryable`. A developer's own bot can surface the raw details, e.g. `(f) => `⚠️ ${f.details}``. */
15
+ /** Customer-facing failure text for the chat (the dev-facing full `details` always go to the operator log). */
18
16
  onError?: (failed: FeishuFailure) => string | undefined;
19
- /** API origin override (tests / self-hosted gateways). Feishu factories default to
20
- * `https://open.feishu.cn`; Lark factories default to `https://open.larksuite.com`. Named to match
21
- * the other channels (telegram/slack). */
17
+ /** API origin override (tests / self-hosted gateways). */
22
18
  apiBaseUrl?: string;
23
- /** How long (ms) a turn waits before its reply-quoted "⏳ Queued" card mounts. Defaults to 0
24
- * (immediate); the same card is later taken over by the live preview/final answer. */
19
+ /** How long (ms) a turn waits before its reply-quoted "⏳ Queued" card mounts. */
25
20
  queueNoticeDelayMs?: number;
26
21
  }
27
22
  export interface FeishuChannelOptions extends FeishuChannelBaseOptions {
28
23
  /** Verification Token for Request-URL authentication. */
29
24
  verificationToken: string;
30
- /** Optional webhook Encrypt Key. When set, plaintext events are rejected. */
31
25
  encryptKey?: string;
32
26
  }
33
27
  export type FeishuWebSocketChannelOptions = FeishuChannelBaseOptions & {
@@ -1,12 +1,6 @@
1
1
  /**
2
- * Canonical Feishu bot-channel engine: verified webhook or official-SDK WebSocket → dedup → route →
3
- * persist → enqueue → stream a live card. Feishu (open.feishu.cn) is the reference cloud. Lark
4
- * international binds this engine through an explicit compatibility profile because its control plane
5
- * trails Feishu; protocol reuse does not make Lark the design center.
6
- *
7
- * The channel kind remains the unit of route, env namespace, state home, logs, and onboarding, so one
8
- * workspace may run both without sharing state. Webhook returns the existing route factory; WebSocket
9
- * returns an explicit long-connection module. Both feed the same acceptance/turn engine. See docs/feishu.md.
2
+ * Canonical Feishu bot-channel engine: verified webhook or official-SDK WebSocket → dedup → route → persist → enqueue
3
+ * → stream a live card.
10
4
  */
11
5
  import { isAbsolute, join } from "node:path";
12
6
  import { log } from "../../log.js";
@@ -15,22 +9,24 @@ import { text } from "../respond.js";
15
9
  import { secretEquals } from "../secret.js";
16
10
  import { createSeenRing } from "../kit/seen.js";
17
11
  import { createTaskTracker } from "../kit/tasks.js";
12
+ import { attachmentsDir } from "../kit/attachment-path.js";
18
13
  import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
19
14
  import { signatureIsFresh } from "../kit/signature.js";
20
15
  import { dispatchStop, isStopText } from "../kit/stop-command.js";
21
16
  import { createTurnRunner } from "../kit/turn-runner.js";
17
+ import { portJoin } from "../../effect-port.js";
22
18
  import { createTurnStore } from "../kit/turn-store.js";
23
19
  import { discussionBlock } from "../kit/context-buffer.js";
24
20
  import { FEISHU_CLOUD } from "./cloud.js";
25
21
  import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
26
22
  import { decryptEvent, verifySignature } from "./crypto.js";
27
- import { invokeFeishuTurn } from "./invoke-turn.js";
23
+ import { feishuTurnStream } from "./invoke-turn.js";
28
24
  import { createFeishuApi } from "./feishu-api.js";
29
25
  import { normalizeFeishuMessage } from "./normalize.js";
30
26
  import { createThreadParticipants } from "../kit/thread-participants.js";
31
27
  import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "./setup-mode.js";
32
28
  import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
33
- import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
29
+ import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, feishuReply, } from "./preview.js";
34
30
  import { connectFeishuWs } from "./ws-ingress.js";
35
31
  import { registerFeishuApi } from "./shared-api.js";
36
32
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
@@ -38,17 +34,14 @@ export { defaultFeishuRoute, feishuEnvelope };
38
34
  /** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
39
35
  const MAX_EVENT_BYTES = 1 << 20;
40
36
  /**
41
- * Replay window for a SIGNED event, sized by the OPEN PLATFORM'S REDELIVERY SCHEDULE, not by Slack's
42
- * 5 minutes: a failed push is retried at 15s / 5min / 1h / 6h. It is not established whether a retry
43
- * is re-signed with a fresh timestamp or replays the original one, and only the second case is safe
44
- * to guess wrong about in one direction — a window under 6h would 401 three of the four retries, i.e.
45
- * turn one transient fault into a permanently lost user message (and a callback-health alarm). Wide
46
- * enough to cover the chain, still bounded, and the `seen` ring dedups an event_id inside it.
37
+ * Replay window for a SIGNED event, sized by the OPEN PLATFORM'S REDELIVERY SCHEDULE, not by Slack's 5 minutes: a
38
+ * failed push is retried at 15s / 5min / 1h / 6h.
47
39
  */
48
40
  const MAX_SIGNATURE_AGE_S = 7 * 60 * 60;
49
- /** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
50
- * accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
51
- * is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
41
+ /**
42
+ * Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was accepted behind
43
+ * another turn.
44
+ */
52
45
  const QUEUE_NOTICE_DELAY_MS = 0;
53
46
  const QUEUED_PLACEHOLDER = "⏳ Queued — I’ll start once the current task finishes.";
54
47
  const DEFERRED_PLACEHOLDER = "⏳ Delayed by a temporary system issue — I’ll retry automatically.";
@@ -81,135 +74,93 @@ export function feishuChannel(opts) {
81
74
  export function feishuWebSocketChannel(opts) {
82
75
  return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
83
76
  }
77
+ /**
78
+ * The bot's own open_id — the identity the default route matches group @mentions against. Seeded synchronously from
79
+ * a per-app cache file so a cold start can summon before the network answers, then refreshed once per process by
80
+ * `bot/v3/info`. The two failure directions differ: a FAILED call is transport weather and keeps the cached identity,
81
+ * while a SUCCESSFUL call reporting no open_id is an affirmative "there is no bot here" and clears it (fail-closed).
82
+ */
83
+ function createBotIdentity(deps) {
84
+ const { api, appId, label, botFile } = deps;
85
+ const stored = loadStateFile(botFile);
86
+ if (stored !== undefined && typeof stored.appId !== "string") {
87
+ log.warn(`${label} unexpected shape in ${botFile} — ignoring the cached bot identity`);
88
+ }
89
+ let cached = stored?.appId === appId && typeof stored.openId === "string" ? stored.openId : undefined;
90
+ let current = cached;
91
+ // `{ appId }` with no openId reads as "no cache" at the loader — the atomic write is reused instead of
92
+ // introducing a deletion path.
93
+ const persist = (openId, failure) => {
94
+ if (openId === cached)
95
+ return;
96
+ cached = openId;
97
+ try {
98
+ saveStateFile(botFile, openId === undefined ? { appId } : { appId, openId });
99
+ }
100
+ catch (e) {
101
+ log.warn(`${label} ${failure}: ${String(e)}`);
102
+ }
103
+ };
104
+ void api.botInfo().then((me) => {
105
+ if (me.openId) {
106
+ if (current !== undefined && current !== me.openId) {
107
+ log.info(`${label} bot open_id changed (${current} → ${me.openId}) — updating the cached identity`);
108
+ }
109
+ current = me.openId;
110
+ persist(me.openId, `could not persist the bot identity to ${botFile} — the next cold start races bot/v3/info again`);
111
+ return;
112
+ }
113
+ log.warn(current === undefined
114
+ ? `${label} bot/v3/info returned no open_id — group @mention summon stays off`
115
+ : `${label} bot/v3/info returned no open_id — cached identity cleared; group @mention summon stays off`);
116
+ current = undefined;
117
+ persist(undefined, `could not clear the cached bot identity ${botFile}`);
118
+ }, (e) => log.warn(current === undefined
119
+ ? `${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`
120
+ : `${label} bot/v3/info failed; running on the cached identity (bot.json): ${String(e)}`));
121
+ return () => current;
122
+ }
84
123
  function createFeishuRuntimeFactory(profile, opts, factoryName) {
85
124
  const { appId, appSecret, route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS } = opts;
86
125
  const baseUrl = opts.apiBaseUrl ?? profile.apiBase;
87
126
  const { kind } = profile;
88
127
  const label = `[${kind}]`;
89
128
  return ({ agent, stateRoot, control }) => {
90
- // Credential checks run when serving starts, not while the authored module is imported: deployment
91
- // can inspect the module shape before secrets exist, while serving still fails before ready.
129
+ // Credential checks run when serving starts, not while the authored module is imported.
92
130
  if (!appId || !appSecret) {
93
131
  throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
94
132
  }
95
133
  const formatError = onError ?? defaultErrorMessage;
96
134
  const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
97
- // One bot/v3/info per process refreshes the bot's own open_id — the identity the default route
98
- // matches group @mentions against. The CACHED copy (bot.json, seeded synchronously once the state
99
- // home exists below) is what makes the first envelope safe: this fetch is fire-and-forget, and
100
- // under the AgentCore posture channel construction happens INSIDE the first envelope
101
- // (channels/agentcore.ts lazy construction), so a network round trip can never beat that same
102
- // envelope's own dispatch — without the seed, every cold start's FIRST group mention raced this
103
- // fetch and lost (field-observed: an explicit @ buffered as bystander context). The open_id is a
104
- // stable property of the app, so disk beats network; the only envelope a deployment ever serves
105
- // without the file is its first one, which is the deploy driver's probe — it carries no mention.
106
- // No identity at all (fresh dir, no cache, fetch pending/failed) keeps today's fail-closed
107
- // behavior: unmatched mentions buffer as context — delayed, never lost.
108
- let botOpenId;
109
- let persistBotIdentity = () => { }; // bound once the state home exists
110
- let invalidateBotIdentity = () => { }; // likewise
111
- void api.botInfo().then((me) => {
112
- if (me.openId) {
113
- if (botOpenId !== undefined && botOpenId !== me.openId) {
114
- log.info(`${label} bot open_id changed (${botOpenId} → ${me.openId}) — updating the cached identity`);
115
- }
116
- botOpenId = me.openId;
117
- persistBotIdentity(me.openId);
118
- }
119
- else {
120
- // The call SUCCEEDED and the platform reported no identity — an affirmative "there is no
121
- // bot here" (capability off, app reconfigured), not transport weather. This is the one
122
- // answer that must also INVALIDATE the cache: keeping a summon identity the platform just
123
- // declined to confirm would quietly turn fail-closed into fail-open.
124
- log.warn(botOpenId === undefined
125
- ? `${label} bot/v3/info returned no open_id — group @mention summon stays off`
126
- : `${label} bot/v3/info returned no open_id — cached identity cleared; group @mention summon stays off`);
127
- botOpenId = undefined;
128
- invalidateBotIdentity();
129
- }
130
- }, (e) =>
131
- // A FAILED call is transport weather (network, rate limit): the platform said nothing about
132
- // the identity, so a cached one keeps serving — the degradation this cache exists for.
133
- log.warn(botOpenId === undefined
134
- ? `${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`
135
- : `${label} bot/v3/info failed; running on the cached identity (bot.json): ${String(e)}`));
136
135
  void api.listAppScopes().then((scopes) => {
137
136
  const grantedScope = (name) => scopes.some((scope) => scope.name === name && scope.grantStatus === 1 && (scope.type === undefined || scope.type === "tenant"));
138
137
  if (grantedScope(FEISHU_GROUP_CONTEXT_SCOPE)) {
139
- // This scope settles the rule's whole input: it delivers the un-mentioned group messages the
140
- // channel buffers, which is also what lets it HEAR a thread. Nothing is fetched, so nothing
141
- // is pending — the only bootstrap left is social, one mention inside a thread. The read scope
142
- // is a separate, softer dependency, so it is reported separately rather than folded in.
138
+ // This scope settles the rule's whole input: it delivers the un-mentioned group messages the channel
139
+ // buffers, which is also what lets it HEAR a thread.
143
140
  log.info(`${label} group visibility: context-aware — buffered discussion enabled; bare replies work in a thread once the agent has been mentioned in it`);
144
141
  }
145
142
  else {
146
143
  log.warn(`${label} group visibility: @mentions only — ${FEISHU_GROUP_CONTEXT_SCOPE} is not granted; bare replies in the agent's threads + group context buffering are unavailable`);
147
144
  }
148
- // Reported OUTSIDE the branch above: the quoted-message read runs in every chat type and every
149
- // posture (a p2p thread's opening ask, any quoted @mention in a group), so pairing this warning
150
- // with the group scope would leave a mention-only deployment silently losing every referent.
145
+ // Reported OUTSIDE the branch above: the quoted-message read runs in every chat type and every posture (a p2p
146
+ // thread's opening ask, any quoted @mention in a group), so pairing this warning with the group scope would
147
+ // leave a mention-only deployment silently losing every referent.
151
148
  if (!scopeSatisfied(FEISHU_MESSAGE_READ_REQUEST, grantedScope)) {
152
149
  log.warn(`${label} ${FEISHU_MESSAGE_READ_SCOPE} is not granted — a message quoted by an ask cannot be read, and degrades to a marker in the prompt`);
153
150
  }
154
151
  }, (error) => log.warn(`${label} could not inspect group visibility: ${String(error)}`));
155
- const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId }));
156
- // The channel-state convention: this channel's durable home is `<stateRoot>/channels/<kind>`
157
- // (engine state at the root, channel state under `channels/<kind>/`) — derived, not an option, so
158
- // the operator's ONE state knob (FASTAGENT_STATE_DIR) can never be silently bypassed by glue.
152
+ // The channel-state convention: this channel's durable home is `<stateRoot>/channels/<kind>` (engine state at the
153
+ // root, channel state under `channels/<kind>/`).
159
154
  if (!isAbsolute(stateRoot)) {
160
155
  throw new Error(`${factoryName} requires an absolute ctx.stateRoot, got "${stateRoot}"`);
161
156
  }
162
157
  const stateHome = join(stateRoot, "channels", kind);
163
158
  ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
164
- // The cached bot identity (rationale at the botInfo block above): seed synchronously — the
165
- // factory runs to completion before any promise resolves, so botOpenId is still unset here and
166
- // the seed is what the first envelope's dispatch sees. Refresh keeps the file current.
167
- //
168
- // BOUND TO THE APP: the state home is per channel KIND, and an operator can point kept state at
169
- // a different app (a recreated app, a tenant migration). A cached identity from another app
170
- // would make THIS bot treat mentions of the OLD bot as its own summons — identity impersonation,
171
- // strictly worse than the race the cache removes — so the cache counts only when it names the
172
- // current appId. A mismatch is not noise worth warning about: the next persist IS the migration.
173
- const botFile = join(stateHome, "bot.json");
174
- const storedBot = loadStateFile(botFile);
175
- let cachedOpenId;
176
- if (storedBot !== undefined) {
177
- if (typeof storedBot.appId !== "string") {
178
- log.warn(`${label} unexpected shape in ${botFile} — ignoring the cached bot identity`);
179
- }
180
- else if (storedBot.appId === appId && typeof storedBot.openId === "string") {
181
- cachedOpenId = storedBot.openId;
182
- }
183
- }
184
- botOpenId ??= cachedOpenId;
185
- persistBotIdentity = (openId) => {
186
- if (openId === cachedOpenId)
187
- return;
188
- cachedOpenId = openId;
189
- try {
190
- saveStateFile(botFile, { appId, openId });
191
- }
192
- catch (e) {
193
- log.warn(`${label} could not persist the bot identity to ${botFile} — the next cold start races bot/v3/info again: ${String(e)}`);
194
- }
195
- };
196
- invalidateBotIdentity = () => {
197
- if (cachedOpenId === undefined)
198
- return;
199
- cachedOpenId = undefined;
200
- try {
201
- // `{ appId }` with no openId reads as "no cache" at the loader — the atomic write is reused
202
- // instead of introducing a deletion path.
203
- saveStateFile(botFile, { appId });
204
- }
205
- catch (e) {
206
- log.warn(`${label} could not clear the cached bot identity ${botFile}: ${String(e)}`);
207
- }
208
- };
159
+ const botOpenId = createBotIdentity({ api, appId, label, botFile: join(stateHome, "bot.json") });
160
+ const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId: botOpenId() }));
209
161
  const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
210
162
  /** This channel's place key for a thread (the shared store is key-agnostic). */
211
- // The SAME identity the session uses (`placeKey`) — a thread's place. Defining it twice would let a
212
- // future re-keying silently split participation from the sessions it is supposed to describe.
163
+ // The SAME identity the session uses (`placeKey`) — a thread's place.
213
164
  const threadKey = (chatId, threadId) => placeKey(kind, { chat_id: chatId, thread_id: threadId });
214
165
  const buffer = createFeishuContextBuffer(join(stateHome, "buffers.json"), label);
215
166
  const store = createTurnStore(join(stateHome, "turns.json"), {
@@ -230,9 +181,8 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
230
181
  replyTo: r.queueReplyTo,
231
182
  replyInThread: r.replyInThread,
232
183
  });
233
- // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
234
- // not just an operator log line. Take over its queue preview in place if present (else send fresh) —
235
- // leaving it pinned at "Queued" while sending a separate failure would double-post.
184
+ // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal, not just an
185
+ // operator log line.
236
186
  const notifyDropped = (r) => {
237
187
  const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
238
188
  void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
@@ -246,12 +196,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
246
196
  fromStored: ({ attempts: _a, ...intent }) => ({ ...intent, preview: undefined }),
247
197
  bufferKey: (rec) => rec.bufferKey,
248
198
  where: (rec) => `chat=${rec.chatId}`,
249
- // Queue feedback: mount that turn's preview early with a queue status. It reply-quotes the exact
250
- // source message (including p2p), then the runner mutates the SAME card/text into Thinking →
251
- // final answer. Immediate by default; with an explicit delay it mounts only if the turn is still
252
- // waiting when the timer fires and is cancelled unsent otherwise. Best-effort and post-ACK: a
253
- // failed mount is a log line, never a failed event delivery; the turn later mounts its normal
254
- // preview.
199
+ // Queue feedback: mount that turn's preview early with a queue status.
255
200
  onQueuedBehind: (rec) => {
256
201
  let fired = false;
257
202
  let settle = () => { };
@@ -281,82 +226,64 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
281
226
  },
282
227
  };
283
228
  },
284
- // Do not recall an existing queue preview (the client exposes a confusing tombstone): settle it in
285
- // place to an honest delayed status. The eventual replay mounts a fresh preview.
229
+ // Do not recall an existing queue preview (the client exposes a confusing tombstone): settle it in place to an
230
+ // honest delayed status.
286
231
  onDeferred: (rec) => {
287
232
  if (rec.preview !== undefined) {
288
233
  void settleFeishuPreview(api, targetOf(rec), rec.preview, DEFERRED_PLACEHOLDER).catch((e) => log.warn(`${label} could not update a deferred turn's queue preview: ${String(e)}`));
289
234
  }
290
235
  },
291
236
  notifyDropped,
292
- execute: (rec, discussion, onCompleted) => {
237
+ // `rec.preview`: the queue card THIS process mounted if the record waited behind another turn.
238
+ deliverAnswer: (rec, answer) => portJoin(() => settleFeishuPreview(api, targetOf(rec), rec.preview, answer)),
239
+ execute: (rec, discussion, onAnswered) => {
293
240
  // PEEK and never commit: the room still owes this discussion to its OWN memory (§8).
294
- // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
295
- // the thread's snapshot before either commits it. That fan-out loses nothing; claiming by buffer
296
- // key would instead couple otherwise-independent root sessions and require failure rollback.
241
+ // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold the room's
242
+ // snapshot before either commits it. That fan-out loses nothing; claiming by buffer key would instead couple
243
+ // otherwise-independent root sessions and require failure rollback.
297
244
  const room = rec.roomBufferKey !== undefined ? buffer.peek(rec.roomBufferKey) : undefined;
298
245
  const roomBlock = room?.text
299
246
  ? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
300
247
  : "";
301
248
  const prompt = `${roomBlock}${discussionBlock(discussion.text)}${rec.baseText}`;
302
- // Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
303
- // attachments win the slots.
249
+ // Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own attachments win the
250
+ // slots.
304
251
  const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...discussion.consumed], {
305
252
  images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
306
253
  files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
307
254
  });
308
- // Recorded at ingress (see submit) — never re-derived from the session key, which may be a
309
- // routed OPAQUE id that only looks like a place key.
255
+ // Recorded at ingress (see submit) — never re-derived from the session key, which may be a routed OPAQUE id
256
+ // that only looks like a place key.
310
257
  const parentSession = rec.parentSession;
311
- return streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
258
+ return feishuReply(feishuTurnStream(agent, rec.session, prompt, {
312
259
  api,
313
260
  chatId: rec.chatId,
314
- filesDir: join(stateHome, "files"),
261
+ filesDir: attachmentsDir(stateHome),
315
262
  label,
316
263
  appId,
317
264
  ...(parentSession !== undefined ? { parentSession } : {}),
318
- }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, onCompleted), api, targetOf(rec), formatError, rec.preview, label);
265
+ }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }), api, targetOf(rec), formatError, rec.preview, label, onAnswered);
319
266
  },
320
267
  });
321
268
  registerFeishuApi(stateRoot, kind, api);
322
269
  let seqCounter = runner.recover().reduce((max, r) => Math.max(max, r.seq), 0);
323
- // Who the agent has heard in a thread decides whether a bare message addresses it (participant
324
- // model §3), and it comes from what this channel observed — see thread-participants.ts.
270
+ // Who the agent has heard in a thread decides whether a bare message addresses it (participant model §3), and it
271
+ // comes from what this channel observed.
325
272
  let warnedUnidentified = false;
326
- /**
327
- * What this delivery contributes to thread participation, or undefined when it contributes nothing.
328
- *
329
- * ONE gate for BOTH writes (the humans observation on the way in, and the `agentSpoke` merge once
330
- * the turn is durable), and one definition of the synthetic speaker id — hand-written conditions in
331
- * two places is the drift that has already bitten this branch once. Structural facts only; see
332
- * thread-participants.ts for why configuration must not appear here.
333
- *
334
- * Feishu-specific: p2p is excluded (nothing reads those records), and a custom route may admit a
335
- * bot the default route filters out — answering one is not participation the summon rule should
336
- * act on, so such a thread keeps no record and the first human still needs the mention bootstrap.
337
- */
273
+ /** What this delivery contributes to thread participation, or undefined when it contributes nothing. */
338
274
  const heardIn = (m, sender) => {
339
275
  if (m.chat_type !== "group" || m.thread_id === undefined || sender?.sender_type !== "user")
340
276
  return undefined;
341
277
  const speakerId = senderId(sender);
342
278
  if (speakerId === undefined && !warnedUnidentified) {
343
- // Once per MOUNT — the flag lives in this channel's closure on purpose: the condition is a
344
- // property of THIS app's event configuration, so a `feishu` and a `lark` mount must each be
345
- // able to report it. A per-thread set would instead grow without bound to repeat one fact.
279
+ // Once per MOUNT — the flag lives in this channel's closure on purpose.
346
280
  warnedUnidentified = true;
347
281
  log.warn(`${label} human senders arrive with no usable id (first seen in thread ${m.thread_id}) — each counts as a distinct speaker, so affected threads permanently require an @mention until thread-participants.json is deleted`);
348
282
  }
349
- // A human whose id no tenant flavour carries still SPOKE, and no human may speak unrecorded. A
350
- // per-MESSAGE synthetic id keeps them distinct; a per-thread one would collapse every human on an
351
- // id-less tenant into one, which is the direction that barges into a crowd. Its cost is PERMANENT
352
- // — two such messages fill MAX_HUMANS and records never shed, so that thread needs an @mention
353
- // from then on and only deleting the file resets it (§3).
283
+ // A human whose id no tenant flavour carries still SPOKE, and no human may speak unrecorded.
354
284
  return { key: threadKey(m.chat_id, m.thread_id), speaker: speakerId ?? `unidentified:${m.message_id}` };
355
285
  };
356
- // Transport-neutral acceptance boundary: normalize, route, persist intent/context, enqueue. It
357
- // touches no network, so it stays synchronous inside the platform's ACK window and the delivery
358
- // dedup ring alone is enough — there is no await for a duplicate push to race through. The
359
- // minutes-long Agent turn remains fire-and-forget.
286
+ // Transport-neutral acceptance boundary: normalize, route, persist intent/context, enqueue.
360
287
  const acceptEvent = (event) => {
361
288
  const m = event.message;
362
289
  if (!m?.message_id || !m.chat_id)
@@ -371,9 +298,8 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
371
298
  return;
372
299
  const bufferKey = feishuBufferPlaceKey(normalized.conversation);
373
300
  const isHumanGroup = event.sender?.sender_type === "user" && m.chat_type === "group";
374
- // Listening is not speaking: every message the channel can see refines who takes part in its
375
- // thread, whether or not it is answered. The sender counts toward the rule immediately — a
376
- // second human speaking is exactly what makes addressing ambiguous again.
301
+ // Listening is not speaking: every message the channel can see refines who takes part in its thread, whether or
302
+ // not it is answered.
377
303
  const heard = heardIn(m, event.sender);
378
304
  if (heard)
379
305
  threadParticipants.merge(heard.key, { humans: [heard.speaker] });
@@ -400,8 +326,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
400
326
  key: resource.key,
401
327
  name: resource.name,
402
328
  }));
403
- // A write failure escapes this boundary. HTTP turns it into a 500 response; the official WS
404
- // SDK turns it into a 500 ACK frame. Both transports therefore ask the platform to re-push.
329
+ // A write failure escapes this boundary.
405
330
  buffer.push(bufferKey, {
406
331
  sender: senderLabel(event.sender) ?? "someone",
407
332
  body: bodyText,
@@ -423,33 +348,23 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
423
348
  return;
424
349
  }
425
350
  // Memory follows the place (participant model §5): one session per chat, and one per thread.
426
- // Keyed by `thread_id`, never `root_id` — the platform's root_id tracks the reply chain and can
427
- // differ between messages of ONE thread, which would split a side conversation in two.
428
351
  const routed = r.session;
429
352
  const session = routed ?? placeKey(kind, m);
430
- // Lineage is recorded ONLY for the default place-derived session. A routed session id is
431
- // OPAQUE (the route contract), and re-parsing it as a place key would let a three-segment id
432
- // like "tenant:user:alice" masquerade as a thread and inherit from "tenant:user" — a
433
- // cross-session injection. Derived from the MESSAGE (the fact this channel owns), at record
434
- // time, where routed-ness is still known; the dequeue path only reads it back.
353
+ // Lineage is recorded ONLY for the default place-derived session.
435
354
  const parentSession = routed === undefined && m.thread_id !== undefined ? placeKey(kind, { chat_id: m.chat_id }) : undefined;
436
- // Read BEFORE this turn records its own participation below, or it is always true. Keyed by the
437
- // SOURCE chat, never the answer target a route may name (§8).
355
+ // Read BEFORE this turn records its own participation below, or it is always true.
438
356
  const roomBufferKey = parentSession !== undefined && !threadParticipants.agentSpokeIn(session)
439
357
  ? feishuBufferPlaceKey({ chatId: m.chat_id })
440
358
  : undefined;
441
359
  const chatId = r.chatId ?? m.chat_id;
442
360
  const sameTarget = chatId === m.chat_id;
443
- // Answer where asked (§4): quote in a group so the ask is identifiable among many speakers,
444
- // stay plain in an ordinary direct message, and stay inside a thread whenever the question came
445
- // from one — a direct message's thread is a place too, and relocating out of it is the silent
446
- // move the model refuses.
361
+ // Answer where asked (§4): quote in a group so the ask is identifiable among many speakers, stay plain in an
362
+ // ordinary direct message, and stay inside a thread whenever the question came from one — a direct message's
363
+ // thread is a place too, and relocating out of it is the silent move the model refuses.
447
364
  const replyTo = sameTarget && (m.chat_type === "group" || m.thread_id !== undefined) ? m.message_id : undefined;
448
365
  const replyInThread = replyTo !== undefined && m.thread_id !== undefined ? true : undefined;
449
366
  const queueReplyTo = sameTarget ? m.message_id : undefined;
450
- // Explicit user stop: a control action, never a turn — it must not queue behind the run it
451
- // stops. Mentions arrive as @name tokens; strip them before matching the bare word. Record the
452
- // message id so a platform re-push doesn't double-abort or double-notify.
367
+ // Explicit user stop: a control action, never a turn — it must not queue behind the run it stops.
453
368
  if (isStopText(normalized.content.text.replace(/@\S+/g, " "))) {
454
369
  seen.add(m.message_id);
455
370
  sideTasks.track(dispatchStop(control, session, label)
@@ -477,46 +392,19 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
477
392
  replyTo,
478
393
  queueReplyTo,
479
394
  replyInThread,
480
- // A quote is the user explicitly pointing at something that may predate this session
481
- // (§8 rung 2), so it is always loaded.
482
- //
483
- // There used to be an exception: skip it inside a thread the agent had already answered in,
484
- // since the session would hold it. That needed a second fact — did the channel RECEIVE the
485
- // messages in between? — which depends on a scope that changes over time, while the record it
486
- // was read against is durable. Every attempt to gate it correctly failed in the same
487
- // direction (a silently missing quote), for an optimisation worth one `getMessage` on a
488
- // quote-reply inside an active thread. Loading it costs a call and some text the session may
489
- // already have; it also pins WHICH message is being answered, which a long thread benefits
490
- // from anyway.
395
+ // A quote is the user explicitly pointing at something that may predate this session (§8 rung 2), so it is
396
+ // always loaded.
491
397
  parentId: m.parent_id,
492
398
  ...(parentSession !== undefined ? { parentSession } : {}),
493
399
  ...(roomBufferKey !== undefined ? { roomBufferKey } : {}),
494
400
  images,
495
401
  files,
496
402
  }, true);
497
- // Answering inside a thread makes the agent a participant of it, which is what lets the NEXT
498
- // bare message address it without a mention (§3).
499
- //
500
- // `r.session === undefined` is what makes this fact mean what its reader assumes. Participation
501
- // is keyed by THREAD while the memory it stands in for is keyed by SESSION, and those agree only
502
- // when the session is derived from the place. A route supplying its own (the scaffold's
503
- // `session: user:<open_id>` example) can put two people's turns in one thread into different
504
- // sessions — recording `agentSpoke` from one of them would tell the summon rule the agent took
505
- // part in a conversation it cannot remember. So the flag records "the agent answered into THIS
506
- // THREAD'S session". Such a thread keeps a bystander record and needs the ordinary mention to
507
- // bootstrap if the route is later dropped. `group` matches the observation above so the record is
508
- // never half-written.
509
- //
510
- // Recorded only once the intent is durable: `submit` can throw, and a redelivery must still see
511
- // the thread as the agent has actually left it. A later delivery failure does not undo it —
512
- // entering the conversation is the intent, not the send.
513
- // Reuses `heardIn` from the way in, so both writes share ONE gate by construction. The extra
514
- // conditions are about the ANSWER, not the speaker: it must land in this thread (`sameTarget`,
515
- // `replyInThread`) and in the place's own session, or the flag would claim a memory that never
516
- // held the turn.
403
+ // Answering inside a thread makes the agent a participant of it, which is what lets the NEXT bare message
404
+ // address it without a mention (§3).
517
405
  if (heard && replyInThread === true && sameTarget && r.session === undefined) {
518
- // Both halves in ONE merge, like Slack's: a record that needed an earlier merge to survive
519
- // could otherwise say "answered here, heard nobody" — which admits bare messages forever.
406
+ // Both halves in ONE merge, like Slack's: a record that needed an earlier merge to survive could otherwise
407
+ // say "answered here, heard nobody".
520
408
  threadParticipants.merge(heard.key, { agentSpoke: true, humans: [heard.speaker] });
521
409
  }
522
410
  };
@@ -528,9 +416,9 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
528
416
  const { kind, envPrefix } = profile;
529
417
  const label = `[${kind}]`;
530
418
  if (!encryptKey) {
531
- // Said once at wiring, not per request: without an encrypt key events arrive in plaintext and
532
- // carry no signature, so the freshness window below never runs and a captured body replays for
533
- // as long as the verification token lives.
419
+ // Said once at wiring, not per request: without an encrypt key events arrive in plaintext and carry no signature,
420
+ // so the freshness window below never runs and a captured body replays for as long as the verification token
421
+ // lives.
534
422
  log.warn(`${label} no ${envPrefix}_ENCRYPT_KEY: events are accepted unsigned, with no replay window — set one in the console to enable it`);
535
423
  }
536
424
  const handler = async (req) => {
@@ -560,10 +448,8 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
560
448
  signature: req.headers.get("x-lark-signature") ?? "",
561
449
  };
562
450
  if (sig.signature) {
563
- // Freshness BEFORE the signature: the signature covers the timestamp but proves nothing about
564
- // it, so without a window a captured body + its three x-lark-* headers replays forever. The
565
- // `seen` ring is not that defence — it is bounded, and past its rollover a replay re-runs the
566
- // turn (a re-sent message, a re-fired tool).
451
+ // Freshness BEFORE the signature: the signature covers the timestamp but proves nothing about it, so without
452
+ // a window a captured body + its three x-lark-* headers replays forever.
567
453
  if (!signatureIsFresh(sig.timestamp, MAX_SIGNATURE_AGE_S)) {
568
454
  log.warn(`${label} rejected an event: X-Lark-Request-Timestamp outside the ±${MAX_SIGNATURE_AGE_S / 3600} h replay window`);
569
455
  return text("stale signature\n", 401);
@@ -13,6 +13,8 @@
13
13
  * or hide its still-readable siblings.
14
14
  */
15
15
  import type { Agent, AgentEvent } from "../../agent.ts";
16
+ import type * as Stream from "effect/Stream";
17
+ import type { PortFailure } from "../../effect-port.ts";
16
18
  import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
17
19
  import type { FeishuBufferedRef } from "./context-buffer.ts";
18
20
  import type { FeishuApi } from "./feishu-api.ts";
@@ -54,9 +56,8 @@ export interface FeishuTurnAttachments {
54
56
  };
55
57
  }
56
58
  /**
57
- * Run one turn: resolve its inputs, then stream agent.invoke with the shared busy-wait
58
- * (invoke-turn-kit — `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
59
- * primary-input failure surfaces as a `failed` event (never a silent drop).
59
+ * Run one turn: resolve its inputs, then ask the agent (invoke-turn-kit — a primary-input failure
60
+ * surfaces as a `failed` event, never a silent drop).
60
61
  */
61
- export declare function invokeFeishuTurn(agent: Agent, session: string, text: string, transport: FeishuTurnTransport, attachments: FeishuTurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
62
+ export declare function feishuTurnStream(agent: Agent, session: string, text: string, transport: FeishuTurnTransport, attachments: FeishuTurnAttachments, busyRetry?: BusyRetry): Stream.Stream<AgentEvent, PortFailure>;
62
63
  export {};