@fastagent-sh/fastagent 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +5 -1
  2. package/dist/channels/agentcore-protocol.d.ts +112 -0
  3. package/dist/channels/agentcore-protocol.js +22 -0
  4. package/dist/channels/agentcore-service.d.ts +0 -4
  5. package/dist/channels/agentcore-service.js +1 -6
  6. package/dist/channels/agentcore-state.d.ts +5 -11
  7. package/dist/channels/agentcore-state.js +4 -1
  8. package/dist/channels/agentcore.d.ts +1 -67
  9. package/dist/channels/agentcore.js +90 -89
  10. package/dist/channels/control.d.ts +1 -1
  11. package/dist/channels/control.js +13 -62
  12. package/dist/channels/discover.d.ts +0 -1
  13. package/dist/channels/discover.js +1 -3
  14. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  15. package/dist/channels/feishu/context-buffer.js +0 -38
  16. package/dist/channels/feishu/crypto.d.ts +0 -2
  17. package/dist/channels/feishu/crypto.js +3 -8
  18. package/dist/channels/feishu/feishu-api.js +2 -4
  19. package/dist/channels/feishu/feishu.js +56 -115
  20. package/dist/channels/feishu/parse.d.ts +4 -3
  21. package/dist/channels/feishu/parse.js +5 -4
  22. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  23. package/dist/channels/feishu/register-webhook.js +43 -57
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  26. package/dist/channels/feishu/setup-mode.js +26 -0
  27. package/dist/channels/feishu/shared-api.d.ts +10 -0
  28. package/dist/channels/feishu/shared-api.js +38 -0
  29. package/dist/channels/http.d.ts +0 -8
  30. package/dist/channels/http.js +4 -56
  31. package/dist/channels/kit/turn-runner.d.ts +59 -0
  32. package/dist/channels/kit/turn-runner.js +84 -0
  33. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  34. package/dist/channels/registration.d.ts +36 -1
  35. package/dist/channels/registration.js +57 -1
  36. package/dist/channels/secret.d.ts +1 -0
  37. package/dist/channels/secret.js +16 -0
  38. package/dist/channels/slack/config-api.d.ts +35 -3
  39. package/dist/channels/slack/config-api.js +51 -9
  40. package/dist/channels/slack/manifest.js +5 -1
  41. package/dist/channels/slack/onboard.d.ts +5 -5
  42. package/dist/channels/slack/onboard.js +41 -20
  43. package/dist/channels/slack/register-webhook.d.ts +3 -2
  44. package/dist/channels/slack/register-webhook.js +34 -14
  45. package/dist/channels/slack/scaffold/channel.ts +3 -10
  46. package/dist/channels/slack/scaffold/slack-send.ts +18 -126
  47. package/dist/channels/slack/shared-api.d.ts +10 -0
  48. package/dist/channels/slack/shared-api.js +34 -0
  49. package/dist/channels/slack/slack-api.d.ts +20 -2
  50. package/dist/channels/slack/slack-api.js +81 -22
  51. package/dist/channels/slack/slack.d.ts +0 -10
  52. package/dist/channels/slack/slack.js +60 -99
  53. package/dist/channels/sse.d.ts +4 -0
  54. package/dist/channels/sse.js +66 -0
  55. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  56. package/dist/channels/telegram/register-webhook.js +44 -42
  57. package/dist/channels/telegram/telegram.js +47 -142
  58. package/dist/channels/wait-health.js +7 -4
  59. package/dist/cli/add-feishu.js +3 -10
  60. package/dist/cli/add-slack.js +7 -20
  61. package/dist/cli/commands/add.d.ts +0 -1
  62. package/dist/cli/commands/add.js +22 -31
  63. package/dist/cli/commands/chat.js +6 -12
  64. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  65. package/dist/cli/commands/deploy/agentcore.js +178 -0
  66. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  67. package/dist/cli/commands/deploy/docker.js +119 -0
  68. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  69. package/dist/cli/commands/deploy/fly.js +131 -0
  70. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  71. package/dist/cli/commands/deploy/railway.js +71 -0
  72. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  73. package/dist/cli/commands/deploy/shared.js +124 -0
  74. package/dist/cli/commands/deploy.d.ts +17 -49
  75. package/dist/cli/commands/deploy.js +39 -618
  76. package/dist/cli/commands/dev.js +17 -46
  77. package/dist/cli/commands/fire.js +6 -12
  78. package/dist/cli/commands/info.js +2 -1
  79. package/dist/cli/commands/invoke.js +4 -11
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +17 -46
  82. package/dist/cli/commands/tool.js +3 -8
  83. package/dist/cli/kernel.d.ts +0 -2
  84. package/dist/cli/kernel.js +0 -2
  85. package/dist/cli/program.js +7 -12
  86. package/dist/cli/serve.d.ts +36 -19
  87. package/dist/cli/serve.js +91 -7
  88. package/dist/cli/shared.d.ts +13 -18
  89. package/dist/cli/shared.js +20 -4
  90. package/dist/deploy/agentcore/forwarder.js +250 -0
  91. package/dist/deploy/agentcore/plan.d.ts +23 -2
  92. package/dist/deploy/agentcore/plan.js +29 -244
  93. package/dist/deploy/agentcore/run.d.ts +4 -20
  94. package/dist/deploy/agentcore/run.js +22 -7
  95. package/dist/deploy/channel-ingress.js +1 -1
  96. package/dist/deploy/docker/run.d.ts +17 -3
  97. package/dist/deploy/docker/run.js +30 -8
  98. package/dist/deploy/fly/plan.js +7 -0
  99. package/dist/deploy/fly/run.d.ts +27 -0
  100. package/dist/deploy/fly/run.js +102 -20
  101. package/dist/deploy/hosts.d.ts +5 -0
  102. package/dist/deploy/hosts.js +4 -0
  103. package/dist/deploy/preflight.js +3 -3
  104. package/dist/deploy/railway/plan.d.ts +5 -0
  105. package/dist/deploy/railway/plan.js +7 -0
  106. package/dist/deploy/railway/run.d.ts +2 -1
  107. package/dist/deploy/railway/run.js +5 -4
  108. package/dist/deploy/secrets.js +0 -17
  109. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  110. package/dist/engines/pi/agent-session-factory.js +109 -85
  111. package/dist/engines/pi/config.d.ts +1 -1
  112. package/dist/engines/pi/create.d.ts +22 -39
  113. package/dist/engines/pi/create.js +58 -73
  114. package/dist/engines/pi/definition.js +8 -8
  115. package/dist/engines/pi/invoke-session.js +13 -30
  116. package/dist/engines/pi/open.d.ts +2 -2
  117. package/dist/engines/pi/open.js +31 -41
  118. package/dist/engines/pi/retry-event.d.ts +6 -0
  119. package/dist/engines/pi/retry-event.js +15 -0
  120. package/dist/engines/pi/session-builder.js +26 -113
  121. package/dist/engines/pi/session-control.d.ts +10 -18
  122. package/dist/engines/pi/session-control.js +47 -81
  123. package/dist/engines/pi/session-settings.d.ts +1 -1
  124. package/dist/engines/pi/session-settings.js +1 -1
  125. package/dist/engines/pi/session-store.d.ts +12 -17
  126. package/dist/engines/pi/session-store.js +18 -40
  127. package/dist/engines/pi/tool-context.d.ts +3 -3
  128. package/dist/engines/pi/tool.d.ts +6 -9
  129. package/dist/engines/pi/tool.js +1 -0
  130. package/dist/feishu.d.ts +1 -0
  131. package/dist/feishu.js +1 -0
  132. package/dist/lark.d.ts +1 -0
  133. package/dist/lark.js +1 -0
  134. package/dist/loader.d.ts +2 -0
  135. package/dist/loader.js +5 -0
  136. package/dist/log.d.ts +9 -17
  137. package/dist/log.js +25 -30
  138. package/dist/paths.d.ts +7 -3
  139. package/dist/paths.js +17 -4
  140. package/dist/scaffold/add-channel.d.ts +6 -1
  141. package/dist/scaffold/add-channel.js +48 -65
  142. package/dist/schedule/wake-alarm.d.ts +1 -12
  143. package/dist/schedule/wake-alarm.js +2 -3
  144. package/dist/service.d.ts +23 -22
  145. package/dist/service.js +7 -73
  146. package/dist/session-remote.d.ts +5 -5
  147. package/dist/session-remote.js +25 -28
  148. package/dist/session.d.ts +1 -1
  149. package/dist/slack.d.ts +2 -0
  150. package/dist/slack.js +1 -0
  151. package/dist/tunnel.d.ts +16 -6
  152. package/dist/tunnel.js +53 -12
  153. package/package.json +5 -4
  154. package/dist/channels/slack/bot-auth.d.ts +0 -15
  155. package/dist/channels/slack/bot-auth.js +0 -135
