@fastagent-sh/fastagent 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +3 -3
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +8 -11
  10. package/dist/channels/feishu/context-buffer.js +6 -67
  11. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  12. package/dist/channels/feishu/feishu-api.js +20 -2
  13. package/dist/channels/feishu/feishu.d.ts +28 -16
  14. package/dist/channels/feishu/feishu.js +210 -188
  15. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  16. package/dist/channels/feishu/invoke-turn.js +10 -62
  17. package/dist/channels/feishu/model.d.ts +2 -1
  18. package/dist/channels/feishu/normalize.d.ts +2 -1
  19. package/dist/channels/feishu/owned-threads.d.ts +2 -6
  20. package/dist/channels/feishu/parse.d.ts +1 -1
  21. package/dist/channels/feishu/preview.d.ts +4 -7
  22. package/dist/channels/feishu/preview.js +24 -140
  23. package/dist/channels/feishu/register-app.d.ts +2 -1
  24. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  25. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  26. package/dist/channels/feishu/setup-mode.js +2 -0
  27. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  28. package/dist/channels/feishu/ws-ingress.js +136 -0
  29. package/dist/channels/github/github.js +8 -6
  30. package/dist/channels/http.d.ts +14 -0
  31. package/dist/channels/http.js +35 -2
  32. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  33. package/dist/channels/invoke-turn-kit.js +87 -0
  34. package/dist/channels/lark/lark.d.ts +4 -2
  35. package/dist/channels/lark/lark.js +4 -1
  36. package/dist/channels/lark/onboard.d.ts +8 -4
  37. package/dist/channels/lark/onboard.js +8 -0
  38. package/dist/channels/preview-kit.d.ts +109 -0
  39. package/dist/channels/preview-kit.js +183 -0
  40. package/dist/channels/seen.d.ts +5 -0
  41. package/dist/channels/seen.js +35 -0
  42. package/dist/channels/slack/bot-auth.d.ts +15 -0
  43. package/dist/channels/slack/bot-auth.js +146 -0
  44. package/dist/channels/slack/config-api.d.ts +60 -0
  45. package/dist/channels/slack/config-api.js +149 -0
  46. package/dist/channels/slack/context-buffer.d.ts +24 -0
  47. package/dist/channels/slack/context-buffer.js +37 -0
  48. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  49. package/dist/channels/slack/invoke-turn.js +63 -0
  50. package/dist/channels/slack/manifest.d.ts +49 -0
  51. package/dist/channels/slack/manifest.js +69 -0
  52. package/dist/channels/slack/model.d.ts +67 -0
  53. package/dist/channels/slack/model.js +2 -0
  54. package/dist/channels/slack/onboard.d.ts +41 -0
  55. package/dist/channels/slack/onboard.js +120 -0
  56. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  57. package/dist/channels/slack/onboarding-state.js +69 -0
  58. package/dist/channels/slack/owned-threads.d.ts +6 -0
  59. package/dist/channels/slack/owned-threads.js +43 -0
  60. package/dist/channels/slack/parse.d.ts +23 -0
  61. package/dist/channels/slack/parse.js +81 -0
  62. package/dist/channels/slack/preview.d.ts +24 -0
  63. package/dist/channels/slack/preview.js +359 -0
  64. package/dist/channels/slack/reaction.d.ts +24 -0
  65. package/dist/channels/slack/reaction.js +62 -0
  66. package/dist/channels/slack/register-webhook.d.ts +10 -0
  67. package/dist/channels/slack/register-webhook.js +49 -0
  68. package/dist/channels/slack/scaffold/channel.ts +33 -0
  69. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  70. package/dist/channels/slack/setup-server.d.ts +17 -0
  71. package/dist/channels/slack/setup-server.js +103 -0
  72. package/dist/channels/slack/slack-api.d.ts +77 -0
  73. package/dist/channels/slack/slack-api.js +415 -0
  74. package/dist/channels/slack/slack.d.ts +58 -0
  75. package/dist/channels/slack/slack.js +451 -0
  76. package/dist/channels/slack/welcomed.d.ts +5 -0
  77. package/dist/channels/slack/welcomed.js +32 -0
  78. package/dist/channels/state.js +3 -3
  79. package/dist/channels/stop-command.d.ts +6 -0
  80. package/dist/channels/stop-command.js +36 -0
  81. package/dist/channels/tasks.d.ts +13 -0
  82. package/dist/channels/tasks.js +10 -0
  83. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  84. package/dist/channels/telegram/context-buffer.js +6 -85
  85. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  86. package/dist/channels/telegram/invoke-turn.js +11 -58
  87. package/dist/channels/telegram/preview.d.ts +4 -7
  88. package/dist/channels/telegram/preview.js +24 -142
  89. package/dist/channels/telegram/telegram.js +23 -9
  90. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  91. package/dist/channels/{feishu/text.js → text.js} +1 -1
  92. package/dist/channels/turn-queue.js +1 -1
  93. package/dist/channels/turn-store.d.ts +1 -1
  94. package/dist/channels/turn-store.js +2 -3
  95. package/dist/cli/add-feishu.d.ts +27 -0
  96. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  97. package/dist/cli/add-slack.d.ts +10 -0
  98. package/dist/cli/add-slack.js +204 -0
  99. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  100. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  101. package/dist/cli/commands/add.d.ts +4 -0
  102. package/dist/cli/commands/add.js +152 -20
  103. package/dist/cli/commands/attach.d.ts +82 -0
  104. package/dist/cli/commands/attach.js +559 -0
  105. package/dist/cli/commands/chat.d.ts +1 -0
  106. package/dist/cli/commands/chat.js +6 -1
  107. package/dist/cli/commands/deploy.d.ts +2 -0
  108. package/dist/cli/commands/deploy.js +78 -22
  109. package/dist/cli/commands/dev.js +11 -5
  110. package/dist/cli/commands/fire.js +3 -3
  111. package/dist/cli/commands/info.js +2 -2
  112. package/dist/cli/commands/invoke.js +2 -3
  113. package/dist/cli/commands/login.js +14 -25
  114. package/dist/cli/commands/models.js +1 -1
  115. package/dist/cli/commands/start.js +15 -8
  116. package/dist/cli/commands/tool.js +2 -1
  117. package/dist/cli/fail.d.ts +0 -2
  118. package/dist/cli/fail.js +1 -1
  119. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  120. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  121. package/dist/cli/kernel.d.ts +3 -2
  122. package/dist/cli/models-view.d.ts +21 -0
  123. package/dist/cli/models-view.js +66 -0
  124. package/dist/cli/program.js +79 -21
  125. package/dist/cli/serve.d.ts +38 -18
  126. package/dist/cli/serve.js +197 -39
  127. package/dist/cli/shared.d.ts +26 -6
  128. package/dist/cli/shared.js +163 -33
  129. package/dist/collect.d.ts +14 -3
  130. package/dist/collect.js +24 -0
  131. package/dist/core.d.ts +3 -1
  132. package/dist/core.js +2 -0
  133. package/dist/deploy/docker/plan.d.ts +2 -2
  134. package/dist/deploy/docker/plan.js +8 -6
  135. package/dist/deploy/fly/plan.d.ts +3 -1
  136. package/dist/deploy/fly/plan.js +12 -7
  137. package/dist/deploy/fly/run.d.ts +5 -4
  138. package/dist/deploy/fly/run.js +16 -5
  139. package/dist/deploy/preflight.d.ts +10 -4
  140. package/dist/deploy/preflight.js +53 -9
  141. package/dist/deploy/railway/plan.d.ts +3 -1
  142. package/dist/deploy/railway/plan.js +12 -4
  143. package/dist/deploy/railway/run.d.ts +5 -4
  144. package/dist/deploy/railway/run.js +15 -5
  145. package/dist/deploy/runner.d.ts +2 -1
  146. package/dist/deploy/secrets.d.ts +2 -1
  147. package/dist/deploy/secrets.js +23 -3
  148. package/dist/dev-supervisor.d.ts +0 -2
  149. package/dist/dev-supervisor.js +7 -3
  150. package/dist/engines/pi/channel.d.ts +22 -16
  151. package/dist/engines/pi/channel.js +90 -60
  152. package/dist/engines/pi/chat.d.ts +4 -16
  153. package/dist/engines/pi/chat.js +8 -261
  154. package/dist/engines/pi/config.d.ts +14 -9
  155. package/dist/engines/pi/config.js +33 -18
  156. package/dist/engines/pi/create.d.ts +22 -2
  157. package/dist/engines/pi/create.js +20 -14
  158. package/dist/engines/pi/harness.d.ts +49 -0
  159. package/dist/engines/pi/harness.js +90 -2
  160. package/dist/engines/pi/invoke.d.ts +55 -2
  161. package/dist/engines/pi/invoke.js +304 -19
  162. package/dist/engines/pi/login.d.ts +11 -0
  163. package/dist/engines/pi/login.js +16 -4
  164. package/dist/engines/pi/models.d.ts +56 -10
  165. package/dist/engines/pi/models.js +61 -23
  166. package/dist/engines/pi/session-builder.d.ts +16 -0
  167. package/dist/engines/pi/session-builder.js +308 -0
  168. package/dist/engines/pi/session-control.d.ts +50 -0
  169. package/dist/engines/pi/session-control.js +604 -0
  170. package/dist/engines/pi/sessions.d.ts +17 -2
  171. package/dist/engines/pi/sessions.js +9 -0
  172. package/dist/engines/pi/tool-context.d.ts +18 -11
  173. package/dist/engines/pi/tool-context.js +3 -9
  174. package/dist/engines/pi/tool.d.ts +6 -8
  175. package/dist/engines/pi/tool.js +6 -1
  176. package/dist/engines/pi/wake-tool.d.ts +0 -3
  177. package/dist/engines/pi/wake-tool.js +9 -7
  178. package/dist/engines/pi/workspace.d.ts +52 -0
  179. package/dist/engines/pi/workspace.js +72 -15
  180. package/dist/feishu.d.ts +1 -1
  181. package/dist/feishu.js +1 -1
  182. package/dist/host/node.d.ts +23 -6
  183. package/dist/host/node.js +5 -4
  184. package/dist/index.d.ts +1 -0
  185. package/dist/index.js +1 -0
  186. package/dist/lark.d.ts +1 -1
  187. package/dist/lark.js +1 -1
  188. package/dist/observe.js +3 -0
  189. package/dist/pi.d.ts +5 -4
  190. package/dist/pi.js +2 -1
  191. package/dist/scaffold/add-channel.d.ts +19 -6
  192. package/dist/scaffold/add-channel.js +126 -17
  193. package/dist/scaffold/templates/fastagent.config.mjs +4 -3
  194. package/dist/schedule/wakeups.d.ts +0 -3
  195. package/dist/schedule/wakeups.js +1 -1
  196. package/dist/session-remote.d.ts +53 -0
  197. package/dist/session-remote.js +336 -0
  198. package/dist/session.d.ts +265 -0
  199. package/dist/session.js +37 -0
  200. package/dist/slack.d.ts +2 -0
  201. package/dist/slack.js +2 -0
  202. package/dist/tunnel.d.ts +7 -4
  203. package/dist/tunnel.js +21 -10
  204. package/package.json +19 -7
  205. package/dist/channels/feishu/seen.d.ts +0 -5
  206. package/dist/channels/feishu/seen.js +0 -47
  207. package/dist/cli-add-feishu.d.ts +0 -8
  208. package/dist/cli-models.d.ts +0 -11
  209. package/dist/cli-models.js +0 -20
