@fastagent-sh/fastagent 0.19.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 (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
@@ -1,9 +1,10 @@
1
- import type { RegistrationOutcome } from "../registration.ts";
1
+ import { type RegistrationOutcome } from "../registration.ts";
2
2
  import { type FeishuCloudKind } from "./cloud.ts";
3
3
  /**
4
4
  * Register `<baseUrl>/<kind>` as the app's event Request URL (webhook mode). Missing credentials print
5
- * the manual instruction instead of failing. `opts` exist for tests: timeouts + `apiBase` (a fake
6
- * platform production derives it from the kind).
5
+ * the manual instruction instead of failing. `opts` carries the attempt budget `--tunnel` takes the
6
+ * default, `deploy --run` passes `DEPLOY_REGISTRATION_ATTEMPTS` (a host starts slower than a tunnel)
7
+ * plus `apiBase`, which is a test's fake platform (production derives it from the kind).
7
8
  *
8
9
  * Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
9
10
  */
@@ -12,8 +13,7 @@ interface FeishuManualRegistration {
12
13
  requestUrl: string;
13
14
  }
14
15
  export interface RegisterFeishuWebhookOptions {
15
- readyTimeoutMs?: number;
16
- readyIntervalMs?: number;
16
+ attempts?: number;
17
17
  retryMs?: number;
18
18
  apiBase?: string;
19
19
  /** Manual fallback after a definitive config error or exhausted retries. Local dev opens this App. */
@@ -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,12 @@
1
+ /**
2
+ * Attachment destination for one conversation. Never rejects an id — but the path it returns is not
3
+ * promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
4
+ * an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
5
+ * `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
6
+ * no platform id approaches, at the cost of the readability this encoding exists to keep.
7
+ */
8
+ export declare function attachmentPath(filesDir: string, conversationId: string | number, fileName: string): {
9
+ dir: string;
10
+ name: string;
11
+ path: string;
12
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Where an inbound attachment lands: `<filesDir>/<conversation>/<file>`.
3
+ *
4
+ * Both halves come from outside the channel, and they are NOT the same question:
5
+ *
6
+ * - The conversation is an id, and turning an arbitrary caller id into a storage name is a job this
7
+ * repo already answers by ENCODING it, never by rejecting it (`piSessionId` in
8
+ * engines/pi/session-store.ts, same shape: a character whitelist under a fixed prefix). An id is
9
+ * the caller's — a route returning `../..` gets an odd directory, not a failed turn, because the
10
+ * place to notice that id is broken is the reply it cannot deliver, not the disk.
11
+ * - The file name is not an id: it is read by a human and by the model, in a path this puts into the
12
+ * prompt. Encoding it would cost that, so it is reduced instead — losing the odd character rather
13
+ * than the readability. A leading dot survives: `.gitignore` is a name, not an escape, and nothing
14
+ * lists this directory, so hiding is not a property anyone here depends on.
15
+ *
16
+ * The encoding makes containment structural rather than checked: `encodeURIComponent` emits no path
17
+ * separator (`/` → `%2F`, `\` → `%5C`, `:` → `%3A`), and the prefix leaves no way to spell `.`, `..`
18
+ * or the empty string.
19
+ *
20
+ * It is lossless for a well-formed id, so `a/b` and `a_b` are different places. Two exceptions, both
21
+ * outside what a platform hands over: ids differing only in malformed UTF-16 (see the substitution
22
+ * below), and ids differing only in case on a case-insensitive filesystem.
23
+ */
24
+ import { resolve, sep } from "node:path";
25
+ /**
26
+ * Attachment destination for one conversation. Never rejects an id — but the path it returns is not
27
+ * promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
28
+ * an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
29
+ * `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
30
+ * no platform id approaches, at the cost of the readability this encoding exists to keep.
31
+ */
32
+ export function attachmentPath(filesDir, conversationId, fileName) {
33
+ // A lone surrogate makes `encodeURIComponent` throw `URIError`, and this function rejecting an id
34
+ // is the one thing it must not do — a permanent bad id would surface as a retryable turn failure.
35
+ // The `u` flag matches by code point, so a valid pair (an emoji in a route's id) is left alone.
36
+ const id = String(conversationId).replace(/\p{Surrogate}/gu, "\uFFFD");
37
+ const dir = resolve(filesDir, `c-${encodeURIComponent(id)}`);
38
+ // The name keeps its own check because it is NOT encoded: separators go, and `resolve` settles
39
+ // whether what is left (`..`, a Windows `D:foo`) still lands in `dir`.
40
+ const cleaned = fileName.replace(/[/\\]/g, "_");
41
+ const name = resolve(dir, cleaned).startsWith(dir + sep) ? cleaned : "file";
42
+ return { dir, name, path: resolve(dir, name) };
43
+ }
@@ -9,6 +9,15 @@ export declare const BUFFER_LINE_MAX_CHARS = 280;
9
9
  * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
10
10
  * each channel's attachment collector caps against this. */
11
11
  export declare const BUFFER_ATTACH_MAX = 3;
12
+ /**
13
+ * The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
14
+ * empty. One renderer for every channel: what the agent is told about un-summoned discussion should
15
+ * not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
16
+ *
17
+ * A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
18
+ * different claim about a different place, not this block with another name.
19
+ */
20
+ export declare function discussionBlock(text: string): string;
12
21
  export interface ContextBuffer<E> {
13
22
  /** Record an un-summoned message. Persists BEFORE returning (pre-ACK; see the module header). */
14
23
  push(placeKey: string, entry: E): void;
@@ -39,6 +39,17 @@ export const BUFFER_LINE_MAX_CHARS = 280;
39
39
  * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
40
40
  * each channel's attachment collector caps against this. */
41
41
  export const BUFFER_ATTACH_MAX = 3;
42
+ /**
43
+ * The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
44
+ * empty. One renderer for every channel: what the agent is told about un-summoned discussion should
45
+ * not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
46
+ *
47
+ * A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
48
+ * different claim about a different place, not this block with another name.
49
+ */
50
+ export function discussionBlock(text) {
51
+ return text ? `[recent group discussion:\n${text}\n]\n\n` : "";
52
+ }
42
53
  export function createContextBuffer(options) {
43
54
  const { path, label, isEntry, line } = options;
44
55
  const load = () => {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
3
+ * nothing about it, so without a window a captured body plus its signed headers replays forever.
4
+ * The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
5
+ */
6
+ /**
7
+ * Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
8
+ * ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
9
+ * signed material, so a value the signature commits to but this cannot read is a reason to refuse,
10
+ * not to wave through.
11
+ */
12
+ export declare function signatureIsFresh(timestamp: string, maxAgeS: number, nowMs?: number): boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
3
+ * nothing about it, so without a window a captured body plus its signed headers replays forever.
4
+ * The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
5
+ */
6
+ /**
7
+ * Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
8
+ * ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
9
+ * signed material, so a value the signature commits to but this cannot read is a reason to refuse,
10
+ * not to wave through.
11
+ */
12
+ export function signatureIsFresh(timestamp, maxAgeS, nowMs = Date.now()) {
13
+ if (!/^\d+$/.test(timestamp))
14
+ return false;
15
+ const seconds = Number(timestamp);
16
+ return Number.isSafeInteger(seconds) && Math.abs(Math.floor(nowMs / 1000) - seconds) <= maxAgeS;
17
+ }
@@ -21,16 +21,16 @@ export async function dispatchStop(control, session, label) {
21
21
  if (!control)
22
22
  return STOP_UNAVAILABLE_NOTICE;
23
23
  try {
24
- const result = await control.dispatch(session, { type: "abort" });
24
+ const result = await control.sessions.get(session).abort();
25
25
  if (result.ok)
26
26
  return STOPPED_NOTICE;
27
27
  if (result.error.code === NO_ACTIVE_RUN_CODE)
28
28
  return NOTHING_RUNNING_NOTICE;
29
- log.warn(`${label} stop dispatch rejected for ${session}: ${result.error.code} — ${result.error.message}`);
29
+ log.warn(`${label} stop rejected for ${session}: ${result.error.code} — ${result.error.message}`);
30
30
  return `⚠️ Could not stop (${result.error.code}).`;
31
31
  }
32
32
  catch (error) {
33
- log.warn(`${label} stop dispatch failed for ${session}: ${String(error)}`);
33
+ log.warn(`${label} stop failed for ${session}: ${String(error)}`);
34
34
  return "⚠️ Could not stop — see the server logs.";
35
35
  }
36
36
  }
@@ -4,4 +4,4 @@ export interface TaskTracker {
4
4
  /** Resolves when every currently-tracked task has settled. */
5
5
  drain(): Promise<void>;
6
6
  }
7
- export declare function createTaskTracker(): TaskTracker;
7
+ export declare function createTaskTracker(label: string): TaskTracker;