@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
@@ -1,10 +1,11 @@
1
1
  import { INVALID_COMMAND_CODE, SESSIONS_UNAVAILABLE_CODE, UNSUPPORTED_CAPABILITY_CODE, UPDATE_FIELDS, } from "../session.js";
2
- import { timingSafeEqual } from "node:crypto";
3
2
  import { parseRouteKey, withoutBody } from "./serve.js";
4
3
  import { log } from "../log.js";
5
4
  import { readBodyCapped } from "./body.js";
6
- import { MAX_BODY_BYTES, createInvokeHandler, sseHeartbeat } from "./http.js";
5
+ import { MAX_BODY_BYTES, createInvokeHandler } from "./http.js";
6
+ import { sseResponse } from "./sse.js";
7
7
  import { text } from "./respond.js";
8
+ import { secretEquals } from "./secret.js";
8
9
  /** The prefix this plane OWNS: everything under it is the plane's to answer. */
9
10
  const CONTROL_PREFIX = "/control";
10
11
  /** The one variable segment in this plane's paths: a percent-encoded session id. Written into route
@@ -266,13 +267,10 @@ export function controlPlaneRoutes(control, options) {
266
267
  if (!token)
267
268
  throw new Error("createControlPlane: a bearer token is required (empty tokens are not a mode)");
268
269
  const epoch = crypto.randomUUID();
269
- // Timing-safe: the bearer token is this surface's ONLY auth (and the --tunnel warning names it
270
- // as the sole protection on a public URL) — a plain === would leak byte-by-byte via timing.
271
- const expected = Buffer.from(`Bearer ${token}`);
272
- const authed = (req) => {
273
- const header = Buffer.from(req.headers.get("authorization") ?? "");
274
- return header.length === expected.length && timingSafeEqual(header, expected);
275
- };
270
+ // The bearer token is this surface's ONLY auth (and the --tunnel warning names it as the sole
271
+ // protection on a public URL).
272
+ const expected = `Bearer ${token}`;
273
+ const authed = (req) => secretEquals(req.headers.get("authorization"), expected);
276
274
  const invokeHandler = options.agent ? createInvokeHandler(options.agent) : undefined;
277
275
  /** Authenticate, then hand the handler the pieces every route wants: the request, the URL (for
278
276
  * query parameters), and the session the PATH named — `""` on the routes that have no id in
@@ -404,60 +402,13 @@ export function controlPlaneRoutes(control, options) {
404
402
  }
405
403
  }),
406
404
  [`GET /control/sessions/${SESSION_SEGMENT}/events`]: guard((_req, _url, session) => {
407
- const iterator = control.sessions.get(session).events()[Symbol.asyncIterator]();
408
- // EAGER registration: issue the first pull NOW, before the Response (and thus the client's
409
- // fetch resolution) exists — hub subscription is registered synchronously inside next(), so
410
- // "the client saw response headers" implies "events from that moment on will be delivered".
411
- // Shrinks the subscribe/backfill race to network reordering instead of a full pull cycle.
412
- let pending = iterator.next();
413
- // Observed here so a client that disconnects BEFORE the first pull cannot turn a rejecting
414
- // events iterator (this is the neutral contract face — any implementation may reject) into a
415
- // process-killing unhandledRejection; awaiting `pending` at pull still surfaces the error.
416
- pending.catch(() => { });
417
405
  let seq = 0;
418
- let stopHeartbeat = () => { };
419
- const encoder = new TextEncoder();
420
- const stream = new ReadableStream({
421
- start(controller) {
422
- stopHeartbeat = sseHeartbeat(controller);
423
- },
424
- async pull(controller) {
425
- let next;
426
- try {
427
- next = await (pending ?? iterator.next());
428
- }
429
- catch (error) {
430
- // A rejecting implementation (the neutral contract permits it) must not leak its
431
- // subscription: an errored stream never gets cancel(), so the unsubscribe and the
432
- // heartbeat teardown happen HERE.
433
- stopHeartbeat();
434
- void iterator.return?.(undefined)?.catch?.(() => { });
435
- controller.error(error);
436
- return;
437
- }
438
- pending = undefined;
439
- if (next.done) {
440
- stopHeartbeat();
441
- controller.close();
442
- return;
443
- }
444
- const wire = { sessionId: session, epoch, seq: seq++, event: next.value };
445
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(wire)}\n\n`));
446
- },
447
- cancel() {
448
- stopHeartbeat();
449
- // Same neutral-contract defense as the pull error path: a rejecting return() on client
450
- // disconnect must not become a process-level unhandledRejection.
451
- void iterator.return?.(undefined)?.catch?.(() => { });
452
- },
453
- });
454
- return new Response(stream, {
455
- headers: {
456
- "content-type": "text/event-stream",
457
- "cache-control": "no-cache",
458
- connection: "keep-alive",
459
- },
460
- });
406
+ return sseResponse(control.sessions.get(session).events(), (event) => ({
407
+ sessionId: session,
408
+ epoch,
409
+ seq: seq++,
410
+ event,
411
+ }));
461
412
  }),
462
413
  };
463
414
  }
@@ -53,7 +53,6 @@ export declare function loadChannels(dir: string, ctx: ChannelContext): Promise<
53
53
  routes: Routes;
54
54
  longConnections: LoadedLongConnectionChannel[];
55
55
  routeChannels: string[];
56
- longConnectionChannels: string[];
57
56
  collisions: ChannelCollision[];
58
57
  failures: ModuleLoadFailure[];
59
58
  }>;
@@ -85,7 +85,6 @@ export async function loadChannels(dir, ctx) {
85
85
  const routes = {};
86
86
  const longConnections = [];
87
87
  const routeChannels = [];
88
- const longConnectionChannels = [];
89
88
  const collisions = [];
90
89
  for (const { name, label, file, mod } of modules) {
91
90
  try {
@@ -96,7 +95,6 @@ export async function loadChannels(dir, ctx) {
96
95
  name: channel.name,
97
96
  connect: (signal) => channel.connect(ctx, signal),
98
97
  });
99
- longConnectionChannels.push(name);
100
98
  continue;
101
99
  }
102
100
  if (typeof mod.default !== "function") {
@@ -124,5 +122,5 @@ export async function loadChannels(dir, ctx) {
124
122
  failures.push({ label, file, message: error.message });
125
123
  }
126
124
  }
127
- return { routes, longConnections, routeChannels, longConnectionChannels, collisions, failures };
125
+ return { routes, longConnections, routeChannels, collisions, failures };
128
126
  }
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
+ * ../kit/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
+ */
1
7
  import { type ContextBuffer } from "../kit/context-buffer.ts";