@@ -1,22 +1,15 @@
1
- import type { ChannelModule } from "../../host/node.ts";
1
+ import type { ChannelModule, LongConnectionChannelModule } from "../../host/node.ts";
2
2
  import { type FeishuCloudProfile } from "./cloud.ts";
3
3
  import { type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, defaultFeishuRoute, feishuEnvelope } from "./parse.ts";
4
4
  import { type FeishuFailure } from "./preview.ts";
5
+ import { connectFeishuWs } from "./ws-ingress.ts";
5
6
  export { defaultFeishuRoute, feishuEnvelope };
6
7
  export type { FeishuFailure, FeishuMessage, FeishuMessageEvent, FeishuRoute };
7
- export interface FeishuChannelOptions {
8
+ interface FeishuChannelBaseOptions {
8
9
  /** App ID (developer console → Credentials & Basic Info). */
9
10
  appId: string;
10
- /** App Secret (same page) — drives the tenant_access_token the replies ride on. */
11
+ /** App Secret (same page) — drives both ingress authentication and outbound API calls. */
11
12
  appSecret: string;
12
- /** Verification Token (console → Events & Callbacks) — authenticates PLAINTEXT events. */
13
- verificationToken: string;
14
- /** Encrypt Key (same page, optional there — recommended): when set, ordinary events arrive encrypted
15
- * and signed; this channel then REFUSES plaintext events (fail closed — accepting both would let a
16
- * forger skip the stronger check). Feishu explicitly excludes the encrypted `url_verification`
17
- * handshake from event signature verification; that narrow path is authenticated after decryption
18
- * by the Verification Token. Must match the console exactly. */
19
- encryptKey?: string;
20
13
  /** Direct-message context + delivery policy. `threaded` (default) gives every top-level p2p message
21
14
  * its own session, creates a platform thread for the answer, and routes later thread messages back
22
15
  * by root message id. `continuous` keeps one session per p2p chat and sends ordinary unquoted replies. */
@@ -33,14 +26,33 @@ export interface FeishuChannelOptions {
33
26
  * log). Return a string to send it, or undefined/"" to stay silent. Default: a neutral message keyed
34
27
  * on `retryable`. A developer's own bot can surface the raw details, e.g. `(f) => `⚠️ ${f.details}``. */
35
28
  onError?: (failed: FeishuFailure) => string | undefined;
36
- /** API origin override (tests / self-hosted gateways). The kind fixes the default
37
- * `feishuChannel` → `https://open.feishu.cn`, `larkChannel` `https://open.larksuite.com`. */
29
+ /** API origin override (tests / self-hosted gateways). Feishu factories default to
30
+ * `https://open.feishu.cn`; Lark factories default to `https://open.larksuite.com`. Named to match
31
+ * the other channels (telegram/slack). */
32
+ apiBaseUrl?: string;
33
+ /** @deprecated Alias of {@link apiBaseUrl}; kept for existing feishu/lark channel files. */
38
34
  baseUrl?: string;
39
35
  /** How long (ms) a turn waits before its reply-quoted "⏳ Queued" card mounts. Defaults to 0
40
36
  * (immediate); the same card is later taken over by the live preview/final answer. */
41
37
  queueNoticeDelayMs?: number;
42
38
  }
43
- /** Build the canonical Feishu channel. Lark calls the internal profile-bound builder below. */
39
+ export interface FeishuChannelOptions extends FeishuChannelBaseOptions {
40
+ /** Verification Token for Request-URL authentication. */
41
+ verificationToken: string;
42
+ /** Optional webhook Encrypt Key. When set, plaintext events are rejected. */
43
+ encryptKey?: string;
44
+ }
45
+ export type FeishuWebSocketChannelOptions = FeishuChannelBaseOptions & {
46
+ verificationToken?: never;
47
+ encryptKey?: never;
48
+ };
49
+ /** Build the canonical Feishu Request-URL webhook channel. */
44
50
  export declare function feishuChannel(opts: FeishuChannelOptions): ChannelModule;
45
- /** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
46
- export declare function buildFeishuChannel(profile: FeishuCloudProfile, { appId, appSecret, verificationToken, encryptKey, directMessageSession, groupMessageSession, route, onError, baseUrl, queueNoticeDelayMs, }: FeishuChannelOptions, factoryName: string): ChannelModule;
51
+ /** Build the canonical Feishu WebSocket long-connection channel. */
52
+ export declare function feishuWebSocketChannel(opts: FeishuWebSocketChannelOptions): LongConnectionChannelModule;
53
+ /** Internal compatibility seams: protocol behavior comes from Feishu; the profile binds cloud edges. */
54
+ interface FeishuWebSocketChannelDeps {
55
+ connectWs?: typeof connectFeishuWs;
56
+ }
57
+ export declare function buildFeishuChannel(profile: FeishuCloudProfile, opts: FeishuChannelOptions, factoryName: string): ChannelModule;
58
+ export declare function buildFeishuWebSocketChannel(profile: FeishuCloudProfile, opts: FeishuWebSocketChannelOptions, factoryName: string, deps?: FeishuWebSocketChannelDeps): LongConnectionChannelModule;
@@ -1,18 +1,21 @@
1
1
  /**
2
- * Canonical Feishu bot-channel engine: verify webhook answer url_verification → dedup → route → persist → run
3
- * the turn → stream a live card → ACK 200. Feishu (open.feishu.cn) is the reference cloud. Lark
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
4
  * international binds this engine through an explicit compatibility profile because its control plane
5
5
  * trails Feishu; protocol reuse does not make Lark the design center.
6
6
  *
7
7
  * The channel kind remains the unit of route, env namespace, state home, logs, and onboarding, so one
8
- * workspace may mount both without sharing state. Webhook mode only; WebSocket long connection needs
9
- * the official SDK and a non-HTTP ingress seam. See docs/feishu.md.
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.
10
10
  */
11
11
  import { isAbsolute, join } from "node:path";
12
12
  import { log } from "../../log.js";
13
13
  import { readBodyCapped } from "../body.js";
14
14
  import { text } from "../respond.js";
15
+ import { createSeenRing } from "../seen.js";
16
+ import { createTaskTracker } from "../tasks.js";
15
17
  import { ensureStateHome } from "../state.js";
18
+ import { dispatchStop, isStopText } from "../stop-command.js";
16
19
  import { createTurnQueue } from "../turn-queue.js";
17
20
  import { createTurnStore } from "../turn-store.js";
18
21
  import { FEISHU_CLOUD } from "./cloud.js";
@@ -22,9 +25,10 @@ import { invokeFeishuTurn } from "./invoke-turn.js";
22
25
  import { createFeishuApi } from "./feishu-api.js";
23
26
  import { normalizeFeishuMessage } from "./normalize.js";
24
27
  import { createOwnedFeishuThreads } from "./owned-threads.js";
28
+ import { FEISHU_GROUP_CONTEXT_SCOPE } from "./setup-mode.js";
25
29
  import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderLabel, } from "./parse.js";
