@fastagent-sh/fastagent 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +13 -3
  2. package/dist/channels/agentcore-limits.d.ts +7 -0
  3. package/dist/channels/agentcore-limits.js +9 -0
  4. package/dist/channels/agentcore-state.d.ts +83 -0
  5. package/dist/channels/agentcore-state.js +258 -0
  6. package/dist/channels/agentcore.d.ts +98 -0
  7. package/dist/channels/agentcore.js +260 -0
  8. package/dist/channels/busy.d.ts +23 -0
  9. package/dist/channels/busy.js +53 -0
  10. package/dist/channels/context-buffer.d.ts +5 -0
  11. package/dist/channels/context-buffer.js +5 -0
  12. package/dist/channels/feishu/context-buffer.d.ts +13 -8
  13. package/dist/channels/feishu/context-buffer.js +60 -6
  14. package/dist/channels/feishu/feishu-api.js +4 -1
  15. package/dist/channels/feishu/feishu.d.ts +0 -12
  16. package/dist/channels/feishu/feishu.js +143 -54
  17. package/dist/channels/feishu/invoke-turn.js +35 -18
  18. package/dist/channels/feishu/model.d.ts +0 -1
  19. package/dist/channels/feishu/normalize.js +0 -1
  20. package/dist/channels/feishu/parse.d.ts +21 -7
  21. package/dist/channels/feishu/parse.js +24 -7
  22. package/dist/channels/feishu/preview.js +3 -2
  23. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -1
  26. package/dist/channels/feishu/setup-mode.js +27 -1
  27. package/dist/channels/github/github.js +8 -1
  28. package/dist/channels/http.js +1 -1
  29. package/dist/channels/lark/scaffold/channel.ts +9 -8
  30. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  31. package/dist/channels/preview-kit.d.ts +7 -1
  32. package/dist/channels/preview-kit.js +3 -2
  33. package/dist/channels/slack/parse.d.ts +16 -1
  34. package/dist/channels/slack/parse.js +46 -3
  35. package/dist/channels/slack/preview.d.ts +1 -2
  36. package/dist/channels/slack/preview.js +68 -24
  37. package/dist/channels/slack/scaffold/channel.ts +5 -5
  38. package/dist/channels/slack/slack-api.d.ts +3 -23
  39. package/dist/channels/slack/slack-api.js +6 -22
  40. package/dist/channels/slack/slack.d.ts +13 -20
  41. package/dist/channels/slack/slack.js +95 -50
  42. package/dist/channels/state.d.ts +11 -4
  43. package/dist/channels/state.js +19 -12
  44. package/dist/channels/tasks.d.ts +0 -6
  45. package/dist/channels/tasks.js +16 -1
  46. package/dist/channels/telegram/parse.d.ts +0 -7
  47. package/dist/channels/telegram/parse.js +4 -2
  48. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  49. package/dist/channels/telegram/telegram.js +1 -1
  50. package/dist/channels/text.d.ts +14 -0
  51. package/dist/channels/text.js +14 -0
  52. package/dist/channels/thread-participants.d.ts +21 -0
  53. package/dist/channels/thread-participants.js +132 -0
  54. package/dist/channels/turn-queue.js +7 -0
  55. package/dist/cli/add-feishu.d.ts +7 -4
  56. package/dist/cli/add-feishu.js +57 -37
  57. package/dist/cli/add-slack.d.ts +2 -1
  58. package/dist/cli/add-slack.js +6 -11
  59. package/dist/cli/commands/add.js +50 -51
  60. package/dist/cli/commands/attach.js +8 -4
  61. package/dist/cli/commands/chat.js +8 -8
  62. package/dist/cli/commands/deploy.d.ts +1 -1
  63. package/dist/cli/commands/deploy.js +323 -77
  64. package/dist/cli/commands/dev.js +24 -22
  65. package/dist/cli/commands/fire.js +15 -16
  66. package/dist/cli/commands/info.js +36 -29
  67. package/dist/cli/commands/init.d.ts +1 -1
  68. package/dist/cli/commands/init.js +65 -53
  69. package/dist/cli/commands/invoke.js +9 -6
  70. package/dist/cli/commands/login.js +35 -21
  71. package/dist/cli/commands/schedule.js +6 -8
  72. package/dist/cli/commands/start.js +93 -37
  73. package/dist/cli/commands/tool.js +22 -18
  74. package/dist/cli/fail.d.ts +17 -0
  75. package/dist/cli/fail.js +24 -0
  76. package/dist/cli/program.js +57 -36
  77. package/dist/cli/serve.d.ts +26 -6
  78. package/dist/cli/serve.js +62 -15
  79. package/dist/cli/shared.d.ts +15 -2
  80. package/dist/cli/shared.js +30 -18
  81. package/dist/deploy/agentcore/plan.d.ts +117 -0
  82. package/dist/deploy/agentcore/plan.js +721 -0
  83. package/dist/deploy/agentcore/run.d.ts +73 -0
  84. package/dist/deploy/agentcore/run.js +412 -0
  85. package/dist/deploy/agentcore/zip.d.ts +17 -0
  86. package/dist/deploy/agentcore/zip.js +68 -0
  87. package/dist/deploy/container.d.ts +26 -25
  88. package/dist/deploy/container.js +93 -89
  89. package/dist/deploy/docker/plan.d.ts +1 -1
  90. package/dist/deploy/docker/plan.js +12 -17
  91. package/dist/deploy/fly/plan.d.ts +2 -0
  92. package/dist/deploy/fly/plan.js +27 -19
  93. package/dist/deploy/fly/run.d.ts +12 -1
  94. package/dist/deploy/fly/run.js +36 -2
  95. package/dist/deploy/preflight.d.ts +11 -5
  96. package/dist/deploy/preflight.js +217 -65
  97. package/dist/deploy/railway/plan.d.ts +7 -0
  98. package/dist/deploy/railway/plan.js +41 -16
  99. package/dist/deploy/railway/run.d.ts +8 -1
  100. package/dist/deploy/railway/run.js +7 -2
  101. package/dist/deploy/runner.d.ts +5 -2
  102. package/dist/deploy/runner.js +9 -3
  103. package/dist/dev-supervisor.d.ts +11 -8
  104. package/dist/dev-supervisor.js +53 -51
  105. package/dist/engines/pi/auth.d.ts +8 -7
  106. package/dist/engines/pi/auth.js +12 -10
  107. package/dist/engines/pi/channel.d.ts +1 -1
  108. package/dist/engines/pi/channel.js +5 -5
  109. package/dist/engines/pi/chat.js +2 -2
  110. package/dist/engines/pi/config.d.ts +6 -46
  111. package/dist/engines/pi/config.js +21 -108
  112. package/dist/engines/pi/create.d.ts +8 -8
  113. package/dist/engines/pi/create.js +13 -12
  114. package/dist/engines/pi/definition.d.ts +7 -26
  115. package/dist/engines/pi/definition.js +8 -54
  116. package/dist/engines/pi/login.d.ts +1 -1
  117. package/dist/engines/pi/models.d.ts +3 -3
  118. package/dist/engines/pi/models.js +1 -1
  119. package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
  120. package/dist/engines/pi/{workspace.js → open.js} +27 -29
  121. package/dist/engines/pi/session-builder.d.ts +2 -2
  122. package/dist/engines/pi/session-builder.js +11 -11
  123. package/dist/engines/pi/tool.js +4 -0
  124. package/dist/env.d.ts +16 -4
  125. package/dist/env.js +43 -5
  126. package/dist/host/node.d.ts +2 -2
  127. package/dist/loader.d.ts +2 -2
  128. package/dist/loader.js +3 -3
  129. package/dist/log.d.ts +1 -1
  130. package/dist/log.js +1 -1
  131. package/dist/paths.d.ts +138 -0
  132. package/dist/paths.js +326 -0
  133. package/dist/pi.d.ts +1 -1
  134. package/dist/pi.js +2 -2
  135. package/dist/runtime.d.ts +7 -5
  136. package/dist/runtime.js +2 -2
  137. package/dist/scaffold/add-channel.d.ts +7 -3
  138. package/dist/scaffold/add-channel.js +55 -29
  139. package/dist/scaffold/init.d.ts +32 -41
  140. package/dist/scaffold/init.js +161 -185
  141. package/dist/scaffold/templates/env.example +15 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +1 -1
  143. package/dist/scaffold/templates/gitignore +14 -6
  144. package/dist/scaffold/templates/persona.md +4 -2
  145. package/dist/scaffold/templates/secrets.gitignore +5 -0
  146. package/dist/scaffold/templates.d.ts +1 -7
  147. package/dist/scaffold/templates.js +3 -25
  148. package/dist/scaffold/vendor-skill.d.ts +2 -2
  149. package/dist/scaffold/vendor-skill.js +13 -13
  150. package/dist/schedule/discover.js +4 -4
  151. package/dist/schedule/scheduler.d.ts +40 -1
  152. package/dist/schedule/scheduler.js +89 -56
  153. package/dist/schedule/state.js +1 -1
  154. package/dist/schedule/wake-alarm.d.ts +47 -0
  155. package/dist/schedule/wake-alarm.js +136 -0
  156. package/dist/schedule/wakeups.d.ts +1 -0
  157. package/dist/schedule/wakeups.js +18 -0
  158. package/dist/tunnel.d.ts +3 -3
  159. package/dist/tunnel.js +7 -7
  160. package/package.json +4 -1
  161. package/dist/channels/feishu/owned-threads.d.ts +0 -7
  162. package/dist/channels/feishu/owned-threads.js +0 -47
  163. package/dist/channels/slack/owned-threads.d.ts +0 -6
  164. package/dist/channels/slack/owned-threads.js +0 -43
  165. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  166. package/dist/scaffold/templates/gitignore.kit +0 -2
  167. package/dist/workspace.d.ts +0 -9
  168. package/dist/workspace.js +0 -45