@@ -7,20 +7,21 @@
7
7
  * Mechanism: the application-v7 config PATCH (`updateEventSubscription`) flips the app's event
8
8
  * subscription to webhook mode and points it at `<baseUrl>/<kind>`. Two properties make this the full
9
9
  * telegram-setWebhook parity: the platform applies a request-URL change IMMEDIATELY (no version
10
- * publish), and it VERIFIES the URL with a url_verification challenge during the PATCH — which is why
11
- * this waits for `<baseUrl>/health` to serve first (the same readiness race the telegram registrar
12
- * fixes). Requires the `application:application:patch` scope (field-tested: `self_manage` does NOT
13
- * cover this PATCH) `add feishu` requests it at creation via addons; without it the PATCH fails
14
- * visibly and the manual console instruction is printed.
10
+ * publish), and it VERIFIES the URL with a url_verification challenge during the PATCH — which makes
11
+ * the PATCH its own readiness probe, retried below. (It used to poll `<baseUrl>/health` from here
12
+ * first; that asked whether THIS machine could reach a freshly minted hostname, which is routinely
13
+ * false for a minute or more while the platform reaches it fine #421.) Requires the
14
+ * `application:application:patch` scope (field-tested: `self_manage` does NOT cover this PATCH) —
15
+ * `add feishu` requests it at creation via addons; without it the PATCH fails visibly and the manual
16
+ * console instruction is printed.
15
17
  *
16
18
  * CLOUD LAG: the application-v7 config API exists on open.feishu.cn but (as of 2026-07) is NOT
17
19
  * deployed on open.larksuite.com — the route 404s there. The registrar still attempts it (the day the
18
20
  * platform ships it, registration starts working with no change here) and names the real cause in the
19
21
  * fallback instead of blaming the app's scopes.
20
22
  */
21
- import { setTimeout as sleep } from "node:timers/promises";
22
23
  import { log } from "../../log.js";
23
- import { waitForHealth } from "../wait-health.js";
24
+ import { retryWhile } from "../registration.js";
24
25
  import { cloudFor } from "./cloud.js";
25
26
  import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError } from "./feishu-api.js";