2
8
  import type { NormalizedFeishuMessage } from "./model.ts";
3
9
  export interface FeishuBufferedResource {
@@ -4,9 +4,7 @@
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 { log } from "../../log.js";
8
7
  import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
9
- import { loadStateFile, saveStateFile } from "../kit/state.js";
10
8
  import { truncateCodePointPrefix } from "../kit/text.js";
11
9
  function bufferLine(entry) {
12
10
  const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
@@ -80,43 +78,7 @@ function isEntry(value) {
80
78
  resources(entry.files) &&
81
79
  resources(entry.images));
82
80
  }
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
- }
118
81
  export function createFeishuContextBuffer(path, label) {
119
- dropRetiredBuckets(path, label);
120
82
  return createGenericContextBuffer({
121
83
  path,
122
84
  label,
@@ -1,5 +1,3 @@
1
- /** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
2
- export declare function timingSafeEqualStr(a: string, b: string): boolean;
3
1
  /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
4
2
  * input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
5
3
  * guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
@@ -14,13 +14,8 @@
14
14
  * Comparisons are constant-time (timingSafeEqual) so neither the signature check nor the verification-
15
15
  * token check leaks a timing signal.
16
16
  */
17
- import { createDecipheriv, createHash, timingSafeEqual } from "node:crypto";
18
- /** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
19
- export function timingSafeEqualStr(a, b) {
20
- const ab = Buffer.from(a);
21
- const bb = Buffer.from(b);
22
- return ab.length === bb.length && timingSafeEqual(ab, bb);
23
- }
17
+ import { createDecipheriv, createHash } from "node:crypto";
18
+ import { secretEquals } from "../secret.js";
24
19
  /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
25
20
  * input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
26
21
  * guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
@@ -39,5 +34,5 @@ export function eventSignature(encryptKey, timestamp, nonce, rawBody) {
39
34
  }
40
35
  /** Whether a request's signature headers verify against the raw body (constant-time). */
41
36
  export function verifySignature(encryptKey, headers, rawBody) {
42
- return timingSafeEqualStr(eventSignature(encryptKey, headers.timestamp, headers.nonce, rawBody), headers.signature);
37
+ return secretEquals(headers.signature, eventSignature(encryptKey, headers.timestamp, headers.nonce, rawBody));
43
38
  }
@@ -14,10 +14,8 @@
14
14
  * 5. Every failure is a {@link FeishuApiError} naming the call; self-description is a property of the
15
15
  * error type, not per-call-site string assembly.
16
16
  *
17
- * On top of the pipeline sit thin typed methods (send/reply/edit/card/resource) adding one is adding
18
- * a wrapper, not wire code. SDK tripwire: if this surface ever needs WebSocket long-connection ingress
19
- * or grows past ~a dozen methods, adopt @larksuiteoapi/node-sdk instead of growing it — the methods
20
- * here are shape-compatible with the SDK's `client.im.*` style, so the policy layer survives that swap.
17
+ * Typed methods cover messages, cards, resources and app configuration. Proactive tools share this
18
+ * transport through shared-api.ts. The official SDK handles WebSocket ingress in ws-ingress.ts.
21
19
  */
22
20
  import { mkdir, writeFile } from "node:fs/promises";
23
21
  import { attachmentPath } from "../kit/attachment-path.js";
@@ -12,17 +12,18 @@ import { isAbsolute, join } from "node:path";
12
12
  import { log } from "../../log.js";
13
13
  import { readBodyCapped } from "../body.js";
14
14
  import { text } from "../respond.js";
15
+ import { secretEquals } from "../secret.js";
15
16
  import { createSeenRing } from "../kit/seen.js";
16
17
  import { createTaskTracker } from "../kit/tasks.js";
17
18
  import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
18
19
  import { signatureIsFresh } from "../kit/signature.js";
19
20
  import { dispatchStop, isStopText } from "../kit/stop-command.js";
20
- import { createTurnQueue } from "../kit/turn-queue.js";
21
- import { commitAnsweredTurn, createTurnStore } from "../kit/turn-store.js";
21
+ import { createTurnRunner } from "../kit/turn-runner.js";
22
+ import { createTurnStore } from "../kit/turn-store.js";
22
23
  import { discussionBlock } from "../kit/context-buffer.js";
23
24
  import { FEISHU_CLOUD } from "./cloud.js";
24
25
  import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
25
- import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
26
+ import { decryptEvent, verifySignature } from "./crypto.js";
26
27
  import { invokeFeishuTurn } from "./invoke-turn.js";
27
28
  import { createFeishuApi } from "./feishu-api.js";
28
29
  import { normalizeFeishuMessage } from "./normalize.js";
@@ -31,6 +32,7 @@ import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE
31
32
  import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
32
33
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
33
34
  import { connectFeishuWs } from "./ws-ingress.js";
35
+ import { registerFeishuApi } from "./shared-api.js";
34
36
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
35
37
  export { defaultFeishuRoute, feishuEnvelope };
36
38
  /** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
@@ -79,16 +81,6 @@ export function feishuChannel(opts) {
79
81
  export function feishuWebSocketChannel(opts) {
80
82
  return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
81
83
  }
82
- /** The participant model removed the session modes (docs/design/participant-model.md §12). An upgraded
83
- * workspace still passing one would otherwise start fine and silently get different placement AND a
84
- * different memory boundary — the one breaking change most likely to be hit, and invisible. */
85
- function rejectRemovedSessionOptions(opts, factoryName) {
86
- const removed = ["directMessageSession", "groupMessageSession"].filter((name) => opts[name] !== undefined);
87
- if (removed.length > 0) {
88
- throw new Error(`${factoryName} no longer accepts ${removed.join(" / ")}: a chat is one session and a thread is another, ` +
89
- "and the summon rule no longer depends on the mode — remove the option (see docs/design/participant-model.md)");
90
- }
91
- }
92
84
  function createFeishuRuntimeFactory(profile, opts, factoryName) {
93
85
  const { appId, appSecret, route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS } = opts;
94
86
  const baseUrl = opts.apiBaseUrl ?? profile.apiBase;
@@ -228,10 +220,6 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
228
220
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
229
221
  // Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
230
222
  const sideTasks = createTaskTracker(label);
231
- const toStored = (r) => {
232
- const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
233
- return { ...intent, attempts: 0 };
234
- };
235
223
  const targetOf = (r) => ({
236
224
  chatId: r.chatId,
237
225
  replyTo: r.replyTo,
@@ -242,18 +230,28 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
242
230
  replyTo: r.queueReplyTo,
243
231
  replyInThread: r.replyInThread,
244
232
  });
245
- // In-memory: the pending queue-preview mount per turn. Immediate by default; with an explicit delay,
246
- // it mounts only if the turn is still waiting when the timer fires and is cancelled unsent otherwise.
247
- // `done` settles either way, awaited at dequeue so the runner reliably receives the mounted preview
248
- // instead of racing it and double-posting.
249
- const notices = new Map();
250
- const queue = createTurnQueue({
233
+ // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
234
+ // not just an operator log line. Take over its queue preview in place if present (else send fresh) —
235
+ // leaving it pinned at "Queued" while sending a separate failure would double-post.
236
+ const notifyDropped = (r) => {
237
+ const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
238
+ void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
239
+ };
240
+ const runner = createTurnRunner({
251
241
  label,
252
- // Queue feedback: when this session already has a turn running/queued, a silent wait reads as
253
- // "the bot ignored me" once the current turn runs long — mount that turn's preview early with a
254
- // queue status. It reply-quotes the exact source message (including p2p), then the runner mutates
255
- // the SAME card/text into Thinking final answer. Best-effort and post-ACK: a failed mount is a
256
- // log line, never a failed event delivery; the turn later mounts its normal preview.
242
+ store,
243
+ buffer,
244
+ seen,
245
+ toStored: ({ preview: _live, ...intent }) => ({ ...intent, attempts: 0 }),
246
+ fromStored: ({ attempts: _a, ...intent }) => ({ ...intent, preview: undefined }),
247
+ bufferKey: (rec) => rec.bufferKey,
248
+ where: (rec) => `chat=${rec.chatId}`,
249
+ // Queue feedback: mount that turn's preview early with a queue status. It reply-quotes the exact
250
+ // source message (including p2p), then the runner mutates the SAME card/text into Thinking →
251
+ // final answer. Immediate by default; with an explicit delay it mounts only if the turn is still
252
+ // waiting when the timer fires and is cancelled unsent otherwise. Best-effort and post-ACK: a
253
+ // failed mount is a log line, never a failed event delivery; the turn later mounts its normal
254
+ // preview.
257
255
  onQueuedBehind: (rec) => {
258
256
  let fired = false;
259
257
  let settle = () => { };
@@ -271,8 +269,9 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
271
269
  const timer = queueNoticeDelayMs > 0 ? setTimeout(mount, queueNoticeDelayMs) : undefined;
272
270
  if (timer === undefined)
273
271
  mount();
274
- notices.set(rec.id, {
275
- // Cancel is a no-op once mounting started — the send is in flight and `done` settles with it.
272
+ return {
273
+ done,
274
+ // A no-op once mounting started — the send is in flight and `done` settles with it.
276
275
  cancel: () => {
277
276
  if (!fired) {
278
277
  if (timer !== undefined)
@@ -280,103 +279,47 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
280
279
  settle();
281
280
  }
282
281
  },
283
- done,
284
- });
282
+ };
285
283
  },
286
- run: async (rec) => {
287
- // Runs at DEQUEUE time (serialized). The turn's queue wait is over: cancel a not-yet-mounted
288
- // preview (fast turnover skips the Queued frame), then settle so rec.preview is final — in the
289
- // common path this await is instant. BEFORE the ceiling check so drop/defer can take it over too.
290
- const notice = notices.get(rec.id);
291
- notice?.cancel();
292
- await notice?.done;
293
- notices.delete(rec.id);
294
- // Count this execution against the durable record (poison-turn ceiling) before running it again.
295
- const decision = store.startAttempt(rec.id);
296
- if (decision === "exceeded") {
297
- notifyDropped(rec);
298
- return;
299
- }
300
- if (decision === "defer") {
301
- // Couldn't record the attempt (disk failure): skip this cycle; a restart replays it. Do not
302
- // recall an existing queue preview (the client exposes a confusing tombstone): settle it in
303
- // place to an honest delayed status. The eventual replay mounts a fresh preview.
304
- if (rec.preview !== undefined) {
305
- void settleFeishuPreview(api, targetOf(rec), rec.preview, DEFERRED_PLACEHOLDER).catch((e) => log.warn(`${label} could not update a deferred turn's queue preview: ${String(e)}`));
306
- }
307
- return;
284
+ // Do not recall an existing queue preview (the client exposes a confusing tombstone): settle it in
285
+ // place to an honest delayed status. The eventual replay mounts a fresh preview.
286
+ onDeferred: (rec) => {
287
+ if (rec.preview !== undefined) {
288
+ void settleFeishuPreview(api, targetOf(rec), rec.preview, DEFERRED_PLACEHOLDER).catch((e) => log.warn(`${label} could not update a deferred turn's queue preview: ${String(e)}`));
308
289
  }
309
- const startedAt = Date.now();
310
- log.info(`${label} turn start: turn=${rec.id} session=${rec.session} chat=${rec.chatId}`);
311
- // Snapshot background discussion at dequeue. Commit only this snapshot on `completed`, so a
312
- // message arriving while the turn runs remains buffered for the next answered turn.
313
- // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
314
- // this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
315
- // would instead couple otherwise-independent root sessions and require failure rollback.
316
- const { text: recent, consumed } = buffer.peek(rec.bufferKey);
290
+ },
291
+ notifyDropped,
292
+ execute: (rec, discussion, onCompleted) => {
317
293
  // PEEK and never commit: the room still owes this discussion to its OWN memory (§8).
294
+ // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
295
+ // the thread's snapshot before either commits it. That fan-out loses nothing; claiming by buffer
296
+ // key would instead couple otherwise-independent root sessions and require failure rollback.
318
297
  const room = rec.roomBufferKey !== undefined ? buffer.peek(rec.roomBufferKey) : undefined;
319
298
  const roomBlock = room?.text
320
299
  ? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
321
300
  : "";
322
- const prompt = `${roomBlock}${discussionBlock(recent)}${rec.baseText}`;
301
+ const prompt = `${roomBlock}${discussionBlock(discussion.text)}${rec.baseText}`;
323
302
  // Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
324
303
  // attachments win the slots.
325
- const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
304
+ const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...discussion.consumed], {
326
305
  images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
327
306
  files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
328
307
  });
329
308
  // Recorded at ingress (see submit) — never re-derived from the session key, which may be a
330
309
  // routed OPAQUE id that only looks like a place key.
331
310
  const parentSession = rec.parentSession;
332
- try {
333
- await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
334
- api,
335
- chatId: rec.chatId,
336
- filesDir: join(stateHome, "files"),
337
- label,
338
- appId,
339
- ...(parentSession !== undefined ? { parentSession } : {}),
340
- }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey: rec.bufferKey, consumed })), api, targetOf(rec), formatError, rec.preview, label);
341
- log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
342
- }
343
- catch (error) {
344
- log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
345
- }
346
- finally {
347
- // Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
348
- // never reach the completed hook above. Idempotent — a second remove is a no-op. Only an
349
- // INTERRUPTED run (this finally never runs — a crash or SIGTERM deploy) leaves the record for
350
- // replay; a transport throw is dropped, not retried (safe retry needs an L2 delivery key).
351
- store.remove(rec.id);
352
- }
311
+ return streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
312
+ api,
313
+ chatId: rec.chatId,
314
+ filesDir: join(stateHome, "files"),
315
+ label,
316
+ appId,
317
+ ...(parentSession !== undefined ? { parentSession } : {}),
318
+ }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, onCompleted), api, targetOf(rec), formatError, rec.preview, label);
353
319
  },