@@ -0,0 +1,260 @@
1
+ /**
2
+ * AWS AgentCore Runtime adapter: serve fastagent's whole HTTP surface through the Runtime's service
3
+ * contract. AgentCore gives a container exactly TWO paths — `POST /invocations` (the only ingress,
4
+ * reached via the SigV4 `InvokeAgentRuntime` API) and `GET /ping` (health) — and no public URL, so
5
+ * the deployment fronts webhooks with a thin forwarder Lambda and delivers cron slots from
6
+ * EventBridge Scheduler; both arrive here as an ENVELOPE in the /invocations payload:
7
+ *
8
+ * - `{ kind: "webhook", method, path, headers?, bodyB64? }` — a verbatim webhook request captured
9
+ * by the forwarder. Reconstructed into a real `Request` and dispatched to the SAME channel routes
10
+ * a direct deployment serves — signature verification (Telegram secret token, Feishu signatures)
11
+ * runs unchanged inside the channel. The channel's HTTP response travels back INSIDE the
12
+ * transport reply (`{ status, headers, bodyB64 }`, transport always 200): AgentCore folds a
13
+ * container non-2xx into its own 424 RuntimeClientError, so riding the real status inside the
14
+ * envelope is the only way the forwarder can re-emit it verbatim (a Feishu URL-verification
15
+ * challenge needs the exact body + content-type back).
16
+ * - `{ kind: "schedule-fire", name, slot }` — one cron instant from the external clock. Dispatched
17
+ * to {@link fireScheduleOnce}-shaped `fire` with the slot as the idempotency key (EventBridge
18
+ * delivery is at-least-once; a duplicate slot must not double-fire).
19
+ * - `{ kind: "invoke", session, text }` — the programmatic data plane; streams the invoke back as
20
+ * SSE (AgentCore's streaming response form), reusing the HTTP channel's handler wholesale.
21
+ *
22
+ * `/ping` reports `HealthyBusy` while process-wide background work is in flight (busy.ts) — webhook
23
+ * channels ACK fast and run turns fire-and-forget, and AgentCore ends an idle session, so without
24
+ * this signal a long turn would be killed mid-flight right after its ACK. `Healthy` when idle lets
25
+ * the platform reclaim the microVM (that idle-to-zero IS the point of this deployment).
26
+ */
27
+ import { Buffer } from "node:buffer";
28
+ import { timingSafeEqual } from "node:crypto";
29
+ import { beginWork, onIdle } from "./busy.js";
30
+ import { router } from "../host/node.js";
31
+ import { log } from "../log.js";
32
+ import { rememberWakeAlarmUrl } from "../schedule/wake-alarm.js";
33
+ import { readBodyCapped } from "./body.js";
34
+ import { createInvokeHandler } from "./http.js";
35
+ import { text } from "./respond.js";
36
+ import { MAX_ENVELOPE_BYTES, MAX_WEBHOOK_BODY_BYTES } from "./agentcore-limits.js";
37
+ const unsnapshottedWarning = "[agentcore] this envelope carried no state-snapshot URLs — the state root is LOCAL ONLY and the " +
38
+ "platform erases it on the next deploy (redeploy with a current fastagent to restore durability)";
39
+ const jsonHeaders = { "content-type": "application/json" };
40
+ const json = (body, status) => new Response(`${JSON.stringify(body)}\n`, { status, headers: jsonHeaders });
41
+ /** Compare an untrusted envelope secret without leaking a matching-prefix timing signal. */
42
+ function secretMatches(actual, expected) {
43
+ if (typeof actual !== "string" || expected === undefined)
44
+ return false;
45
+ const actualBytes = Buffer.from(actual);
46
+ const expectedBytes = Buffer.from(expected);
47
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
48
+ }
49
+ /**
50
+ * Build the AgentCore serving surface: `{ "POST /invocations", "GET /ping" }`. The caller merges it
51
+ * over its routes (collision-checked at the mount site, serve.ts) — the inner routes stay mounted
52
+ * too, which is harmless (AgentCore routes only /invocations and /ping into the container) and keeps
53
+ * a local `curl` debug surface.
54
+ */
55
+ export function agentcoreRoutes(options) {
56
+ const { routes, agent, stateRoot, isBusy, fire, stateSync, ingressSecret, onStateReady } = options;
57
+ const dispatch = router(routes);
58
+ const invokeHandler = createInvokeHandler(agent);
59
+ // Snapshot on the 0-in-flight edge: webhook channels ACK fast and finish the turn in the
60
+ // background, so "the request returned" is NOT when the state root settles.
61
+ if (stateSync)
62
+ onIdle(() => stateSync.save());
63
+ let warnedUnsnapshotted = false;
64
+ let stateReadyFired = false;
65
+ const handleInvocation = async (req) => {
66
+ const body = await readBodyCapped(req, MAX_ENVELOPE_BYTES);
67
+ if ("tooLarge" in body)
68
+ return text("envelope too large\n", 413);
69
+ let envelope;
70
+ try {
71
+ envelope = JSON.parse(body.text);
72
+ }
73
+ catch {
74
+ return text("invalid json\n", 400);
75
+ }
76
+ if (envelope === null || typeof envelope !== "object" || typeof envelope.kind !== "string") {
77
+ return text('need { "kind": "webhook" | "schedule-fire" | "invoke" | "wake-poke" | "checkpoint", ... }\n', 400);
78
+ }
79
+ // AUTHENTICATION BOUNDARY. `InvokeAgentRuntime` is an ordinary IAM action, so "reached this
80
+ // handler" proves nothing about the sender. Only an envelope carrying the shared secret is the
81
+ // forwarder's; anything else is the PUBLIC data plane, which may run exactly one kind (`invoke`)
82
+ // and may NOT carry internal fields — riding a `state` or `wake` URL on a public invoke would
83
+ // redirect the state snapshot (auth.json) or the alarm callback (the wake secret) to the caller.
84
+ // Internal fields are DROPPED rather than rejected: a public caller has no business knowing them.
85
+ const trusted = secretMatches(envelope.auth, ingressSecret);
86
+ if (!trusted) {
87
+ if (envelope.kind !== "invoke") {
88
+ log.warn(`[agentcore] rejected an unauthenticated "${envelope.kind}" envelope`);
89
+ return text("forbidden\n", 403);
90
+ }
91
+ envelope.wake = undefined;
92
+ envelope.state = undefined;
93
+ }
94
+ // The forwarder rides its public URL along on every envelope — persist it (write-if-changed) so
95
+ // the wake-alarm sink can call back. Written AFTER the restore below: a stale snapshot copy must
96
+ // not win over the URL this deployment is actually reachable at. A bad persist must not fail the turn.
97
+ const rememberUrl = () => {
98
+ if (typeof envelope.wake?.url !== "string")
99
+ return;
100
+ try {
101
+ rememberWakeAlarmUrl(stateRoot, envelope.wake.url);
102
+ }
103
+ catch (e) {
104
+ log.error(`[agentcore] could not persist the wake-alarm URL: ${String(e)}`);
105
+ }
106
+ };
107
+ // Cross-deploy state: the platform wipes /mnt/state on every version update, so the durable copy
108
+ // must be pulled back BEFORE anything reads it. A failed restore fails the request — serving an
109
+ // empty agent (and then snapshotting that emptiness over the good copy) is the worse outcome.
110
+ if (stateSync) {
111
+ if (envelope.state && typeof envelope.state.getUrl === "string" && typeof envelope.state.putUrl === "string") {
112
+ stateSync.use(envelope.state);
113
+ }
114
+ else if (envelope.kind !== "invoke" && !stateSync.configured() && !warnedUnsnapshotted) {
115
+ // webhook/schedule-fire/wake-poke reach us ONLY through the forwarder, which always mints the
116
+ // pair. Missing = a broken/stale topology whose state dies at the next deploy: say so, loudly,
117
+ // once per process (a direct `invoke` legitimately has none — its session storage is its own).
118
+ warnedUnsnapshotted = true;
119
+ log.warn(unsnapshottedWarning);
120
+ }
121
+ try {
122
+ await stateSync.ready();
123
+ }
124
+ catch (e) {
125
+ log.error(`[agentcore] state restore failed: ${String(e)}`);
126
+ return text(`state restore failed: ${String(e)}\n`, 503);
127
+ }
128
+ }
129
+ rememberUrl();
130
+ // The state root is authoritative only now — anything that must READ it at startup (the wake-alarm
131
+ // reconcile) runs here, once, rather than at boot against a mount the platform just wiped.
132
+ if (onStateReady && !stateReadyFired) {
133
+ stateReadyFired = true;
134
+ onStateReady();
135
+ }
136
+ switch (envelope.kind) {
137
+ case "webhook": {
138
+ const { method, path, query, headers, bodyB64 } = envelope;
139
+ if (typeof method !== "string" || typeof path !== "string" || !path.startsWith("/")) {
140
+ return text('webhook envelope needs { "method": string, "path": "/..." }\n', 400);
141
+ }
142
+ if (typeof bodyB64 === "string" && Buffer.byteLength(bodyB64, "base64") > MAX_WEBHOOK_BODY_BYTES) {
143
+ return json({
144
+ status: 413,
145
+ headers: { "content-type": "text/plain" },
146
+ bodyB64: Buffer.from("payload too large\n").toString("base64"),
147
+ }, 200);
148
+ }
149
+ const inner = new Request(`http://agentcore.local${path}${typeof query === "string" && query !== "" ? `?${query}` : ""}`, {
150
+ method,
151
+ headers: headers ?? {},
152
+ body: typeof bodyB64 === "string" && method !== "GET" && method !== "HEAD"
153
+ ? Buffer.from(bodyB64, "base64")
154
+ : undefined,
155
+ });
156
+ const response = await dispatch(inner);
157
+ // Buffer the channel's ACK (webhook ACKs are small by design — the turn itself runs
158
+ // fire-and-forget) and ride it inside the transport reply, byte-exact.
159
+ const replyBody = Buffer.from(await response.arrayBuffer());
160
+ const replyHeaders = {};
161
+ response.headers.forEach((value, key) => {
162
+ replyHeaders[key] = value;
163
+ });
164
+ const reply = {
165
+ status: response.status,
166
+ headers: replyHeaders,
167
+ bodyB64: replyBody.toString("base64"),
168
+ };
169
+ return json(reply, 200);
170
+ }
171
+ case "schedule-fire": {
172
+ const { name, slot } = envelope;
173
+ if (typeof name !== "string" || typeof slot !== "string" || Number.isNaN(Date.parse(slot))) {
174
+ return text('schedule-fire envelope needs { "name": string, "slot": ISO-date }\n', 400);
175
+ }
176
+ // No fire capability (no schedules in this definition) or an unknown name is deploy drift —
177
+ // an external clock rule outliving the schedule it fired for. 404 keeps it VISIBLE in the
178
+ // clock's logs (a 200 would silently absorb every future fire).
179
+ if (!fire)
180
+ return text(`no schedules in this deployment (schedule-fire "${name}")\n`, 404);
181
+ // The whole agent turn runs inside this request — but the CALLER (the forwarder Lambda) may
182
+ // time out and drop the connection while the turn keeps running server-side. Count it as
183
+ // in-flight work so /ping holds the session (HealthyBusy) for the remainder.
184
+ const workDone = beginWork();
185
+ try {
186
+ const outcome = await fire(name, new Date(slot));
187
+ return json(outcome, 200);
188
+ }
189
+ catch (e) {
190
+ if (e instanceof UnknownScheduleError)
191
+ return text(`${e.message}\n`, 404);
192
+ // A claim-state fault (unreadable/unwritable fires.json) — surface it as the request's
193
+ // failure so the external clock's logs carry it (fail visibly, never a silent absorb).
194
+ log.error(`[agentcore] schedule-fire ${name} failed: ${String(e)}`);
195
+ return text(`schedule-fire failed: ${String(e)}\n`, 500);
196
+ }
197
+ finally {
198
+ workDone();
199
+ }
200
+ }
201
+ case "checkpoint": {
202
+ // Deliberately does NOT wait for idle: the durable turn intent is persisted BEFORE the
203
+ // webhook ACK (turn-store.ts), so a flush right now already carries the interrupted turn —
204
+ // and blocking a deploy on a turn that may run for minutes would be the worse trade.
205
+ if (!stateSync)
206
+ return json({ written: false, reason: "no state sync in this deployment" }, 200);
207
+ try {
208
+ // The reply is the ONLY thing telling the operator whether an in-flight turn was
209
+ // protected, so it reports what actually happened — `written: false` (nothing to write)
210
+ // reads differently from `written: true`, and a failure is a failure.
211
+ return json(await stateSync.checkpoint(), 200);
212
+ }
213
+ catch (e) {
214
+ log.error(`[agentcore] checkpoint failed: ${String(e)}`);
215
+ return text(`checkpoint failed: ${String(e)}\n`, 500);
216
+ }
217
+ }
218
+ case "wake-poke": {
219
+ // The poke's job is DONE by arriving: the invocation woke (or kept awake) the container, and
220
+ // the wake pump (boot drain + 30s poll) fires whatever is due. Nothing to dispatch.
221
+ return json({ ok: true }, 200);
222
+ }
223
+ case "invoke": {
224
+ // Reuse the HTTP channel's handler wholesale (SSE, cancellation, backpressure) by handing it
225
+ // the shape it already validates — one protocol, one implementation.
226
+ const inner = new Request("http://agentcore.local/invoke", {
227
+ method: "POST",
228
+ headers: jsonHeaders,
229
+ body: JSON.stringify({ session: envelope.session, text: envelope.text }),
230
+ });
231
+ return invokeHandler(inner);
232
+ }
233
+ default:
234
+ return text(`unknown envelope kind "${envelope.kind}"\n`, 400);
235
+ }
236
+ };
237
+ // Settle-then-snapshot: when the envelope leaves nothing in flight, its writes are final now (a
238
+ // background turn instead reports through the idle edge above).
239
+ const invocations = async (req) => {
240
+ const response = await handleInvocation(req);
241
+ if (stateSync && !isBusy())
242
+ stateSync.save();
243
+ return response;
244
+ };
245
+ return {
246
+ "POST /invocations": invocations,
247
+ // The Runtime ping contract: Healthy = reclaimable, HealthyBusy = keep the session alive
248
+ // (background turns in flight). No time_of_last_update — the platform tracks status changes
249
+ // itself, and a timestamp advancing every ping would defeat the idle timeout (their docs warn).
250
+ "GET /ping": () => json({ status: isBusy() ? "HealthyBusy" : "Healthy" }, 200),
251
+ };
252
+ }
253
+ /** Thrown by the mount-site `fire` binding when the envelope names a schedule this workspace does
254
+ * not have — the adapter maps it to 404 (deploy drift stays visible in the external clock's logs). */
255
+ export class UnknownScheduleError extends Error {
256
+ constructor(name) {
257
+ super(`unknown schedule "${name}"`);
258
+ this.name = "UnknownScheduleError";
259
+ }
260
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * SHARED process-wide in-flight work signal. Channels ACK a webhook fast and run the turn
3
+ * fire-and-forget on this process's event loop (host/node.ts) — so "is this process busy?" is not
4
+ * derivable from open HTTP requests. The two shared execution primitives (turn-queue chains,
5
+ * task-tracker side tasks) report here; a serving surface that must stay alive while background
6
+ * work runs (the AgentCore adapter's /ping → HealthyBusy) reads it.
7
+ *
8
+ * Deliberately a counter, not a registry: consumers need only "any work in flight?"; keeping the
9
+ * module dependency-free lets both channel primitives import it without cycles.
10
+ */
11
+ /**
12
+ * Mark one unit of background work as started. Returns its completion callback — idempotent, so a
13
+ * caller may safely settle it from multiple cleanup paths (finally + catch) without double-counting.
14
+ */
15
+ export declare function beginWork(): () => void;
16
+ /**
17
+ * Subscribe to the 0-in-flight edge: the moment the process finishes its last background turn.
18
+ * Returns an unsubscribe. Used by the AgentCore adapter to push its state snapshot exactly when the
19
+ * state root has settled and before the platform may reclaim the idle microVM.
20
+ */
21
+ export declare function onIdle(listener: () => void): () => void;
22
+ /** How many units of background work are currently in flight (0 = idle). */
23
+ export declare function activeWork(): number;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * SHARED process-wide in-flight work signal. Channels ACK a webhook fast and run the turn
3
+ * fire-and-forget on this process's event loop (host/node.ts) — so "is this process busy?" is not
4
+ * derivable from open HTTP requests. The two shared execution primitives (turn-queue chains,
5
+ * task-tracker side tasks) report here; a serving surface that must stay alive while background
6
+ * work runs (the AgentCore adapter's /ping → HealthyBusy) reads it.
7
+ *
8
+ * Deliberately a counter, not a registry: consumers need only "any work in flight?"; keeping the
9
+ * module dependency-free lets both channel primitives import it without cycles.
10
+ */
11
+ let inFlight = 0;
12
+ const idleListeners = new Set();
13
+ /**
14
+ * Mark one unit of background work as started. Returns its completion callback — idempotent, so a
15
+ * caller may safely settle it from multiple cleanup paths (finally + catch) without double-counting.
16
+ */
17
+ export function beginWork() {
18
+ inFlight += 1;
19
+ let done = false;
20
+ return () => {
21
+ if (done)
22
+ return;
23
+ done = true;
24
+ inFlight -= 1;
25
+ if (inFlight > 0)
26
+ return;
27
+ for (const listener of idleListeners) {
28
+ // A listener fault must not corrupt the counter or starve the others (this runs inside a
29
+ // channel's `finally`): report and carry on.
30
+ try {
31
+ listener();
32
+ }
33
+ catch (e) {
34
+ console.error(`[fastagent] idle listener failed: ${String(e)}`);
35
+ }
36
+ }
37
+ };
38
+ }
39
+ /**
40
+ * Subscribe to the 0-in-flight edge: the moment the process finishes its last background turn.
41
+ * Returns an unsubscribe. Used by the AgentCore adapter to push its state snapshot exactly when the
42
+ * state root has settled and before the platform may reclaim the idle microVM.
43
+ */
44
+ export function onIdle(listener) {
45
+ idleListeners.add(listener);
46
+ return () => {
47
+ idleListeners.delete(listener);
48
+ };
49
+ }
50
+ /** How many units of background work are currently in flight (0 = idle). */
51
+ export function activeWork() {
52
+ return inFlight;
53
+ }
@@ -1,3 +1,8 @@
1
+ /** Per-message bound INSIDE that budget. The fold is a digest of many messages competing for one
2
+ * allowance, so the job here is fairness, not fidelity: one rambler must not price out everyone who
3
+ * spoke after them. 280 against 4000 keeps at least ~14 messages in a full buffer. A referent — the
4
+ * single message the asker points at — is the opposite job and takes REFERENT_MAX_CODE_POINTS. */
5
+ export declare const BUFFER_LINE_MAX_CHARS = 280;
1
6
  /** How many buffered files and images (each, most recent first) a summon pulls in with the folded
2
7
  * discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
3
8
  * group posting many attachments between summons. Skipped ones must be counted into the prompt
@@ -28,6 +28,11 @@ import { loadStateFile, saveStateFile } from "./state.js";
28
28
  * sparse-but-relevant lines, a busy burst is capped). The `line` renderer is the eviction cost
29
29
  * basis: the budget must price what the fold actually renders, or it would systematically overrun. */
30
30
  const BUFFER_MAX_CHARS = 4000;
31
+ /** Per-message bound INSIDE that budget. The fold is a digest of many messages competing for one
32
+ * allowance, so the job here is fairness, not fidelity: one rambler must not price out everyone who
33
+ * spoke after them. 280 against 4000 keeps at least ~14 messages in a full buffer. A referent — the
34
+ * single message the asker points at — is the opposite job and takes REFERENT_MAX_CODE_POINTS. */
35
+ export const BUFFER_LINE_MAX_CHARS = 280;
31
36
  /** How many buffered files and images (each, most recent first) a summon pulls in with the folded
32
37
  * discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
33
38
  * group posting many attachments between summons. Skipped ones must be counted into the prompt
@@ -1,9 +1,3 @@
1
- /**
2
- * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
- * ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
4
- * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
- * concrete thread root) and folded into the next answered turn in that place.
6
- */
7
1
  import { type ContextBuffer } from "../context-buffer.ts";
8
2
  import type { NormalizedFeishuMessage } from "./model.ts";
9
3
  export interface FeishuBufferedResource {
@@ -23,8 +17,19 @@ export interface FeishuBufferEntry {
23
17
  export interface FeishuBufferedRef extends FeishuBufferedResource {
24
18
  from: string;
25
19
  }
26
- /** Main-chat discussion stays in the chat bucket; a topic uses its stable root id (thread id fallback). */
27
- export declare function feishuBufferPlaceKey(conversation: Pick<NormalizedFeishuMessage["conversation"], "chatId" | "rootId" | "threadId">): string;
20
+ /**
21
+ * The place a message belongs to: the main chat, or a thread within it. Keyed by `thread_id`, the
22
+ * platform's own identity for a side conversation — NOT `root_id`, which tracks the reply chain and
23
+ * can differ between messages of one thread (which would split a thread's context across buckets).
24
+ * A quoted reply outside a thread carries a root but is main-chat discussion, so it buckets there.
25
+ *
26
+ * Its own namespace, deliberately: this names a BUCKET of undelivered text, while `parse.ts`'s
27
+ * `placeKey` names a SESSION. Nothing here claims anything about that session — unlike thread
28
+ * participation, which asserts "the agent answered into this memory" and is therefore keyed by the
29
+ * session itself. Re-keying either one leaves the other correct, and converging them now would strand
30
+ * live buckets for no gain (Slack keeps its own shape for the same reason).
31
+ */
32
+ export declare function feishuBufferPlaceKey(conversation: Pick<NormalizedFeishuMessage["conversation"], "chatId" | "threadId">): string;
28
33
  /** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
29
34
  export declare function feishuBufferText(text: string): string;
30
35
  /**
@@ -4,21 +4,34 @@
4
4
  * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
5
  * concrete thread root) and folded into the next answered turn in that place.
6
6
  */
7
- import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
7
+ import { log } from "../../log.js";
8
+ import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
9
+ import { loadStateFile, saveStateFile } from "../state.js";
10
+ import { truncateCodePointPrefix } from "../text.js";
8
11
  function bufferLine(entry) {
9
12
  const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
10
13
  .filter(Boolean)
11
14
  .join(", ");
12
15
  return `${entry.sender} (${meta}): ${entry.body}`;
13
16
  }
14
- /** Main-chat discussion stays in the chat bucket; a topic uses its stable root id (thread id fallback). */
17
+ /**
18
+ * The place a message belongs to: the main chat, or a thread within it. Keyed by `thread_id`, the
19
+ * platform's own identity for a side conversation — NOT `root_id`, which tracks the reply chain and
20
+ * can differ between messages of one thread (which would split a thread's context across buckets).
21
+ * A quoted reply outside a thread carries a root but is main-chat discussion, so it buckets there.
22
+ *
23
+ * Its own namespace, deliberately: this names a BUCKET of undelivered text, while `parse.ts`'s
24
+ * `placeKey` names a SESSION. Nothing here claims anything about that session — unlike thread
25
+ * participation, which asserts "the agent answered into this memory" and is therefore keyed by the
26
+ * session itself. Re-keying either one leaves the other correct, and converging them now would strand
27
+ * live buckets for no gain (Slack keeps its own shape for the same reason).
28
+ */
15
29
  export function feishuBufferPlaceKey(conversation) {
16
- const topic = conversation.rootId ?? conversation.threadId;
17
- return topic ? `${conversation.chatId}:root:${topic}` : conversation.chatId;
30
+ return conversation.threadId ? `${conversation.chatId}:thread:${conversation.threadId}` : conversation.chatId;
18
31
  }
19
32
  /** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
20
33
  export function feishuBufferText(text) {
21
- return text.replace(/\s+/g, " ").trim().slice(0, 280);
34
+ return truncateCodePointPrefix(text.replace(/\s+/g, " ").trim(), BUFFER_LINE_MAX_CHARS);
22
35
  }
23
36
  function resourceIdentity(resource) {
24
37
  return `${resource.messageId}\u0000${resource.key}`;
@@ -67,6 +80,47 @@ function isEntry(value) {
67
80
  resources(entry.files) &&
68
81
  resources(entry.images));
69
82
  }
83
+ /**
84
+ * Buckets from the pre-participant-model keying (`<chat>:root:<root_id>`) can never be produced again —
85
+ * a place is `<chat>` or `<chat>:thread:<thread_id>` — so nothing could ever fold or clear them, and
86
+ * they would hold chat content on disk forever. Dropped here, before the buffer loads, so the shared
87
+ * kernel never learns about a key shape one channel retired.
88
+ *
89
+ * TWO one-time losses, both accepted and both logged by count. (1) The retired shape covered every
90
+ * thread bucket and every main-chat quoted-reply bucket, so buffered discussion in threads does not
91
+ * survive the upgrade — it becomes unreachable BECAUSE of the re-keying, not before it. (2)
92
+ * `turns.json` persists each in-flight turn's `bufferKey` verbatim and this runs before turn recovery,
93
+ * so a turn spanning the upgrade finds its bucket already gone. Sparing referenced keys would couple
94
+ * the buffer to the turn store to protect a single upgrade, and would not help (1) at all.
95
+ *
96
+ * PERMANENT, unlike the `owned-threads.json` cleanup it otherwise resembles. That one leaves an inert
97
+ * orphan file, so deleting it a release later is free; this one is what stops user chat content
98
+ * lingering, and a deployment that skips from before the model to well after it would never run an
99
+ * expired version of this code. The standing cost is one key scan at load, and nothing when no retired
100
+ * key is present.
101
+ */
102
+ function dropRetiredBuckets(path, label) {
103
+ const raw = loadStateFile(path);
104
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
105
+ return;
106
+ const live = Object.entries(raw).filter(([placeKey]) => !placeKey.includes(":root:"));
107
+ const dropped = Object.keys(raw).length - live.length;
108
+ if (dropped === 0)
109
+ return;
110
+ log.info(`${label} dropped ${dropped} context bucket(s) with a retired key shape`);
111
+ try {
112
+ saveStateFile(path, Object.fromEntries(live));
113
+ }
114
+ catch (error) {
115
+ log.warn(`${label} could not rewrite ${path} after dropping retired buckets: ${String(error)}`);
116
+ }
117
+ }
70
118
  export function createFeishuContextBuffer(path, label) {
71
- return createGenericContextBuffer({ path, label, isEntry, line: bufferLine });
119
+ dropRetiredBuckets(path, label);
120
+ return createGenericContextBuffer({
121
+ path,
122
+ label,
123
+ isEntry,
124
+ line: bufferLine,
125
+ });
72
126
  }
@@ -236,7 +236,10 @@ export function createFeishuApi(opts) {
236
236
  await call("deleteMessage", "DELETE", `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`);
237
237
  },
238
238
  async getMessage(messageId) {
239
- const data = await call("getMessage", "GET", `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`);
239
+ const data = await call("getMessage", "GET",
240
+ // Pin the id type: callers match mentions/sender against open_ids, so the response's id shape
241
+ // must not depend on the platform's default staying open_id.
242
+ `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}?user_id_type=open_id`);
240
243
  return data.data?.items?.[0];
241
244
  },
242
245
  async downloadResource(messageId, fileKey, type) {
@@ -10,16 +10,6 @@ interface FeishuChannelBaseOptions {
10
10
  appId: string;
11
11
  /** App Secret (same page) — drives both ingress authentication and outbound API calls. */
12
12
  appSecret: string;
13
- /** Direct-message context + delivery policy. `threaded` (default) gives every top-level p2p message
14
- * its own session, creates a platform thread for the answer, and routes later thread messages back
15
- * by root message id. `continuous` keeps one session per p2p chat and sends ordinary unquoted replies. */
16
- directMessageSession?: "continuous" | "threaded";
17
- /** Group-message context + delivery policy. `threaded` (default) gives every top-level summoned
18
- * message its own session and platform thread; later bare user messages in that managed thread answer
19
- * in the same root session, while @other-only discussion buffers. `continuous` preserves the legacy
20
- * chat/topic sessions (`chat_id` / `chat_id:thread_id`). Buffering and bare continuations require
21
- * `im:message.group_msg`. */
22
- groupMessageSession?: "continuous" | "threaded";
23
13
  /** Policy: whether/where to answer an event (return null to ignore). Defaults to {@link defaultFeishuRoute}. */
24
14
  route?: (event: FeishuMessageEvent) => FeishuRoute | null;
25
15
  /** Customer-facing failure text for the chat (the dev-facing full `details` always go to the operator
@@ -30,8 +20,6 @@ interface FeishuChannelBaseOptions {
30
20
  * `https://open.feishu.cn`; Lark factories default to `https://open.larksuite.com`. Named to match
31
21
  * the other channels (telegram/slack). */
32
22
  apiBaseUrl?: string;
33
- /** @deprecated Alias of {@link apiBaseUrl}; kept for existing feishu/lark channel files. */
34
- baseUrl?: string;
35
23
  /** How long (ms) a turn waits before its reply-quoted "⏳ Queued" card mounts. Defaults to 0
36
24
  * (immediate); the same card is later taken over by the live preview/final answer. */
37
25
  queueNoticeDelayMs?: number;