26
30
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
27
- import { createSeenRing } from "./seen.js";
31
+ import { connectFeishuWs } from "./ws-ingress.js";
28
32
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
29
33
  export { defaultFeishuRoute, feishuEnvelope };
30
34
  /** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
@@ -58,30 +62,33 @@ function isStoredFeishuTurn(t) {
58
62
  refs(r.files) &&
59
63
  typeof r.attempts === "number");
60
64
  }
61
- /** Build the canonical Feishu channel. Lark calls the internal profile-bound builder below. */
65
+ /** Build the canonical Feishu Request-URL webhook channel. */
62
66
  export function feishuChannel(opts) {
63
67
  return buildFeishuChannel(FEISHU_CLOUD, opts, feishuChannel.name);
64
68
  }
65
- /** Internal compatibility seam: protocol behavior comes from Feishu; the profile binds cloud edges. */
66
- export function buildFeishuChannel(profile, { appId, appSecret, verificationToken, encryptKey, directMessageSession = "threaded", groupMessageSession = "threaded", route, onError, baseUrl = profile.apiBase, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, }, factoryName) {
67
- const { kind, envPrefix } = profile;
68
- const label = `[${kind}]`;
69
- // All three are mandatory: without the app credentials no reply can be sent; without the verification
70
- // token a plaintext-mode endpoint would accept forged events. Fail at construction (startup), not
71
- // silently at the first event.
72
- if (!appId || !appSecret) {
73
- throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
74
- }
75
- if (!verificationToken) {
76
- throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks; an unset one accepts forged events)`);
77
- }
78
- if (directMessageSession !== "continuous" && directMessageSession !== "threaded") {
69
+ /** Build the canonical Feishu WebSocket long-connection channel. */
70
+ export function feishuWebSocketChannel(opts) {
71
+ return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
72
+ }
73
+ function validateSessionOptions(opts, factoryName) {
74
+ if (opts.directMessageSession !== undefined && !["continuous", "threaded"].includes(opts.directMessageSession)) {
79
75
  throw new Error(`${factoryName} directMessageSession must be "continuous" or "threaded"`);
80
76
  }
81
- if (groupMessageSession !== "continuous" && groupMessageSession !== "threaded") {
77
+ if (opts.groupMessageSession !== undefined && !["continuous", "threaded"].includes(opts.groupMessageSession)) {
82
78
  throw new Error(`${factoryName} groupMessageSession must be "continuous" or "threaded"`);
83
79
  }
84
- return ({ agent, stateRoot }) => {
80
+ }
81
+ function createFeishuRuntimeFactory(profile, opts, factoryName) {
82
+ const { appId, appSecret, directMessageSession = "threaded", groupMessageSession = "threaded", route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS, } = opts;
83
+ const baseUrl = opts.apiBaseUrl ?? opts.baseUrl ?? profile.apiBase;
84
+ const { kind } = profile;
85
+ const label = `[${kind}]`;
86
+ return ({ agent, stateRoot, control }) => {
87
+ // Credential checks run when serving starts, not while the authored module is imported: deployment
88
+ // can inspect the module shape before secrets exist, while serving still fails before ready.
89
+ if (!appId || !appSecret) {
90
+ throw new Error(`${factoryName} requires appId + appSecret (developer console → Credentials & Basic Info)`);
91
+ }
85
92
  const formatError = onError ?? defaultErrorMessage;
86
93
  const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
87
94
  // One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
@@ -92,6 +99,17 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
92
99
  if (!botOpenId)
93
100
  log.warn(`${label} bot/v3/info returned no open_id — group @mention summon stays off`);
94
101
  }, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
102
+ void api.listAppScopes().then((scopes) => {
103
+ const contextAware = scopes.some((scope) => scope.name === FEISHU_GROUP_CONTEXT_SCOPE &&
104
+ scope.grantStatus === 1 &&
105
+ (scope.type === undefined || scope.type === "tenant"));
106
+ if (contextAware) {
107
+ log.info(`${label} group visibility: context-aware — bare managed-thread replies + buffered discussion enabled`);
108
+ }
109
+ else {
110
+ log.warn(`${label} group visibility: @mentions only — ${FEISHU_GROUP_CONTEXT_SCOPE} is not granted; bare managed-thread replies + group context buffering are unavailable`);
111
+ }
112
+ }, (error) => log.warn(`${label} could not inspect group visibility: ${String(error)}`));
95
113
  const decide = route ?? ((event) => defaultFeishuRoute(event, { botOpenId }));
96
114
  // The channel-state convention: this channel's durable home is `<stateRoot>/channels/<kind>`
97
115
  // (engine state at the root, channel state under `channels/<kind>/`) — derived, not an option, so
@@ -109,6 +127,8 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
109
127
  order: (a, b) => a.seq - b.seq,
110
128
  });
111
129
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
130
+ // Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
131
+ const sideTasks = createTaskTracker();
112
132
  const toStored = (r) => {
113
133
  const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
114
134
  return { ...intent, attempts: 0 };
@@ -226,7 +246,7 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
226
246
  // when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
227
247
  const submit = (rec, persist) => {
228
248
  if (persist) {
229
- store.add(toStored(rec)); // failed write → webhook 500 → platform redelivery
249
+ store.add(toStored(rec)); // failed write → HTTP/WS 500 → platform re-push
230
250
  seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
231
251
  }
232
252
  queue.accept(rec);
@@ -250,104 +270,20 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
250
270
  const bufferKey = intent.bufferKey ?? `${intent.chatId}:legacy-turn:${intent.id}`;
251
271
  submit({ ...intent, bufferKey, preview: undefined }, false);
252
272
  }
253
- const handler = async (req) => {
254
- if (req.method !== "POST")
255
- return text("POST only\n", 405);
256
- const body = await readBodyCapped(req, MAX_EVENT_BYTES);
257
- if ("tooLarge" in body)
258
- return text("payload too large\n", 413);
259
- let outer;
260
- try {
261
- outer = JSON.parse(body.text);
262
- if (typeof outer !== "object" || outer === null)
263
- throw new Error("not an object");
264
- }
265
- catch {
266
- return text("invalid json\n", 400);
267
- }
268
- // ── Verification. Two modes, decided by the CONSOLE's Encrypt Key setting, mirrored here. ──────
269
- let envelope;
270
- if (typeof outer.encrypt === "string") {
271
- if (!encryptKey) {
272
- log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
273
- return text("encrypt key not configured\n", 400);
274
- }
275
- const sig = {
276
- timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
277
- nonce: req.headers.get("x-lark-request-nonce") ?? "",
278
- signature: req.headers.get("x-lark-signature") ?? "",
279
- };
280
- // Ordinary encrypted events MUST verify the signature over the raw body before decryption.
281
- // Feishu's documented exception is Request URL verification: its encrypted challenge carries
282
- // no event-signature headers, so it is decrypted first and admitted ONLY when its type is
283
- // url_verification; the common constant-time Token check below then authenticates it.
284
- if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
285
- log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
286
- return text("invalid signature\n", 401);
287
- }
288
- try {
289
- envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
290
- }
291
- catch {
292
- if (!sig.signature) {
293
- log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
294
- return text("invalid encrypted payload\n", 401);
295
- }
296
- return text("invalid encrypted payload\n", 400);
297
- }
298
- if (!sig.signature && envelope.type !== "url_verification") {
299
- log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
300
- return text("invalid signature\n", 401);
301
- }
302
- }
303
- else {
304
- if (encryptKey) {
305
- // With an Encrypt Key configured, a PLAINTEXT event can only be a forgery (or a console
306
- // mismatch — surfaced in the log): accepting it would let a sender skip the signature.
307
- log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
308
- return text("plaintext events not accepted\n", 401);
309
- }
310
- envelope = outer;
311
- }
312
- // The Verification Token authenticates plaintext mode and the platform-documented unsigned,
313
- // encrypted URL challenge; on signed encrypted events it is defense in depth. V2 events carry it
314
- // in header.token, while url_verification carries it at the top level. Fail closed when absent.
315
- const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
316
- (typeof envelope.header?.token === "string"
317
- ? envelope.header.token
318
- : undefined);
319
- if (!token || !timingSafeEqualStr(token, verificationToken)) {
320
- // Loud on purpose: the send side gets an opaque 401 and the platform just retries — this line is
321
- // the operator's ONLY signal that LARK_VERIFICATION_TOKEN does not match the console.
322
- log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
323
- return text("invalid token\n", 401);
324
- }
325
- // ── The console's URL-verification challenge (fires when the operator saves the Request URL). ──
326
- if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
327
- // The console fires this when the operator saves the Request URL; without this line a PASSING
328
- // handshake is invisible and "did the challenge even arrive?" becomes guesswork.
329
- log.info(`${label} answered the console's url_verification challenge`);
330
- return Response.json({ challenge: envelope.challenge });
331
- }
332
- // ── Events. Only im.message.receive_v1 is consumed; everything else is ACKed and dropped
333
- // (a non-2xx would just make the platform retry an event this channel will never act on). ──────
334
- const header = envelope.header;
335
- if (header?.event_type !== "im.message.receive_v1") {
336
- log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
337
- return new Response(null, { status: 200 });
338
- }
339
- const event = (envelope.event ?? {});
273
+ // Transport-neutral acceptance boundary. It performs only the fast pre-ACK work: normalize,
274
+ // route, persist intent/context, and enqueue. The minutes-long Agent turn remains fire-and-forget.
275
+ const acceptEvent = (event) => {
340
276
  const m = event.message;
341
277
  if (!m?.message_id || !m.chat_id)
342
- return new Response(null, { status: 200 });
278
+ return;
343
279
  if (seen.has(m.message_id)) {
344
280
  log.debug(`${label} duplicate push for message ${m.message_id} — already persisted, skipping`);
345
- return new Response(null, { status: 200 });
281
+ return;
346
282
  }
347
283
  let r = decide(event);
348
284
  const normalized = normalizeFeishuMessage(event);
349
285
  if (!normalized)
350
- return new Response(null, { status: 200 });
286
+ return;
351
287
  const bufferKey = feishuBufferPlaceKey(normalized.conversation);
352
288
  const isHumanGroup = event.sender?.sender_type === "user" && m.chat_type === "group";
353
289
  const managedThread = groupMessageSession === "threaded" &&
@@ -355,9 +291,6 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
355
291
  m.thread_id !== undefined &&
356
292
  m.root_id !== undefined &&
357
293
  ownedThreads.has(m.chat_id, m.root_id);
358
- // In an Agent-created thread, a bare user continuation still summons. Any explicit mention changes
359
- // that intent: only defaultFeishuRoute's structural @THIS-bot match summons; @other-only discussion
360
- // is buffered like unsummoned group context. A custom route remains fully authoritative.
361
294
  if (!r && route === undefined && managedThread && !normalized.content.hasMentions)
362
295
  r = {};
363
296
  if (!r) {
@@ -375,7 +308,8 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
375
308
  key: resource.key,
376
309
  name: resource.name,
377
310
  }));