26
27
  export async function registerFeishuWebhook(baseUrl, kind, opts = {}) {
@@ -35,16 +36,6 @@ export async function registerFeishuWebhook(baseUrl, kind, opts = {}) {
35
36
  log.info(`[fastagent] ${kind}: set ${envPrefix}_APP_ID + ${envPrefix}_APP_SECRET in .env, then re-run to auto-register. Or ${manual}`);
36
37
  return "manual";
37
38
  }
38
- // Align registration with the server actually serving: the PATCH triggers the platform's
39
- // url_verification challenge against requestUrl — registering before /health serves would fail.
40
- log.info(`[fastagent] ${kind}: waiting for ${baseUrl} to be reachable before registering the event URL…`);
41
- const ready = await waitForHealth(`${baseUrl}/health`, opts.readyTimeoutMs ?? 120_000, opts.readyIntervalMs ?? 3_000);
42
- if (!ready) {
43
- // Terminal for this run (registration will not be retried) — error, not warn: the event URL is NOT
44
- // registered and the operator must act. Same taxonomy as the permanent PATCH failure below.
45
- log.error(`[fastagent] ${kind}: ${baseUrl}/health did not come up in time — the app may still be starting. ${manual}`);
46
- return "failed";
47
- }
48
39
  const api = createFeishuApi({ kind, baseUrl: apiBase, appId, appSecret });
49
40
  const consoleUrl = `${apiBase}/app/${encodeURIComponent(appId)}/event`;
50
41
  const versionUrl = `${apiBase}/app/${encodeURIComponent(appId)}/version`;
@@ -61,46 +52,41 @@ export async function registerFeishuWebhook(baseUrl, kind, opts = {}) {
61
52
  log.warn(`[fastagent] ${kind}: could not open Events & Callbacks: ${String(callbackError)}`);
62
53
  }
63
54
  };