354
320
  });
355
- // Accept a turn: persist its intent before the ACK, then record the platform delivery id and enqueue
356
- // it. The ordering is deliberate: recording first could turn a failed intent write into silent loss
357
- // when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
358
- const submit = (rec, persist) => {
359
- if (persist) {
360
- store.add(toStored(rec)); // failed write → HTTP/WS 500 → platform re-push
361
- seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
362
- }
363
- queue.accept(rec);
364
- };
365
- // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
366
- // not just an operator log line. Take over its queue preview in place if present (else send fresh) —
367
- // leaving it pinned at "Queued" while sending a separate failure would double-post.
368
- const notifyDropped = (r) => {
369
- const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
370
- void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
371
- };
372
- // Re-enqueue turns a prior crash left mid-flight (ACKed but unfinished). Synchronous at construction:
373
- // the queue runs them on the next tick. The execution ceiling is enforced per turn at dequeue.
374
- const recovered = store.recover();
375
- if (recovered.length > 0)
376
- log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
377
- let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
378
- for (const { attempts: _a, ...intent } of recovered)
379
- submit({ ...intent, preview: undefined }, false);
321
+ registerFeishuApi(stateRoot, kind, api);
322
+ let seqCounter = runner.recover().reduce((max, r) => Math.max(max, r.seq), 0);
380
323
  // Who the agent has heard in a thread decides whether a bare message addresses it (participant