378
- // Pre-ACK persistence: a write failure rejects the webhook so the platform can redeliver.
311
+ // A write failure escapes this boundary. HTTP turns it into a 500 response; the official WS
312
+ // SDK turns it into a 500 ACK frame. Both transports therefore ask the platform to re-push.
379
313
  buffer.push(bufferKey, {
380
314
  sender: senderLabel(event.sender) ?? "someone",
381
315
  body: bodyText,
@@ -384,7 +318,7 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
384
318
  files: files.length ? files : undefined,
385
319
  images: images.length ? images : undefined,
386
320
  });
387
- seen.add(m.message_id); // post-persist: a redelivery cannot duplicate buffered context
321
+ seen.add(m.message_id);
388
322
  log.debug(`${label} buffered unsummoned group message ${m.message_id} (place ${bufferKey})`);
389
323
  }
390
324
  else {
@@ -394,79 +328,167 @@ export function buildFeishuChannel(profile, { appId, appSecret, verificationToke
394
328
  else {
395
329
  log.debug(`${label} not summoned — ignoring message ${m.message_id} (chat ${m.chat_id}, ${m.chat_type})`);
396
330
  }
397
- return new Response(null, { status: 200 });
331
+ return;
398
332
  }
399
- {
400
- const threadedP2p = directMessageSession === "threaded" && m.chat_type === "p2p";
401
- const threadedGroup = groupMessageSession === "threaded" && m.chat_type === "group";
402
- const threadedConversation = threadedP2p || threadedGroup;
403
- // A top-level threaded message has no thread_id yet. Its tenant-unique message_id is therefore
404
- // the only identity available both before and after the first reply creates the thread.
405
- // Continuations carry that same value as root_id (field-verified on Feishu p2p; shared protocol
406
- // shape for groups/Lark). Prefix with the channel kind to isolate Feishu/Lark while keeping pi's
407
- // provider-facing session/cache key under 64 characters.
408
- if (threadedConversation && m.thread_id !== undefined && m.root_id === undefined) {
409
- log.warn(`${label} threaded ${m.chat_type} message ${m.message_id} has thread_id ${m.thread_id} but no root_id — session continuity cannot be guaranteed`);
410
- }
411
- const defaultSession = threadedConversation
412
- ? `${kind}:${m.thread_id === undefined ? m.message_id : (m.root_id ?? `missing-root:${m.thread_id}`)}`
413
- : placeKey(m);
414
- const session = r.session ?? defaultSession;
415
- const chatId = r.chatId ?? m.chat_id;
416
- // Groups always quote the summon. Threaded groups and p2p add reply_in_thread: on a top-level
417
- // message that creates the thread, and on a continuation it keeps the answer inside it. Only
418
- // quote when the resolved target is the source chat — a custom redirect cannot reuse a message
419
- // id there. A continuous group still keeps replies inside an already-existing platform topic.
420
- const sameTarget = chatId === m.chat_id;
421
- const replyTo = sameTarget && (m.chat_type === "group" || threadedP2p) ? m.message_id : undefined;
422
- const replyInThread = replyTo !== undefined && (threadedConversation || m.thread_id !== undefined) ? true : undefined;
423
- // Queue feedback always identifies the exact ask, including continuous modes. In threaded mode
424
- // it inherits replyInThread, so an ask queued inside a root cannot leak a status card to main chat.
425
- const queueReplyTo = sameTarget ? m.message_id : undefined;
426
- const resources = normalized.content.resources;
427
- const images = resources
428
- .filter((resource) => resource.kind === "image")
429
- .map((resource) => ({ msg: resource.messageId, key: resource.key }));
430
- const files = resources
431
- .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
432
- .map((resource) => ({ msg: resource.messageId, key: resource.key, name: resource.name }));
433
- const baseText = r.text ?? cloudEnvelope(event, kind);
434
- if (baseText.trim() !== "" || images.length > 0 || files.length > 0) {
435
- // Persist ownership before ACK. The platform thread does not exist until the first reply lands,
436
- // but a failed reply has no continuation to misroute; pre-ACK ownership closes the opposite,
437
- // worse window (thread created, process dies, then its unmentioned continuation is forgotten).
438
- if (route === undefined &&
439
- threadedGroup &&
440
- m.thread_id === undefined &&
441
- sameTarget &&
442
- replyInThread === true) {
443
- ownedThreads.add(m.chat_id, m.message_id);
444
- }
445
- submit({
446
- id: m.message_id,
447
- seq: ++seqCounter,
448
- session,
449
- baseText,
450
- bufferKey,
451
- chatId,
452
- replyTo,
453
- queueReplyTo,
454
- replyInThread,
455
- // Inside a threaded session the root conversation history already contains the previous
456
- // turns. Reloading parent_id would duplicate that input (and its attachments). A top-level
457
- // quoted reply has no thread_id, starts a new root, and still hydrates its referent.
458
- parentId: threadedConversation && m.thread_id !== undefined ? undefined : m.parent_id,
459
- images,
460
- files,
461
- }, true);
333
+ const threadedP2p = directMessageSession === "threaded" && m.chat_type === "p2p";
334
+ const threadedGroup = groupMessageSession === "threaded" && m.chat_type === "group";
335
+ const threadedConversation = threadedP2p || threadedGroup;
336
+ if (threadedConversation && m.thread_id !== undefined && m.root_id === undefined) {
337
+ log.warn(`${label} threaded ${m.chat_type} message ${m.message_id} has thread_id ${m.thread_id} but no root_id session continuity cannot be guaranteed`);
338
+ }
339
+ const defaultSession = threadedConversation
340
+ ? `${kind}:${m.thread_id === undefined ? m.message_id : (m.root_id ?? `missing-root:${m.thread_id}`)}`
341
+ : placeKey(m);
342
+ const session = r.session ?? defaultSession;
343
+ const chatId = r.chatId ?? m.chat_id;
344
+ const sameTarget = chatId === m.chat_id;
345
+ const replyTo = sameTarget && (m.chat_type === "group" || threadedP2p) ? m.message_id : undefined;
346
+ const replyInThread = replyTo !== undefined && (threadedConversation || m.thread_id !== undefined) ? true : undefined;
347
+ const queueReplyTo = sameTarget ? m.message_id : undefined;
348
+ // Explicit user stop: a control action, never a turn — it must not queue behind the run it
349
+ // stops. Mentions arrive as @name tokens; strip them before matching the bare word. Record the
350
+ // message id so a platform re-push doesn't double-abort or double-notify.
351
+ if (isStopText(normalized.content.text.replace(/@\S+/g, " "))) {
352
+ seen.add(m.message_id);
353
+ sideTasks.track(dispatchStop(control, session, label)
354
+ .then((feedback) => api.sendText({ chatId, replyTo, replyInThread }, feedback).then(() => undefined))
355
+ .catch((error) => log.warn(`${label} stop feedback failed: ${String(error)}`)));
356
+ return;
357
+ }
358
+ const resources = normalized.content.resources;
359
+ const images = resources
360
+ .filter((resource) => resource.kind === "image")
361
+ .map((resource) => ({ msg: resource.messageId, key: resource.key }));
362
+ const files = resources
363
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
364
+ .map((resource) => ({ msg: resource.messageId, key: resource.key, name: resource.name }));
365
+ const baseText = r.text ?? cloudEnvelope(event, kind);
366
+ if (baseText.trim() === "" && images.length === 0 && files.length === 0)
367
+ return;
368
+ if (route === undefined && threadedGroup && m.thread_id === undefined && sameTarget && replyInThread === true) {
369
+ ownedThreads.add(m.chat_id, m.message_id);
370
+ }
371
+ submit({
372
+ id: m.message_id,
373
+ seq: ++seqCounter,
374
+ session,
375
+ baseText,
376
+ bufferKey,
377
+ chatId,
378
+ replyTo,
379
+ queueReplyTo,
380
+ replyInThread,
381
+ parentId: threadedConversation && m.thread_id !== undefined ? undefined : m.parent_id,
382
+ images,
383
+ files,
384
+ }, true);
385
+ };
386
+ return { acceptEvent, turnsIdle: () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined) };
387
+ };
388
+ }
389
+ function createFeishuWebhookRoutes(profile, opts, runtime) {
390
+ const { verificationToken, encryptKey } = opts;
391
+ const { kind, envPrefix } = profile;
392
+ const label = `[${kind}]`;
393
+ const handler = async (req) => {
394
+ if (req.method !== "POST")
395
+ return text("POST only\n", 405);
396
+ const body = await readBodyCapped(req, MAX_EVENT_BYTES);
397
+ if ("tooLarge" in body)
398
+ return text("payload too large\n", 413);
399
+ let outer;
400
+ try {
401
+ outer = JSON.parse(body.text);
402
+ if (typeof outer !== "object" || outer === null)
403
+ throw new Error("not an object");
404
+ }
405
+ catch {
406
+ return text("invalid json\n", 400);
407
+ }
408
+ let envelope;
409
+ if (typeof outer.encrypt === "string") {
410
+ if (!encryptKey) {
411
+ log.error(`${label} received an ENCRYPTED event but no encryptKey is configured — set ${envPrefix}_ENCRYPT_KEY`);
412
+ return text("encrypt key not configured\n", 400);
413
+ }
414
+ const sig = {
415
+ timestamp: req.headers.get("x-lark-request-timestamp") ?? "",
416
+ nonce: req.headers.get("x-lark-request-nonce") ?? "",
417
+ signature: req.headers.get("x-lark-signature") ?? "",
418
+ };
419
+ if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
420
+ log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
421
+ return text("invalid signature\n", 401);
422
+ }
423
+ try {
424
+ envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
425
+ }
426
+ catch {
427
+ if (!sig.signature) {
428
+ log.warn(`${label} rejected an unsigned encrypted request that could not be decrypted`);
429
+ return text("invalid encrypted payload\n", 401);
462
430
  }
431
+ return text("invalid encrypted payload\n", 400);
432
+ }
433
+ if (!sig.signature && envelope.type !== "url_verification") {
434
+ log.warn(`${label} rejected an encrypted event: missing X-Lark-Signature`);
435
+ return text("invalid signature\n", 401);
436
+ }
437
+ }
438
+ else {
439
+ if (encryptKey) {
440
+ log.warn(`${label} rejected a plaintext event while encryptKey is set (console mismatch, or a forgery)`);
441
+ return text("plaintext events not accepted\n", 401);
463
442
  }
464
- // ACK immediately (the platform expects a fast 200; the turn may outlast it by minutes) —
465
- // lifecycle goes to stderr; after the 200 those lines are the operator's only signal.
443
+ envelope = outer;
444
+ }
445
+ const token = (typeof envelope.token === "string" ? envelope.token : undefined) ??
446
+ (typeof envelope.header?.token === "string"
447
+ ? envelope.header.token
448
+ : undefined);
449
+ if (!token || !timingSafeEqualStr(token, verificationToken)) {
450
+ log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
451
+ return text("invalid token\n", 401);
452
+ }
453
+ if (envelope.type === "url_verification" && typeof envelope.challenge === "string") {
454
+ log.info(`${label} answered the console's url_verification challenge`);
455
+ return Response.json({ challenge: envelope.challenge });
456
+ }
457
+ const header = envelope.header;
458
+ if (header?.event_type !== "im.message.receive_v1") {
459
+ log.debug(`${label} ignoring event type ${header?.event_type ?? "(none)"}`);
466
460
  return new Response(null, { status: 200 });
467
- };
468
- // Test/observability seam: await the fire-and-forget turns this handler enqueues (see turn-queue).
469
- handler.turnsIdle = () => queue.idle();
470
- return { [`POST /${kind}`]: handler };
461
+ }
462
+ runtime.acceptEvent((envelope.event ?? {}));
463
+ return new Response(null, { status: 200 });
464
+ };
465
+ handler.turnsIdle = runtime.turnsIdle;
466
+ return { [`POST /${kind}`]: handler };
467
+ }
468
+ export function buildFeishuChannel(profile, opts, factoryName) {
469
+ validateSessionOptions(opts, factoryName);
470
+ const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
471
+ return (ctx) => {
472
+ if (!opts.verificationToken) {
473
+ throw new Error(`${factoryName} requires a non-empty verificationToken (console → Events & Callbacks)`);
474
+ }
475
+ return createFeishuWebhookRoutes(profile, opts, createRuntime(ctx));
476
+ };
477
+ }
478
+ export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
479
+ validateSessionOptions(opts, factoryName);
480
+ const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
481
+ return {
482
+ name: `${profile.kind} websocket`,
483
+ connect(ctx, signal) {
484
+ const runtime = createRuntime(ctx);
485
+ return (deps.connectWs ?? connectFeishuWs)({
486
+ kind: profile.kind,
487
+ appId: opts.appId,
488
+ appSecret: opts.appSecret,
489
+ domain: opts.apiBaseUrl ?? opts.baseUrl ?? profile.apiBase,
490
+ onEvent: runtime.acceptEvent,
491
+ }, signal);
492
+ },
471
493
  };