64
- // Reachable → register. The PATCH is the real probe (same lesson as the token bootstrap): the
65
- // platform verifies request_url with a challenge DURING the call, and a fresh tunnel's edge can be
66
- // reachable from here while the platform's own path still lags its 210042 "request_url validation
67
- // failed" is therefore retried with backoff, alongside transient network errors. Only a permanent
68
- // config error (missing scope, app under review, the intl 404) is reported once with the manual path.
69
- const attempts = 8;
70
- for (let attempt = 0; attempt < attempts; attempt++) {
71
- if (attempt > 0)
72
- await sleep(opts.retryMs ?? 10_000);
73
- try {
74
- await api.updateEventSubscription(appId, { subscriptionType: "webhook", requestUrl });
75
- log.info(`[fastagent] ${kind}: event Request URL registered ${requestUrl}`);
76
- // Field-tested: a URL change applies immediately, but the MODE flip (the template's long
77
- // connection webhook) only takes effect when a version is published the dispatcher serves
78
- // the published snapshot, and version publishing has no open API. One console click, once.
79
- log.info(`[fastagent] ${kind}: if messages do not arrive, publish a version (one click, prompted) — the switch to webhook mode takes effect on publish: ${versionUrl}`);
80
- return "registered";
81
- }
82
- catch (e) {
83
- // A 404 on the config route is the CLOUD lagging, not this app's configuration: the v7 API is
84
- // live on open.feishu.cn but not yet on open.larksuite.com. Name that "check your scopes"
85
- // would send the operator hunting for a problem they cannot fix.
86
- if (isFeishuConfigApiMissing(e)) {
87
- log.warn(`[fastagent] ${kind}: this cloud (${apiBase}) returned HTTP 404 for the app-config API — ` +
88
- `manual registration is required`);
89
- manualRegistration();
90
- return "manual"; // that cloud has no config API — the manual path is the norm there
91
- }
92
- if (!isTransientFeishuRegistrationError(e)) {
93
- log.error(`[fastagent] ${kind}: could not register the event URL (${String(e)}). ` +
94
- `The app may lack the "application:application:patch" scope (console → Permissions) or be under review; manual registration is available below.`);
95
- manualRegistration();
96
- return "failed";
97
- }
55
+ // The PATCH is the probe (same lesson as the token bootstrap): the platform verifies request_url
56
+ // with a challenge DURING the call, so its 210042 "request_url validation failed" is the readiness
57
+ // signal and is retried with backoff, alongside transient network errors. Only a permanent config
58
+ // error (missing scope, app under review, the intl 404) is reported once with the manual path.
59
+ try {
60
+ await retryWhile(() => api.updateEventSubscription(appId, { subscriptionType: "webhook", requestUrl }), isTransientFeishuRegistrationError, {
61
+ attempts: opts.attempts,
62
+ retryMs: opts.retryMs,
63
+ onRetry: ({ attempt, attempts }) => log.info(`[fastagent] ${kind}: the platform cannot verify ${requestUrl} yet (attempt ${attempt}/${attempts}); retrying…`),
64
+ });
65
+ log.info(`[fastagent] ${kind}: event Request URL registered → ${requestUrl}`);
66
+ // Field-tested: a URL change applies immediately, but the MODE flip (the template's long
67
+ // connection webhook) only takes effect when a version is published — the dispatcher serves
68
+ // the published snapshot, and version publishing has no open API. One console click, once.
69
+ log.info(`[fastagent] ${kind}: if messages do not arrive, publish a version (one click, prompted) the switch to webhook mode takes effect on publish: ${versionUrl}`);
70
+ return "registered";
71
+ }
72
+ catch (e) {
73
+ // A 404 on the config route is the CLOUD lagging, not this app's configuration: the v7 API is
74
+ // live on open.feishu.cn but not yet on open.larksuite.com. Name that "check your scopes"
75
+ // would send the operator hunting for a problem they cannot fix. It stays WARN: on that cloud the
76
+ // manual path is the known norm, not an exceptional failure.
77
+ if (isFeishuConfigApiMissing(e)) {
78
+ log.warn(`[fastagent] ${kind}: this cloud (${apiBase}) returned HTTP 404 for the app-config API — ` +
79
+ `manual registration is required`);
80
+ manualRegistration();
81
+ return "manual"; // that cloud has no config API — the manual path is the norm there
98
82
  }
83
+ // Exhausted retries end in the same state as a permanent config error (event URL not registered,
84
+ // manual action required) — report at the same level.
85
+ log.error(isTransientFeishuRegistrationError(e)
86
+ ? `[fastagent] ${kind}: the platform could not verify ${requestUrl} after retries — manual registration is required`
87
+ : `[fastagent] ${kind}: could not register the event URL (${String(e)}). ` +
88
+ `The app may lack the "application:application:patch" scope (console → Permissions) or be under review; manual registration is available below.`);
89
+ manualRegistration();
90
+ return "failed";
99
91
  }
100
- // Exhausted retries end in the same state as a permanent error (event URL not registered, manual
101
- // action required) — report at the same level. The cloud-lag 404 above stays warn: on that cloud the
102
- // manual path is the known norm, not an exceptional failure.
103
- log.error(`[fastagent] ${kind}: registration still failing after retries — manual registration is required`);
104
- manualRegistration();
105
- return "failed";
106
92
  }
@@ -1,58 +1,8 @@
1
1
  import { defineTool, z } from "@fastagent-sh/fastagent";
2
+ import { feishuTransport } from "@fastagent-sh/fastagent/feishu";
2
3
 
3
- // Send a message to a Feishu chat. In a CHAT turn the channel delivers the reply itself — this
4
- // tool is for turns NO channel is carrying: a scheduled turn (schedules/<name>.ts) or a self-scheduled
5
- // wake-up, whose plain reply is not delivered anywhere. The chatId comes from the [feishu: chat …]
6
- // context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
7
- // the target chat id. tools/ is auto-discovered.
8
-
9
- // Embedded copy of the channel transport's discipline: a timeout so a wedged connection can't hang
10
- // the tool call (and the turn), named errors, and success gated on the body's own code===0.
11
- // Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
12
- // fail-fast beats a silently sleeping tool.
13
- const BASE = "https://open.feishu.cn";
14
-
15
- async function callApi(path: string, body: unknown, token?: string): Promise<Record<string, unknown>> {
16
- let res: Response;
17
- let raw: string;
18
- try {
19
- res = await fetch(`${BASE}${path}`, {
20
- method: "POST",
21
- headers: {
22
- "content-type": "application/json; charset=utf-8",
23
- ...(token ? { authorization: `Bearer ${token}` } : {}),
24
- },
25
- body: JSON.stringify(body),
26
- signal: AbortSignal.timeout(30_000),
27
- });
28
- raw = await res.text();
29
- } catch (e) {
30
- throw new Error(`feishu ${path}: ${String(e)}`, { cause: e });
31
- }
32
- let data: { code?: number; msg?: string; [k: string]: unknown };
33
- try {
34
- data = JSON.parse(raw) as typeof data;
35
- } catch {
36
- data = {};
37
- }
38
- if (!res.ok || data.code !== 0) {
39
- throw new Error(`feishu ${path} failed: ${res.status} ${data.msg ?? "response was not the expected JSON"}`);
40
- }
41
- return data;
42
- }
43
-
44
- async function tenantToken(): Promise<string> {
45
- const appId = process.env.FEISHU_APP_ID;
46
- const appSecret = process.env.FEISHU_APP_SECRET;
47
- if (!appId || !appSecret) throw new Error("FEISHU_APP_ID / FEISHU_APP_SECRET are not set");
48
- const data = await callApi("/open-apis/auth/v3/tenant_access_token/internal", {
49
- app_id: appId,
50
- app_secret: appSecret,
51
- });
52
- const token = data.tenant_access_token;
53
- if (typeof token !== "string") throw new Error("feishu tenant_access_token: response carried no token");
54
- return token;
55
- }
4
+ // Proactive delivery uses the mounted channel's credentials, gateway and token cache. With no
5
+ // channel mounted (fire/invoke/tool), it reads FEISHU_APP_ID / FEISHU_APP_SECRET from the environment.
56
6
 
57
7
  export default defineTool({
58
8
  description:
@@ -70,23 +20,18 @@ export default defineTool({
70
20
  text: z.string().optional().describe("plain text message to send"),
71
21
  markdown: z.string().optional().describe("markdown to send as a card"),
72
22
  }),
73
- async execute({ chatId, text, markdown }) {
23
+ async execute({ chatId, text, markdown }, ctx) {
74
24
  if ((text === undefined) === (markdown === undefined)) {
75
25
  throw new Error("pass exactly one of `text` (plain) or `markdown` (a card)");
76
26
  }
77
- const token = await tenantToken();
78
- const msg =
79
- text !== undefined
80
- ? { msg_type: "text", content: JSON.stringify({ text }) }
81
- : {
82
- msg_type: "interactive",
83
- // An inline static card: one markdown element, no entity/streaming machinery needed here.
84
- content: JSON.stringify({
85
- schema: "2.0",
86
- body: { elements: [{ tag: "markdown", content: markdown }] },
87
- }),
88
- };
89
- await callApi(`/open-apis/im/v1/messages?receive_id_type=chat_id`, { receive_id: chatId, ...msg }, token);
27
+ const api = feishuTransport(ctx.cwd);
28
+ if (text !== undefined) await api.sendText({ chatId }, text);
29
+ else {
30
+ await api.sendMessage(chatId, "interactive", JSON.stringify({
31
+ schema: "2.0",
32
+ body: { elements: [{ tag: "markdown", content: markdown }] },
33
+ }));
34
+ }
90
35
  return `sent ${text !== undefined ? "message" : "card"} to chat ${chatId}`;
91
36
  },
92
37
  });
@@ -4,6 +4,36 @@ export type FeishuSubscriptionMode = "webhook" | "websocket";
4
4
  * runtime remains capability-driven because the platform, not channel source, decides which events
5
5
  * are delivered. */
6
6
  export type FeishuGroupBehavior = "context" | "mentions";
7
+ /**
8
+ * Configuring THIS app: what every v7 config PATCH needs — registering a webhook Request URL, and
9
+ * adding a scope to the app draft during context-aware group setup.
10
+ *
11
+ * Requested at creation for BOTH ingress modes, though only webhook uses it on day one. A WebSocket
12
+ * app that later wants webhook delivery cannot acquire it in passing: changing mode is a migration
13
+ * the CLI deliberately refuses to perform (`resolveIngress`), so the app would have to be recreated
14
+ * or the scope added by hand in the console after a failed PATCH. It is not a data scope — it reads
15
+ * and writes this app's own configuration — so carrying it costs an app nothing and keeps the one
16
+ * door open that the alternative nails shut.
17
+ */
18
+ export declare const FEISHU_APP_CONFIG_SCOPE = "application:application:patch";
19
+ /** The one event an agent app cannot serve without: an inbound message. */
20
+ export declare const FEISHU_MESSAGE_RECEIVE_EVENT = "im.message.receive_v1";
21
+ /**
22
+ * What a created app carries on top of the platform's agent template, for EITHER ingress — the
23
+ * `addons` merged onto the confirm page. Here rather than inline at the call site because it is a
24
+ * policy decision (which capabilities an app is born with), and the CLI layer that requests it is
25
+ * terminal wiring with no seam to test through.
26
+ */
27
+ export declare function feishuAppAddons(): {
28
+ scopes: {
29
+ tenant: string[];
30
+ };
31
+ events: {
32
+ items: {
33
+ tenant: string[];
34
+ };
35
+ };
36
+ };
7
37
  /** The sensitive tenant scope behind both bare replies in the agent's threads and group context buffering. */
8
38
  export declare const FEISHU_GROUP_CONTEXT_SCOPE = "im:message.group_msg";
9
39
  /** Reading a QUOTED message by id, so an ask carries what it replies to (participant-model.md §8).
@@ -1,3 +1,29 @@
1
+ /**
2
+ * Configuring THIS app: what every v7 config PATCH needs — registering a webhook Request URL, and
3
+ * adding a scope to the app draft during context-aware group setup.
4
+ *
5
+ * Requested at creation for BOTH ingress modes, though only webhook uses it on day one. A WebSocket
6
+ * app that later wants webhook delivery cannot acquire it in passing: changing mode is a migration
7
+ * the CLI deliberately refuses to perform (`resolveIngress`), so the app would have to be recreated
8
+ * or the scope added by hand in the console after a failed PATCH. It is not a data scope — it reads
9
+ * and writes this app's own configuration — so carrying it costs an app nothing and keeps the one
10
+ * door open that the alternative nails shut.
11
+ */
12
+ export const FEISHU_APP_CONFIG_SCOPE = "application:application:patch";
13
+ /** The one event an agent app cannot serve without: an inbound message. */
14
+ export const FEISHU_MESSAGE_RECEIVE_EVENT = "im.message.receive_v1";
15
+ /**
16
+ * What a created app carries on top of the platform's agent template, for EITHER ingress — the
17
+ * `addons` merged onto the confirm page. Here rather than inline at the call site because it is a
18
+ * policy decision (which capabilities an app is born with), and the CLI layer that requests it is
19
+ * terminal wiring with no seam to test through.
20
+ */
21
+ export function feishuAppAddons() {
22
+ return {
23
+ scopes: { tenant: [FEISHU_APP_CONFIG_SCOPE] },
24
+ events: { items: { tenant: [FEISHU_MESSAGE_RECEIVE_EVENT] } },
25
+ };
26
+ }
1
27
  /** The sensitive tenant scope behind both bare replies in the agent's threads and group context buffering. */
2
28
  export const FEISHU_GROUP_CONTEXT_SCOPE = "im:message.group_msg";
3
29
  /** Reading a QUOTED message by id, so an ask carries what it replies to (participant-model.md §8).
@@ -0,0 +1,10 @@
1
+ import { type FeishuCloudKind } from "./cloud.ts";
2
+ import { type FeishuApi } from "./feishu-api.ts";
3
+ /** The delivery methods a proactive sender needs. */
4
+ export type FeishuTransport = Pick<FeishuApi, "sendText" | "sendMessage">;
5
+ /** A remount replaces the transport; separate clouds can share one agent state root. */
6
+ export declare function registerFeishuApi(stateRoot: string, kind: FeishuCloudKind, api: FeishuApi): void;
7
+ /** Resolve the Feishu transport for a tool's workspace (`ctx.cwd`). */
8
+ export declare function feishuTransport(cwd: string): FeishuTransport;
9
+ /** Resolve the Lark transport for a tool's workspace (`ctx.cwd`). */
10
+ export declare function larkTransport(cwd: string): FeishuTransport;
@@ -0,0 +1,38 @@
1
+ /** The mounted channel and proactive tools share credentials, token cache, gateway and retry policy. */
2
+ import { findAgentDir, resolveStateRoot } from "../../paths.js";
3
+ import { cloudFor } from "./cloud.js";
4
+ import { createFeishuApi } from "./feishu-api.js";
5
+ const byKind = {
6
+ feishu: new Map(),
7
+ lark: new Map(),
8
+ };
9
+ /** A remount replaces the transport; separate clouds can share one agent state root. */
10
+ export function registerFeishuApi(stateRoot, kind, api) {
11
+ byKind[kind].set(stateRoot, api);
12
+ }
13
+ function cloudTransport(cwd, kind) {
14
+ // Embedded senders may use a bare definition directory or an independent workspace.
15
+ const stateRoot = resolveStateRoot(findAgentDir(cwd) ?? cwd);
16
+ const transports = byKind[kind];
17
+ let api = transports.get(stateRoot);
18
+ if (!api) {
19
+ // fire/invoke/tool do not mount channels, so their credentials come from the agent's environment.
20
+ const { apiBase, envPrefix } = cloudFor(kind);
21
+ const appId = process.env[`${envPrefix}_APP_ID`];
22
+ const appSecret = process.env[`${envPrefix}_APP_SECRET`];
23
+ if (!appId || !appSecret) {
24
+ throw new Error(`${envPrefix}_APP_ID / ${envPrefix}_APP_SECRET are not set and no ${kind} channel is mounted`);
25
+ }
26
+ api = createFeishuApi({ kind, baseUrl: apiBase, appId, appSecret });
27
+ transports.set(stateRoot, api);
28
+ }
29
+ return api;
30
+ }
31
+ /** Resolve the Feishu transport for a tool's workspace (`ctx.cwd`). */
32
+ export function feishuTransport(cwd) {
33
+ return cloudTransport(cwd, "feishu");
34
+ }
35
+ /** Resolve the Lark transport for a tool's workspace (`ctx.cwd`). */
36
+ export function larkTransport(cwd) {
37
+ return cloudTransport(cwd, "lark");
38
+ }
@@ -13,14 +13,6 @@ import type { Agent } from "../agent.ts";
13
13
  /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
14
14
  * dispatch imports it), so the two caps cannot drift apart. */
15
15
  export declare const MAX_BODY_BYTES: number;
16
- /** SSE comment-heartbeat interval, shared by every SSE surface (the control events route imports
17
- * it, and the remote client sizes its dead-connection watchdog as a multiple of it). */
18
- export declare const SSE_HEARTBEAT_MS = 30000;
19
- /** The emitting half of the heartbeat contract (the client watchdog is the other): starts the
20
- * `: ping` comment interval on an SSE stream controller and returns its stop function — ONE
21
- * implementation for every SSE surface, so the emission side cannot regress on one route while
22
- * the shared client watchdog keeps assuming it. Self-stops if the controller is already closed. */
23
- export declare function sseHeartbeat(controller: ReadableStreamDefaultController<Uint8Array>): () => void;
24
16
  /** A valid example request body for the invoke handler — lives HERE, next to the shape check it must
25
17
  * satisfy, so the CLI's "try it" hint can't drift from the protocol. */
26
18
  export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hello\"}";
@@ -1,28 +1,9 @@
1
1
  import { readBodyCapped } from "./body.js";
2
2
  import { text } from "./respond.js";
3
+ import { sseResponse } from "./sse.js";
3
4
  /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
4
5
  * dispatch imports it), so the two caps cannot drift apart. */