381
324
  // model §3), and it comes from what this channel observed — see thread-participants.ts.
382
325
  let warnedUnidentified = false;
@@ -524,7 +467,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
524
467
  const baseText = r.text ?? cloudEnvelope(event, kind);
525
468
  if (baseText.trim() === "" && images.length === 0 && files.length === 0)
526
469
  return;
527
- submit({
470
+ runner.submit({
528
471
  id: m.message_id,
529
472
  seq: ++seqCounter, // arrival order; the turn store replays by it
530
473
  session,
@@ -577,7 +520,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
577
520
  threadParticipants.merge(heard.key, { agentSpoke: true, humans: [heard.speaker] });
578
521
  }
579
522
  };
580
- return { acceptEvent, turnsIdle: () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined) };
523
+ return { acceptEvent, turnsIdle: () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined) };
581
524
  };
582
525
  }
583
526
  function createFeishuWebhookRoutes(profile, opts, runtime) {
@@ -656,7 +599,7 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
656
599
  (typeof envelope.header?.token === "string"
657
600
  ? envelope.header.token
658
601
  : undefined);
659
- if (!token || !timingSafeEqualStr(token, verificationToken)) {
602
+ if (!secretEquals(token, verificationToken)) {
660
603
  log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
661
604
  return text("invalid token\n", 401);
662
605
  }
@@ -676,7 +619,6 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
676
619
  return { [`POST /${kind}`]: handler };
677
620
  }
678
621
  export function buildFeishuChannel(profile, opts, factoryName) {
679
- rejectRemovedSessionOptions(opts, factoryName);
680
622
  const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
681
623
  return (ctx) => {
682
624
  if (!opts.verificationToken) {
@@ -686,7 +628,6 @@ export function buildFeishuChannel(profile, opts, factoryName) {
686
628
  };
687
629
  }
688
630
  export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
689
- rejectRemovedSessionOptions(opts, factoryName);
690
631
  const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
691
632
  return {
692
633
  name: `${profile.kind} websocket`,
@@ -35,9 +35,10 @@ export declare function senderId(sender: FeishuSender | undefined): string | und
35
35
  *
36
36
  * Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
37
37
  * every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
38
- * would answer into the same memory. The length bound is the FILENAME the id becomes (sessions.ts
39
- * percent-encodes it, so each `:` costs three), and the worst case here brand + a 35-char chat id +
40
- * a 36-char thread id — encodes to well under 100 bytes against the filesystem's 255.
38
+ * would answer into the same memory. The length bound is the FILENAME the id becomes (session-store.ts
39
+ * `piSessionId` escapes every character outside `[A-Za-z0-9.-]` as `_XX` / `_uXXXX`, so each `:` costs
40
+ * three), and the worst case here — brand + a 35-char chat id + a 36-char thread id — encodes to well
41
+ * under 100 bytes against the filesystem's 255.
41
42
  */
42
43
  export declare function placeKey(kind: string, message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
43
44
  /** The canonical Feishu-branded prompt envelope. */
@@ -30,9 +30,10 @@ export function senderId(sender) {
30
30
  *
31
31
  * Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
32
32
  * every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
33
- * would answer into the same memory. The length bound is the FILENAME the id becomes (sessions.ts
34
- * percent-encodes it, so each `:` costs three), and the worst case here brand + a 35-char chat id +
35
- * a 36-char thread id — encodes to well under 100 bytes against the filesystem's 255.
33
+ * would answer into the same memory. The length bound is the FILENAME the id becomes (session-store.ts
34
+ * `piSessionId` escapes every character outside `[A-Za-z0-9.-]` as `_XX` / `_uXXXX`, so each `:` costs
35
+ * three), and the worst case here — brand + a 35-char chat id + a 36-char thread id — encodes to well
36
+ * under 100 bytes against the filesystem's 255.
36
37
  */
37
38
  export function placeKey(kind, message) {
38
39
  const chat = `${kind}:${message.chat_id}`;
@@ -54,7 +55,7 @@ export function cloudEnvelope(event, tag) {
54
55
  from ? `from ${from}` : undefined,
55
56
  // The message's own id is LOAD-BEARING, not decoration: it is the only way this message's id
56
57
  // enters the session transcript, and session inheritance locates a thread's branch point by
57
- // searching the parent transcript for exactly these ids (scope.branchHints — sessions.ts).
58
+ // searching the parent transcript for exactly these ids (scope.branchHints — session-inheritance.ts).
58
59
  // Remove it and every thread quietly inherits from the room's present instead of the branch
59
60
  // point. It also lets the model name what it is answering in a busy chat.
60
61
  `msg ${message.message_id}`,
@@ -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. */