472
494
  }
@@ -11,7 +11,8 @@
11
11
  * BUFFERED resources come from earlier un-summoned thread/group discussion and degrade per attachment:
12
12
  * one expired background file must not block the current ask or hide its still-readable siblings.
13
13
  */
14
- import { type Agent, type AgentEvent } from "../../agent.ts";
14
+ import type { Agent, AgentEvent } from "../../agent.ts";
15
+ import { type BusyRetry } from "../invoke-turn-kit.ts";
15
16
  import type { FeishuBufferedRef } from "./context-buffer.ts";
16
17
  import type { FeishuApi } from "./feishu-api.ts";
17
18
  /** Everything the transport needs to fetch a turn's attachments. */
@@ -23,7 +24,7 @@ export interface FeishuTurnTransport {
23
24
  }
24
25
  /** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
25
26
  * bytes by message_id + key, so the pair travels together through the turn record). */
26
- export interface FeishuAttachmentInput {
27
+ interface FeishuAttachmentInput {
27
28
  msg: string;
28
29
  key: string;
29
30
  name?: string;
@@ -42,24 +43,10 @@ export interface FeishuTurnAttachments {
42
43
  skipped: number;
43
44
  };
44
45
  }
45
- /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
46
- * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
47
- * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
48
- * always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
49
- export interface BusyRetry {
50
- delayMs: number;
51
- maxWaitMs: number;
52
- }
53
46
  /**
54
- * Run one turn: resolve its inputs, then stream agent.invoke. A primary-input failure surfaces as a
55
- * `failed` event (never a silent drop). `onCompleted` (if given) fires on the `completed` event — the
56
- * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
57
- * moment the turn provably lives in the session.
58
- *
59
- * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
60
- * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
61
- * the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
62
- * FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
63
- * engine emits it in, so nothing that started is ever re-run.
47
+ * Run one turn: resolve its inputs, then stream agent.invoke with the shared busy-wait
48
+ * (invoke-turn-kit `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
49
+ * primary-input failure surfaces as a `failed` event (never a silent drop).
64
50
  */
65
51
  export declare function invokeFeishuTurn(agent: Agent, session: string, text: string, transport: FeishuTurnTransport, attachments: FeishuTurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
52
+ export {};