5
6
  export const MAX_BODY_BYTES = 1 << 20;
6
- const encoder = new TextEncoder();
7
- /** SSE comment-heartbeat interval, shared by every SSE surface (the control events route imports
8
- * it, and the remote client sizes its dead-connection watchdog as a multiple of it). */
9
- export const SSE_HEARTBEAT_MS = 30_000;
10
- /** The emitting half of the heartbeat contract (the client watchdog is the other): starts the
11
- * `: ping` comment interval on an SSE stream controller and returns its stop function — ONE
12
- * implementation for every SSE surface, so the emission side cannot regress on one route while
13
- * the shared client watchdog keeps assuming it. Self-stops if the controller is already closed. */
14
- export function sseHeartbeat(controller) {
15
- const encoder = new TextEncoder();
16
- const timer = setInterval(() => {
17
- try {
18
- controller.enqueue(encoder.encode(": ping\n\n"));
19
- }
20
- catch {
21
- clearInterval(timer);
22
- }
23
- }, SSE_HEARTBEAT_MS);
24
- return () => clearInterval(timer);
25
- }
26
7
  /** A valid example request body for the invoke handler — lives HERE, next to the shape check it must
27
8
  * satisfy, so the CLI's "try it" hint can't drift from the protocol. */
28
9
  export const INVOKE_EXAMPLE_BODY = '{"session":"dev","text":"hello"}';
@@ -48,7 +29,7 @@ export function createInvokeHandler(agent) {
48
29
  if (typeof session !== "string" || typeof promptText !== "string") {
49
30
  return text('need { "session": string, "text": string }\n', 400);
50
31
  }
51
- // ^ the request shape INVOKE_EXAMPLE_BODY (below) must keep satisfying.
32
+ // INVOKE_EXAMPLE_BODY must keep satisfying this request shape.
52
33
  // The OPTIONAL lineage extension (Scope): malformed values are a 400, not a silent drop — a
53
34
  // caller that sent them meant them.
54
35
  if (parentSession !== undefined && typeof parentSession !== "string") {
@@ -57,43 +38,10 @@ export function createInvokeHandler(agent) {
57
38
  if (branchHints !== undefined && !(Array.isArray(branchHints) && branchHints.every((h) => typeof h === "string"))) {
58
39
  return text('"branchHints" must be an array of strings\n', 400);
59
40
  }
60
- // Take the iterator explicitly so the stream's cancel() (consumer disconnect) can return() it and
61
- // run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
62
- const iterator = agent
63
- .invoke({
41
+ return sseResponse(agent.invoke({
64
42
  session,
65
43
  ...(parentSession !== undefined ? { parentSession } : {}),
66
44
  ...(branchHints !== undefined ? { branchHints } : {}),
67
- }, { text: promptText })[Symbol.asyncIterator]();
68
- // Heartbeats: a QUIET stream (a long tool call, no events) is normal here — remote consumers
69
- // distinguish "quiet but alive" from a dead connection by byte arrival, so silence must not
70
- // look identical to a black hole (SSE comments are ignored by spec-conforming parsers).
71
- let stopHeartbeat = () => { };
72
- const stream = new ReadableStream({
73
- start(controller) {
74
- stopHeartbeat = sseHeartbeat(controller);
75
- },
76
- async pull(controller) {
77
- const { value, done } = await iterator.next();
78
- if (done) {
79
- stopHeartbeat();
80
- controller.close();
81
- return;
82
- }
83
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
84
- },
85
- async cancel() {
86
- stopHeartbeat();
87
- await iterator.return?.();
88
- },
89
- });
90
- return new Response(stream, {
91
- status: 200,
92
- headers: {
93
- "content-type": "text/event-stream",
94
- "cache-control": "no-cache",
95
- connection: "keep-alive",
96
- },
97
- });
45
+ }, { text: promptText }));
98
46
  };
99
47
  }
@@ -0,0 +1,59 @@
1
+ import type { ContextBuffer } from "./context-buffer.ts";
2
+ import { type TurnRecordBase, type TurnStore } from "./turn-store.ts";
3
+ /** A pending turn is the persisted intent minus its attempt count, plus live-only fields the
4
+ * channel adds (a notice's message id) — never persisted, reconstructed fresh on replay. */
5
+ export type PendingBase<S extends TurnRecordBase> = Omit<S, "attempts">;
6
+ export interface TurnRunnerOptions<R extends PendingBase<S>, S extends TurnRecordBase, E> {
7
+ label: string;
8
+ store: TurnStore<S>;
9
+ buffer: ContextBuffer<E>;
10
+ /** Delivery dedup by platform id, recorded post-persist (Slack, Feishu). */
11
+ seen?: {
12
+ add(id: string): void;
13
+ };
14
+ /** The persisted intent for a pending turn — drops the live-only fields. */
15
+ toStored(rec: R): S;
16
+ /** A recovered intent as a pending turn — live-only fields start absent. */
17
+ fromStored(stored: S): R;
18
+ /** The context-buffer bucket this turn folds. */
19
+ bufferKey(rec: R): string;
20
+ /** The place, for the lifecycle log line (`chat=… thread=…`). */
21
+ where(rec: R): string;
22
+ /** Queue feedback when a turn is scheduled BEHIND an active one. Returns what the runner awaits at
23
+ * dequeue (so the turn reliably takes the notice over instead of racing it) and, optionally, how
24
+ * to cancel a notice that has not fired yet. `done` may reject — posting the notice is a platform
25
+ * call — and the runner logs that and runs the turn anyway. */
26
+ onQueuedBehind?(rec: R): {
27
+ done: Promise<void>;
28
+ cancel?: () => void;
29
+ };
30
+ /** Runs before the attempt is counted. Answer false to leave the intent untouched for a later run
31
+ * (Slack: its transport is known to be down, so an Agent turn now would have nowhere to answer). */
32
+ beforeRun?(rec: R): Promise<boolean>;
33
+ /** The attempt could not be recorded (disk failure): a restart replays the turn, so say so on any
34
+ * notice it holds rather than leaving it pinned at "Queued". */
35
+ onDeferred(rec: R): void;
36
+ /** The turn started the ceiling's worth of times without finishing: tell the asker. */
37
+ notifyDropped(rec: R): void;
38
+ /** Run the turn. `onCompleted` is the durable-commit point (the turn's `completed` event): it drops
39
+ * the intent and commits the folded discussion, in that order. A throw is logged as the turn's
40
+ * failure; the intent is dropped either way. */
41
+ execute(rec: R, discussion: {
42
+ text: string;
43
+ consumed: E[];
44
+ }, onCompleted: () => void): Promise<void>;
45
+ }
46
+ export interface TurnRunner<R, S> {
47
+ /** Accept a turn: persist its intent (pre-ACK — a failed write throws so the platform redelivers),
48
+ * record the delivery id, enqueue. Recovery re-enqueues without re-persisting. */
49
+ submit(rec: R, persist: boolean): void;
50
+ /** Re-enqueue the turns a prior crash left mid-flight; returns them so a channel can continue its
51
+ * arrival counter. */
52
+ recover(): S[];
53
+ /** Resolve once no turn is in flight — the test/observability seam. */
54
+ idle(): Promise<void>;
55
+ }
56
+ export declare function createTurnRunner<R extends PendingBase<S> & {
57
+ id: string;
58
+ session: string;
59
+ }, S extends TurnRecordBase, E>(options: TurnRunnerOptions<R, S, E>): TurnRunner<R, S>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The durable-turn LIFECYCLE the stateful chat channels share, over the kit's parts: accept
3
+ * (persist pre-ACK, dedup, enqueue) → dequeue (settle the queue notice, count the attempt against
4
+ * the poison ceiling, fold the buffered discussion) → execute → end (log, drop the intent). Telegram,
5
+ * Slack and Feishu each wrote this out; the copies had already drifted in small ways that were not
6
+ * decisions (which one deletes its notice on defer, which one logs the duration on failure).
7
+ *
8
+ * What stays with the platform is everything that names a platform object: how a queue notice is
9
+ * mounted and taken over, what the prompt looks like, how attachments resolve, what a dropped turn
10
+ * says and where. Those arrive as hooks; the ORDER they run in is this module's.
11
+ */
12
+ import { log } from "../../log.js";
13
+ import { createTurnQueue } from "./turn-queue.js";
14
+ import { commitAnsweredTurn } from "./turn-store.js";
15
+ export function createTurnRunner(options) {
16
+ const { label, store, buffer, seen, onQueuedBehind } = options;
17
+ const notices = new Map();
18
+ const queue = createTurnQueue({
19
+ label,
20
+ onQueuedBehind: onQueuedBehind && ((rec) => notices.set(rec.id, onQueuedBehind(rec))),
21
+ run: async (rec) => {
22
+ // Runs at DEQUEUE time (serialized). The queue wait is over: cancel a notice that has not
23
+ // fired, then settle so the turn's preview handle is final — in the common path this await is
24
+ // instant. BEFORE the ceiling check so a dropped or deferred turn can take the notice over too.
25
+ const notice = notices.get(rec.id);
26
+ notice?.cancel?.();
27
+ // The notice is the PLATFORM's feedback, not the turn: a failed post must not throw out of the
28
+ // run, which would leak the persisted intent (removed only below) into a replay that burns an
29
+ // attempt against the ceiling. Logged, then the turn proceeds without its handle.
30
+ await notice?.done.catch((error) => log.warn(`${label} queue notice failed: turn=${rec.id} session=${rec.session}: ${String(error)}`));
31
+ notices.delete(rec.id);
32
+ if (options.beforeRun && !(await options.beforeRun(rec)))
33
+ return;
34
+ const decision = store.startAttempt(rec.id);
35
+ if (decision === "exceeded") {
36
+ options.notifyDropped(rec);
37
+ return;
38
+ }
39
+ if (decision === "defer") {
40
+ options.onDeferred(rec);
41
+ return;
42
+ }
43
+ const startedAt = Date.now();
44
+ log.info(`${label} turn start: turn=${rec.id} session=${rec.session} ${options.where(rec)}`);
45
+ // Snapshot the discussion at dequeue; commit only this snapshot on `completed`, so a message
46
+ // arriving while the turn runs stays buffered for the next answered turn.
47
+ const bufferKey = options.bufferKey(rec);
48
+ const discussion = buffer.peek(bufferKey);
49
+ try {
50
+ await options.execute(rec, discussion, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey, consumed: discussion.consumed }));
51
+ log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
52
+ }
53
+ catch (error) {
54
+ log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
55
+ }
56
+ finally {
57
+ // Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
58
+ // never reach the completed hook. Idempotent. Only an INTERRUPTED run (this finally never
59
+ // runs — a crash or SIGTERM deploy) leaves the record for replay; a transport throw is
60
+ // dropped, not retried (safe retry needs an L2 delivery key).
61
+ store.remove(rec.id);
62
+ }
63
+ },
64
+ });
65
+ const submit = (rec, persist) => {
66
+ if (persist) {
67
+ store.add(options.toStored(rec)); // pre-ACK: a failed write throws → 500 → redelivery
68
+ seen?.add(rec.id); // post-persist — recording first could turn a failed write into silent loss
69
+ }
70
+ queue.accept(rec);
71
+ };
72
+ return {
73
+ submit,
74
+ recover() {
75
+ const recovered = store.recover();
76
+ if (recovered.length > 0)
77
+ log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
78
+ for (const stored of recovered)
79
+ submit(options.fromStored(stored), false);
80
+ return recovered;
81
+ },
82
+ idle: () => queue.idle(),
83
+ };
84
+ }