@fastagent-sh/fastagent 0.12.1 → 0.14.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 (151) hide show
  1. package/README.md +6 -4
  2. package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
  3. package/dist/channels/feishu/bootstrap-token.js +94 -0
  4. package/dist/channels/feishu/card.d.ts +32 -0
  5. package/dist/channels/feishu/card.js +66 -0
  6. package/dist/channels/feishu/cloud.d.ts +17 -0
  7. package/dist/channels/feishu/cloud.js +19 -0
  8. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  9. package/dist/channels/feishu/context-buffer.js +133 -0
  10. package/dist/channels/feishu/crypto.d.ts +15 -0
  11. package/dist/channels/feishu/crypto.js +43 -0
  12. package/dist/channels/feishu/feishu-api.d.ts +108 -0
  13. package/dist/channels/feishu/feishu-api.js +325 -0
  14. package/dist/channels/feishu/feishu.d.ts +46 -0
  15. package/dist/channels/feishu/feishu.js +472 -0
  16. package/dist/channels/feishu/invoke-turn.d.ts +65 -0
  17. package/dist/channels/feishu/invoke-turn.js +157 -0
  18. package/dist/channels/feishu/model.d.ts +97 -0
  19. package/dist/channels/feishu/model.js +9 -0
  20. package/dist/channels/feishu/normalize.d.ts +22 -0
  21. package/dist/channels/feishu/normalize.js +132 -0
  22. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  23. package/dist/channels/feishu/owned-threads.js +47 -0
  24. package/dist/channels/feishu/parse.d.ts +43 -0
  25. package/dist/channels/feishu/parse.js +65 -0
  26. package/dist/channels/feishu/preview.d.ts +36 -0
  27. package/dist/channels/feishu/preview.js +387 -0
  28. package/dist/channels/feishu/register-app.d.ts +70 -0
  29. package/dist/channels/feishu/register-app.js +141 -0
  30. package/dist/channels/feishu/register-webhook.d.ts +22 -0
  31. package/dist/channels/feishu/register-webhook.js +106 -0
  32. package/dist/channels/feishu/scaffold/channel.ts +41 -0
  33. package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
  34. package/dist/channels/feishu/seen.d.ts +5 -0
  35. package/dist/channels/feishu/seen.js +47 -0
  36. package/dist/channels/feishu/text.d.ts +13 -0
  37. package/dist/channels/feishu/text.js +63 -0
  38. package/dist/channels/lark/lark.d.ts +15 -0
  39. package/dist/channels/lark/lark.js +10 -0
  40. package/dist/channels/lark/onboard.d.ts +39 -0
  41. package/dist/channels/lark/onboard.js +58 -0
  42. package/dist/channels/lark/scaffold/channel.ts +39 -0
  43. package/dist/channels/lark/scaffold/lark-send.ts +87 -0
  44. package/dist/channels/registration.d.ts +15 -0
  45. package/dist/channels/registration.js +1 -0
  46. package/dist/channels/{telegram/state.js → state.js} +6 -4
  47. package/dist/channels/telegram/context-buffer.js +1 -1
  48. package/dist/channels/telegram/register-webhook.d.ts +4 -1
  49. package/dist/channels/telegram/register-webhook.js +17 -26
  50. package/dist/channels/telegram/telegram.js +2 -2
  51. package/dist/channels/telegram/turn-store.d.ts +8 -21
  52. package/dist/channels/telegram/turn-store.js +11 -130
  53. package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
  54. package/dist/channels/turn-store.d.ts +42 -0
  55. package/dist/channels/turn-store.js +139 -0
  56. package/dist/channels/wait-health.d.ts +6 -0
  57. package/dist/channels/wait-health.js +27 -0
  58. package/dist/cli/commands/add.d.ts +9 -0
  59. package/dist/cli/commands/add.js +142 -0
  60. package/dist/cli/commands/chat.d.ts +3 -0
  61. package/dist/cli/commands/chat.js +16 -0
  62. package/dist/cli/commands/deploy.d.ts +13 -0
  63. package/dist/cli/commands/deploy.js +338 -0
  64. package/dist/cli/commands/dev.d.ts +11 -0
  65. package/dist/cli/commands/dev.js +76 -0
  66. package/dist/cli/commands/fire.d.ts +7 -0
  67. package/dist/cli/commands/fire.js +45 -0
  68. package/dist/cli/commands/info.d.ts +7 -0
  69. package/dist/cli/commands/info.js +108 -0
  70. package/dist/cli/commands/init.d.ts +8 -0
  71. package/dist/cli/commands/init.js +81 -0
  72. package/dist/cli/commands/invoke.d.ts +7 -0
  73. package/dist/cli/commands/invoke.js +29 -0
  74. package/dist/cli/commands/login.d.ts +6 -0
  75. package/dist/cli/commands/login.js +63 -0
  76. package/dist/cli/commands/models.d.ts +1 -0
  77. package/dist/cli/commands/models.js +15 -0
  78. package/dist/cli/commands/schedule.d.ts +12 -0
  79. package/dist/cli/commands/schedule.js +89 -0
  80. package/dist/cli/commands/start.d.ts +10 -0
  81. package/dist/cli/commands/start.js +90 -0
  82. package/dist/cli/commands/tool.d.ts +1 -0
  83. package/dist/cli/commands/tool.js +37 -0
  84. package/dist/cli/fail.d.ts +19 -0
  85. package/dist/cli/fail.js +32 -0
  86. package/dist/cli/kernel.d.ts +89 -0
  87. package/dist/cli/kernel.js +190 -0
  88. package/dist/cli/program.d.ts +11 -0
  89. package/dist/cli/program.js +421 -0
  90. package/dist/cli/serve.d.ts +28 -0
  91. package/dist/cli/serve.js +90 -0
  92. package/dist/cli/shared.d.ts +24 -0
  93. package/dist/cli/shared.js +116 -0
  94. package/dist/cli-add-feishu.d.ts +8 -0
  95. package/dist/cli-add-feishu.js +223 -0
  96. package/dist/cli.js +8 -1284
  97. package/dist/deploy/container.js +10 -6
  98. package/dist/deploy/docker/plan.d.ts +45 -0
  99. package/dist/deploy/docker/plan.js +139 -0
  100. package/dist/deploy/docker/run.d.ts +40 -0
  101. package/dist/deploy/docker/run.js +126 -0
  102. package/dist/deploy/fly/plan.d.ts +1 -1
  103. package/dist/deploy/fly/plan.js +15 -4
  104. package/dist/deploy/fly/run.d.ts +7 -4
  105. package/dist/deploy/fly/run.js +26 -5
  106. package/dist/deploy/preflight.js +4 -3
  107. package/dist/deploy/railway/plan.d.ts +1 -1
  108. package/dist/deploy/railway/plan.js +17 -5
  109. package/dist/deploy/railway/run.d.ts +6 -3
  110. package/dist/deploy/railway/run.js +26 -4
  111. package/dist/deploy/registration-gate.d.ts +20 -0
  112. package/dist/deploy/registration-gate.js +20 -0
  113. package/dist/deploy/runner.d.ts +4 -1
  114. package/dist/deploy/runner.js +1 -0
  115. package/dist/deploy/secrets.d.ts +10 -9
  116. package/dist/deploy/secrets.js +15 -14
  117. package/dist/dev-supervisor.js +2 -1
  118. package/dist/engines/pi/auth.js +160 -46
  119. package/dist/engines/pi/chat.js +80 -7
  120. package/dist/engines/pi/config.d.ts +12 -3
  121. package/dist/engines/pi/config.js +16 -1
  122. package/dist/engines/pi/create.d.ts +14 -6
  123. package/dist/engines/pi/create.js +52 -16
  124. package/dist/engines/pi/harness.d.ts +16 -1
  125. package/dist/engines/pi/harness.js +77 -1
  126. package/dist/engines/pi/invoke.d.ts +1 -1
  127. package/dist/engines/pi/invoke.js +37 -2
  128. package/dist/engines/pi/login.js +1 -1
  129. package/dist/engines/pi/search-tools.d.ts +10 -0
  130. package/dist/engines/pi/search-tools.js +138 -0
  131. package/dist/engines/pi/tool-context.d.ts +28 -0
  132. package/dist/engines/pi/tool-context.js +8 -0
  133. package/dist/engines/pi/tool.d.ts +32 -1
  134. package/dist/engines/pi/tool.js +42 -1
  135. package/dist/engines/pi/workspace.d.ts +4 -1
  136. package/dist/engines/pi/workspace.js +3 -1
  137. package/dist/feishu.d.ts +2 -0
  138. package/dist/feishu.js +2 -0
  139. package/dist/lark.d.ts +3 -0
  140. package/dist/lark.js +3 -0
  141. package/dist/open-url.d.ts +2 -0
  142. package/dist/open-url.js +6 -0
  143. package/dist/pi.d.ts +2 -1
  144. package/dist/scaffold/add-channel.d.ts +9 -5
  145. package/dist/scaffold/add-channel.js +75 -7
  146. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  147. package/dist/tunnel.d.ts +9 -6
  148. package/dist/tunnel.js +48 -31
  149. package/package.json +18 -4
  150. /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
  151. /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Canonical Feishu live-preview rendering (also reused by Lark compatibility). The preview is ONE
3
+ * streaming CARD (create entity → mount it with a reply/send → stream full-text snapshots at its
4
+ * markdown element with a strictly increasing `sequence`; the client renders the typewriter effect);
5
+ * on completion the same card is settled in place with the final answer (streaming off). Streaming
6
+ * updates ride the cardkit quota (50 QPS per app, 10 QPS per card entity, no edit ceiling) — NOT the
7
+ * 5 QPS per-chat message quota or
8
+ * the 20-edit cap on text messages, which is why the preview is a card and not an edited text message.
9
+ *
10
+ * A queued turn mounts this same card early with its queue status, reply-quoted to that turn's source
11
+ * message; when execution starts the preview takes the entity over in place. This mirrors Telegram's
12
+ * one-message lifecycle without trying to change a text message into a card (which the platform does
13
+ * not support), and keeps multiple queued asks attributable even if their card mounts race visually.
14
+ *
15
+ * Fallback tier (fail visibly, degrade per turn): if the card cannot be created or mounted, the turn
16
+ * runs with a TEXT placeholder and NO live updates (text edits are capped at 20 per message, so the
17
+ * text tier spends them only on terminal writes); if the platform closes streaming mid-turn (idle
18
+ * timeout), the preview freezes and the settle still lands. The final write is authoritative either
19
+ * way, mirroring the telegram preview's terminal-write matrix (completed/failed/abnormal ×
20
+ * settle/delete+send/suppress).
21
+ */
22
+ import { setTimeout as sleep } from "node:timers/promises";
23
+ import { log } from "../../log.js";
24
+ import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
25
+ import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
26
+ import { truncateCodePointPrefix, truncateCodePointSuffix, truncateUtf8 } from "./text.js";
27
+ /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
28
+ export function defaultErrorMessage(failed) {
29
+ return failed.retryable ? "⚠️ Temporary problem — please try again." : "⚠️ Sorry, something went wrong.";
30
+ }
31
+ /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
32
+ * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
33
+ * animates between snapshots).
34
+ * Doubles as the answer-preview aging window (see answerView). */
35
+ const STREAM_THROTTLE_MS = 1000;
36
+ /** Max length of a tool's arg preview in the live view. */
37
+ const TOOL_ARG_MAX = 48;
38
+ /** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
39
+ const THINKING_PREVIEW = 280;
40
+ /** The placeholder shown before any reasoning/tool/text arrives. */
41
+ const THINKING_PLACEHOLDER = "💭 Thinking…";
42
+ /** One-line, truncated: collapse whitespace so a multi-line command/arg stays on one line. */
43
+ function clip(s) {
44
+ const one = s.replace(/\s+/g, " ").trim();
45
+ return truncateCodePointPrefix(one, TOOL_ARG_MAX);
46
+ }
47
+ /**
48
+ * A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
49
+ * rather than just `🔧 read`. Generic (the channel knows no tool schemas): show the salient value — the
50
+ * first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
51
+ */
52
+ function summarizeArgs(args) {
53
+ if (args === null || typeof args !== "object" || Array.isArray(args))
54
+ return clip(String(args));
55
+ const values = Object.values(args);
56
+ const primary = values.find((v) => typeof v === "string" || typeof v === "number");
57
+ if (primary !== undefined)
58
+ return clip(String(primary));
59
+ return values.length > 0 ? clip(JSON.stringify(args)) : "";
60
+ }
61
+ /** Cap a live view to the card budget, PREFIX-STABLE: the streaming client animates only when the old
62
+ * text is a prefix of the new, so an over-budget view freezes at its head rather than sliding a tail
63
+ * window (which would redraw the whole card every frame). The full answer still lands at settle. */
64
+ function capBytes(s, maxBytes) {
65
+ return truncateUtf8(s, maxBytes);
66
+ }
67
+ /**
68
+ * The terminal-write POLICY: resolve the preview into `text`. One card → settle it in place (final
69
+ * markdown, streaming off); an over-budget answer settles the card with its first chunk and sends the
70
+ * rest as follow-up messages. A failed settle falls back to delete + fresh send, so no "Thinking…" card
71
+ * is left pinned above the answer. Text tier → ONE edit into the final text (or delete + fresh sends
72
+ * when it doesn't fit). No preview → fresh send. EMPTY text = "say nothing" → just delete the preview.
73
+ */
74
+ async function finalize(api, target, preview, text, seq) {
75
+ if (text.trim() === "") {
76
+ if (preview.kind !== "none")
77
+ await api.deleteMessage(preview.messageId).catch(() => { });
78
+ return;
79
+ }
80
+ if (preview.kind === "card") {
81
+ const [head, ...rest] = chunkFeishuText(text, CARD_MARKDOWN_MAX_BYTES);
82
+ let settled = false;
83
+ try {
84
+ await api.updateCard(preview.cardId, finalCardJson(head ?? ""), seq());
85
+ settled = true;
86
+ }
87
+ catch {
88
+ // Settle failed (card expired / rejected) — fall through to delete + fresh send below.
89
+ }
90
+ if (settled) {
91
+ // Threaded continuations must keep reply_in_thread; continuous top-level group replies intentionally
92
+ // avoid repeating the quote on every chunk. sendText owns the same distinction for its own chunking.
93
+ // A continuation failure propagates: the card is already authoritative, so deleting it and sending
94
+ // the full answer again would deterministically duplicate every continuation that already landed.
95
+ const continuationTarget = target.replyInThread ? target : { chatId: target.chatId };
96
+ for (const chunk of rest)
97
+ await api.sendText(continuationTarget, chunk);
98
+ return;
99
+ }
100
+ }
101
+ if (preview.kind === "text") {
102
+ if (chunkFeishuText(text).length === 1) {
103
+ try {
104
+ await api.editTextMessage(preview.messageId, text);
105
+ return;
106
+ }
107
+ catch {
108
+ // Edit failed (edit window / count / policy) — fall through to delete + fresh send below.
109
+ }
110
+ }
111
+ }
112
+ if (preview.kind !== "none")
113
+ await api.deleteMessage(preview.messageId).catch(() => { });
114
+ await api.sendText(target, text);
115
+ }
116
+ /**
117
+ * Mount one preview message: preferably a streaming card entity, with a static text message as the
118
+ * visible fallback. Queue feedback and ordinary turn startup share this constructor so a queued card
119
+ * has exactly the same shape the stream pump expects to take over later.
120
+ */
121
+ export async function mountFeishuPreview(api, target, initial, label = "[feishu]") {
122
+ try {
123
+ const cardId = await api.createCard(streamingCardJson(initial));
124
+ const content = cardEntityContent(cardId);
125
+ const mountOnce = () => target.replyTo !== undefined
126
+ ? api.replyMessage(target.replyTo, "interactive", content, { replyInThread: target.replyInThread })
127
+ : api.sendMessage(target.chatId, "interactive", content);
128
+ let messageId;
129
+ // Field-observed: the mount can reject a JUST-minted card id (code 230099 / "cardid is invalid")
130
+ // — the entity is not yet visible to the IM side (eventual consistency between cardkit and IM).
131
+ // That specific rejection gets a short backoff and another try before degrading; anything else
132
+ // degrades immediately.
133
+ for (let attempt = 1;; attempt++) {
134
+ try {
135
+ messageId = await mountOnce();
136
+ break;
137
+ }
138
+ catch (e) {
139
+ if (attempt >= 3 || !/230099|11310|cardid is invalid/i.test(String(e)))
140
+ throw e;
141
+ log.warn(`${label} mount rejected the fresh card (card=${cardId}, attempt ${attempt}) — retrying: ${String(e)}`);
142
+ await sleep(attempt * 400);
143
+ }
144
+ }
145
+ if (messageId === undefined)
146
+ throw new Error("interactive send returned ok without a message_id");
147
+ return { kind: "card", cardId, messageId };
148
+ }
149
+ catch (e) {
150
+ // Card tier failed — degrade to a text placeholder with NO live updates (the text tier's 20-edit
151
+ // cap is spent on terminal writes only). Visible: the operator learns why the preview is static.
152
+ log.warn(`${label} streaming card unavailable — live preview degrades to a static placeholder: ${String(e)}`);
153
+ const messageId = target.replyTo !== undefined
154
+ ? await api.replyMessage(target.replyTo, "text", JSON.stringify({ text: initial }), {
155
+ replyInThread: target.replyInThread,
156
+ })
157
+ : await api.sendMessage(target.chatId, "text", JSON.stringify({ text: initial }));
158
+ if (messageId === undefined)
159
+ throw new Error("text preview send returned ok without a message_id");
160
+ return { kind: "text", messageId };
161
+ }
162
+ }
163
+ /** Settle an already-mounted queue preview without starting an Agent stream (the poison/defer paths).
164
+ * Card and text tiers both change in place; only a missing/failed preview sends a fresh message. */
165
+ export async function settleFeishuPreview(api, target, preview, text) {
166
+ let sequence = 0;
167
+ await finalize(api, target, preview ?? { kind: "none" }, text, () => ++sequence);
168
+ }
169
+ /**
170
+ * Consume one turn's event stream into a Feishu-compatible chat, live (see the module header for the preview
171
+ * model). Preview updates are best-effort (logged once if they fail); the final write is authoritative
172
+ * and surfaces a real failure (bad credentials, etc.). `initialPreview`, when present, is the queued
173
+ * turn's already-mounted card/text message: the pump and terminal write mutate that same message rather
174
+ * than recalling it and posting another reply.
175
+ */
176
+ export async function streamFeishuReply(events, api, target, formatError, initialPreview, label = "[feishu]") {
177
+ const tools = [];
178
+ const toolIndexById = new Map();
179
+ let thinking = "";
180
+ let answer = "";
181
+ let answerPreviewSince;
182
+ const mark = { running: "…", ok: "✓", error: "✗" };
183
+ const toolView = () => tools.map((t) => `🔧 ${t.label} ${mark[t.status]}`).join("\n");
184
+ // Reasoning is process, not the answer: shown (capped to its tail) in the live preview only, never
185
+ // in the settled final card (which is `answer` alone).
186
+ const thinkingView = () => {
187
+ const t = thinking.replace(/\s+/g, " ").trim();
188
+ if (t === "")
189
+ return "";
190
+ return `💭 ${truncateCodePointSuffix(t, THINKING_PREVIEW)}`;
191
+ };
192
+ // The answer is hidden until its first delta has aged one STREAM_THROTTLE_MS: the pump's leading-edge
193
+ // flush would otherwise turn the very first content delta (often a lone character) into its own frame
194
+ // — the short-reply flicker. Aging is anchored at delta ARRIVAL (set in the event loop, not here) so
195
+ // an in-flight update can't skew the clock; a turn completing within the window settles directly.
196
+ const answerView = () => {
197
+ if (answer.trim() === "" || answerPreviewSince === undefined)
198
+ return "";
199
+ return Date.now() - answerPreviewSince >= STREAM_THROTTLE_MS ? answer : "";
200
+ };
201
+ const view = () => {
202
+ const v = [thinkingView(), toolView(), answerView()]
203
+ .filter((s) => s.trim() !== "")
204
+ .join("\n\n")
205
+ .trim();
206
+ return capBytes(v === "" ? THINKING_PLACEHOLDER : v, CARD_MARKDOWN_MAX_BYTES);
207
+ };
208
+ // The live preview is ONE message: either the queue card/text handed in by the wiring, or a preview
209
+ // mounted lazily on this turn's first flush. `sequence` must increase strictly per card — the single-
210
+ // writer pump guarantees it by construction. A queue card has had no updates yet, so sequence starts
211
+ // at zero in both paths.
212
+ let preview = initialPreview ?? { kind: "none" };
213
+ let setupAttempted = initialPreview !== undefined;
214
+ let sequence = 0;
215
+ const nextSeq = () => ++sequence;
216
+ let streamDead = false; // the platform closed streaming (idle timeout) — freeze the live view
217
+ let finalized = false; // a terminal write (completed/failed) ran — the finally skips its orphan cleanup
218
+ let lastSent = "";
219
+ const flushPreview = async () => {
220
+ const text = view();
221
+ if (!setupAttempted) {
222
+ setupAttempted = true;
223
+ preview = await mountFeishuPreview(api, target, text, label);
224
+ lastSent = text;
225
+ return;
226
+ }
227
+ if (preview.kind !== "card" || streamDead)
228
+ return; // text tier / dead stream: frozen until the terminal write
229
+ if (text === lastSent)
230
+ return; // skip an unchanged snapshot
231
+ lastSent = text;
232
+ try {
233
+ await api.updateCardElement(preview.cardId, ANSWER_ELEMENT_ID, text, nextSeq());
234
+ }
235
+ catch (e) {
236
+ if (isCardStreamingClosed(e)) {
237
+ // The platform closed streaming (idle timeout). Freeze the live view; the settle write replaces
238
+ // the whole entity (streaming off) and still lands.
239
+ streamDead = true;
240
+ log.warn(`${label} card streaming closed mid-turn — preview frozen; the final answer still lands`);
241
+ return;
242
+ }
243
+ throw e;
244
+ }
245
+ };
246
+ // ── Live-preview pump: a SINGLE serialized writer. ──────────────────────────────────────────
247
+ // Events mutate state (thinking / tools / answer) and mark the preview dirty; the pump pushes the
248
+ // LATEST view() with at most ONE update in flight, paced by a throttle. One-in-flight also guarantees
249
+ // the card's strictly-increasing `sequence` lands in order (no concurrent frames).
250
+ let dirty = false;
251
+ let pumping = false;
252
+ let stopped = false;
253
+ let previewErrLogged = false;
254
+ let pumpDone;
255
+ let wakeThrottle; // set while the pump is mid-throttle; finish() cuts it short
256
+ const runPump = async () => {
257
+ pumping = true;
258
+ try {
259
+ while (dirty && !stopped) {
260
+ dirty = false;
261
+ try {
262
+ await flushPreview();
263
+ }
264
+ catch (e) {
265
+ // Best-effort preview (the final write is authoritative), but a failing update must be visible —
266
+ // log once per turn so a never-rendering preview is diagnosable, not silent.
267
+ if (!previewErrLogged) {
268
+ previewErrLogged = true;
269
+ log.warn(`${label} live preview failed (final reply still sends): ${String(e)}`);
270
+ }
271
+ }
272
+ if (dirty && !stopped) {
273
+ // Pace + coalesce a burst into one snapshot. Interruptible: finish() cuts this short so the
274
+ // final write is not delayed by up to STREAM_THROTTLE_MS after the turn completes.
275
+ await new Promise((resolve) => {
276
+ const t = setTimeout(resolve, STREAM_THROTTLE_MS);
277
+ wakeThrottle = () => {
278
+ clearTimeout(t);
279
+ resolve();
280
+ };
281
+ });
282
+ wakeThrottle = undefined;
283
+ }
284
+ }
285
+ }
286
+ finally {
287
+ pumping = false;
288
+ }
289
+ };
290
+ // Mark the preview dirty and ensure the single writer is running (an update already in flight picks
291
+ // up the new state on its next loop). Synchronous — callers never await a network write.
292
+ const touch = () => {
293
+ dirty = true;
294
+ if (!pumping)
295
+ pumpDone = runPump();
296
+ };
297
+ touch(); // mount the "💭 Thinking…" preview immediately
298
+ // Stop the pump and await any in-flight update, so the final write below is strictly the LAST one to
299
+ // the preview (no stale frame landing after the answer).
300
+ const finish = async () => {
301
+ stopped = true;
302
+ wakeThrottle?.(); // cut an in-flight throttle so the final write is not delayed up to STREAM_THROTTLE_MS
303
+ await pumpDone?.catch(() => { });
304
+ };
305
+ /** Terminal write, whatever tier the preview reached. */
306
+ const settle = async (text) => {
307
+ await finalize(api, target, preview, text, nextSeq);
308
+ };
309
+ try {
310
+ for await (const e of events) {
311
+ if (e.type === "text") {
312
+ answer += e.delta;
313
+ if (answerPreviewSince === undefined && answer.trim() !== "")
314
+ answerPreviewSince = Date.now();
315
+ touch();
316
+ }
317
+ else if (e.type === "thinking") {
318
+ thinking += e.delta;
319
+ touch();
320
+ }
321
+ else if (e.type === "tool_started") {
322
+ const arg = summarizeArgs(e.args);
323
+ toolIndexById.set(e.id, tools.length);
324
+ tools.push({ label: arg ? `${e.name} ${arg}` : e.name, status: "running" });
325
+ touch();
326
+ }
327
+ else if (e.type === "tool_ended") {
328
+ const i = toolIndexById.get(e.id);
329
+ const t = i === undefined ? undefined : tools[i];
330
+ if (t)
331
+ t.status = e.isError ? "error" : "ok";
332
+ touch();
333
+ }
334
+ else if (e.type === "completed") {
335
+ await finish();
336
+ // Settle the preview into the final answer; the persisted card is the answer alone — the
337
+ // process (thinking/tools) was preview-only. Mark finalized BEFORE delivering: the terminal was
338
+ // reached, so a delivery failure here is a plain failure, not an "abnormal exit" (which would
339
+ // wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
340
+ finalized = true;
341
+ await settle(answer.trim() !== "" ? answer : "(no reply)");
342
+ return;
343
+ }
344
+ else if (e.type === "failed") {
345
+ await finish();
346
+ // Two audiences: the chat (customer-facing — formatError, neutral by default) and the operator
347
+ // log (dev-facing — the full details, via the throw below + the handler's catch). Same terminal
348
+ // write as completed; an empty notice deletes the preview (suppress = no residue). Best-effort —
349
+ // we throw below regardless.
350
+ finalized = true;
351
+ {
352
+ const msg = formatError({ details: e.details, retryable: e.retryable }) ?? "";
353
+ try {
354
+ await settle(msg);
355
+ }
356
+ catch (deliveryError) {
357
+ // Preserve the Agent failure as the primary error below, but keep the broken final hop in
358
+ // the operator-visible chain — otherwise the log falsely implies the user saw the notice.
359
+ log.error(`${label} failed to deliver the agent-failure notice: ${String(deliveryError)}`);
360
+ }
361
+ }
362
+ throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
363
+ }
364
+ }
365
+ throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
366
+ }
367
+ finally {
368
+ await finish();
369
+ // Abnormal exit (stream ended without a terminal, the generator threw, or the consumer abandoned):
370
+ // no terminal write ran. Show the SAME neutral notice a `failed` event would — the preview may show
371
+ // real partial work, so don't delete it silently, and don't leave the user in silence. A suppressing
372
+ // onError still collapses to a delete (finalize on empty text).
373
+ if (!finalized) {
374
+ // retryable:false — an abnormal end (no terminal / a throw) is of UNKNOWN retryability, so use the
375
+ // neutral "something went wrong" default rather than promising "try again" that may not help.
376
+ const notice = formatError({ details: "the turn ended without completing", retryable: false }) ?? "";
377
+ try {
378
+ await settle(notice);
379
+ }
380
+ catch (deliveryError) {
381
+ // The stream's original throw remains primary; this explicit line records that the user-facing
382
+ // terminal notice failed too instead of silently breaking the responsibility chain.
383
+ log.error(`${label} failed to deliver the abnormal-turn notice: ${String(deliveryError)}`);
384
+ }
385
+ }
386
+ }
387
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * One-click app creation ("scan to create") — the OAuth 2.0 Device Authorization Grant (RFC 8628)
3
+ * flow the platform provides for agent apps: `begin` returns a one-time verification URL the user
4
+ * opens in Feishu/Lark and confirms (the platform pre-configures the agent app template: bot
5
+ * capability, messaging scopes, event subscriptions); polling returns the new app's credentials.
6
+ *
7
+ * Hand-rolled on fetch, no SDK: the wire protocol is two form-encoded POSTs to the accounts endpoint
8
+ * plus RFC 8628's polling error dance — shared verbatim by all four official SDKs (node/python/java/go),
9
+ * which makes it a de-facto stable surface even though only the SDKs document it. Provenance:
10
+ * larksuite/node-sdk `scene/registration` (registerApp). If the platform ever moves this behind
11
+ * something non-trivial (signed payloads, websockets), adopt the official SDK instead of chasing it —
12
+ * the same tripwire as feishu-api.ts.
13
+ *
14
+ * The scanning user's tenant decides the brand: a Lark-tenant user flips polling to the Lark accounts
15
+ * domain mid-flow (`tenant_brand: "lark"`), and the result carries the brand so the caller can point
16
+ * everything else (API origin) at the right cloud.
17
+ */
18
+ /**
19
+ * Additive app config carried on the confirm-page URL (`addons` query param): extra scopes/events
20
+ * merged ON TOP of the platform's agent template — base permissions can never be removed. Shape and
21
+ * encoding (JSON → gzip → base64url) follow the official SDKs (provenance: node-sdk
22
+ * scene/registration); item names unknown to the platform catalog are silently dropped by the page.
23
+ */
24
+ export interface FeishuAppAddons {
25
+ scopes?: {
26
+ tenant?: string[];
27
+ user?: string[];
28
+ };
29
+ events?: {
30
+ items?: {
31
+ tenant?: string[];
32
+ user?: string[];
33
+ };
34
+ };
35
+ callbacks?: {
36
+ items?: string[];
37
+ };
38
+ }
39
+ export interface RegisterFeishuAppOptions {
40
+ /** Pre-filled app name shown on the confirm page (`{user}` expands to the scanning user's name). */
41
+ name?: string;
42
+ /** Pre-filled app description. */
43
+ desc?: string;
44
+ /** Extra scopes/events merged onto the agent template at creation (see {@link FeishuAppAddons}). */
45
+ addons?: FeishuAppAddons;
46
+ /** Called once the one-time verification URL is ready — print it / render it as a QR code. */
47
+ onVerificationUrl: (info: {
48
+ url: string;
49
+ expiresInS: number;
50
+ }) => void;
51
+ /** Cancel the polling. */
52
+ signal?: AbortSignal;
53
+ /** Accounts origins, for tests. */
54
+ accountsBaseUrl?: string;
55
+ larkAccountsBaseUrl?: string;
56
+ }
57
+ export interface RegisteredFeishuApp {
58
+ appId: string;
59
+ appSecret: string;
60
+ /** "feishu" | "lark" — which cloud the scanning user's tenant lives on (drives the API origin). */
61
+ tenantBrand?: string;
62
+ /** The scanning user's open_id, when the platform returns it. */
63
+ openId?: string;
64
+ }
65
+ /**
66
+ * Run the scan-to-create flow (module header): begin → hand the verification URL to the caller →
67
+ * poll until the user confirms. Resolves with the new app's credentials; rejects on denial, expiry,
68
+ * abort, or a transport failure — every rejection is a plain Error whose message says what to do.
69
+ */
70
+ export declare function registerFeishuApp(options: RegisterFeishuAppOptions): Promise<RegisteredFeishuApp>;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * One-click app creation ("scan to create") — the OAuth 2.0 Device Authorization Grant (RFC 8628)
3
+ * flow the platform provides for agent apps: `begin` returns a one-time verification URL the user
4
+ * opens in Feishu/Lark and confirms (the platform pre-configures the agent app template: bot
5
+ * capability, messaging scopes, event subscriptions); polling returns the new app's credentials.
6
+ *
7
+ * Hand-rolled on fetch, no SDK: the wire protocol is two form-encoded POSTs to the accounts endpoint
8
+ * plus RFC 8628's polling error dance — shared verbatim by all four official SDKs (node/python/java/go),
9
+ * which makes it a de-facto stable surface even though only the SDKs document it. Provenance:
10
+ * larksuite/node-sdk `scene/registration` (registerApp). If the platform ever moves this behind
11
+ * something non-trivial (signed payloads, websockets), adopt the official SDK instead of chasing it —
12
+ * the same tripwire as feishu-api.ts.
13
+ *
14
+ * The scanning user's tenant decides the brand: a Lark-tenant user flips polling to the Lark accounts
15
+ * domain mid-flow (`tenant_brand: "lark"`), and the result carries the brand so the caller can point
16
+ * everything else (API origin) at the right cloud.
17
+ */
18
+ import { gzipSync } from "node:zlib";
19
+ /** Feishu accounts endpoint (the flow starts here for every user; a Lark-tenant scan switches over). */
20
+ const FEISHU_ACCOUNTS = "https://accounts.feishu.cn";
21
+ const LARK_ACCOUNTS = "https://accounts.larksuite.com";
22
+ const ENDPOINT = "/oauth/v1/app/registration";
23
+ /** Per-attempt timeout for one registration POST — small form/JSON round-trips. */
24
+ const REQUEST_TIMEOUT_MS = 30_000;
25
+ /** JSON → gzip → base64url — the platform's fixed addons encoding. */
26
+ function encodeAddons(addons) {
27
+ return gzipSync(Buffer.from(JSON.stringify(addons), "utf8"))
28
+ .toString("base64")
29
+ .replace(/\+/g, "-")
30
+ .replace(/\//g, "_")
31
+ .replace(/=+$/, "");
32
+ }
33
+ /** RFC 8628 device-flow states whose non-2xx JSON bodies belong to the polling state machine. */
34
+ const DEVICE_FLOW_ERRORS = new Set(["authorization_pending", "slow_down", "access_denied", "expired_token"]);
35
+ /** One registration POST (form-encoded). RFC 8628 delivers polling states (authorization_pending,
36
+ * slow_down, …) as HTTP 400 with a JSON body — those parse as data, not as transport failures. */
37
+ async function post(baseUrl, params) {
38
+ let res;
39
+ let raw;
40
+ try {
41
+ res = await fetch(`${baseUrl}${ENDPOINT}`, {
42
+ method: "POST",
43
+ headers: { "content-type": "application/x-www-form-urlencoded" },
44
+ body: new URLSearchParams(params).toString(),
45
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
46
+ });
47
+ raw = await res.text();
48
+ }
49
+ catch (e) {
50
+ throw new Error(`feishu app registration: ${String(e)}`, { cause: e });
51
+ }
52
+ let data;
53
+ try {
54
+ data = JSON.parse(raw);
55
+ }
56
+ catch {
57
+ throw new Error(`feishu app registration failed: ${res.status} — response was not the expected JSON`);
58
+ }
59
+ if (!res.ok && !(data.error && DEVICE_FLOW_ERRORS.has(data.error))) {
60
+ const diagnostic = data.error
61
+ ? `${data.error}${data.error_description ? ` — ${data.error_description}` : ""}`
62
+ : raw.trim().slice(0, 500) || "empty response";
63
+ throw new Error(`feishu app registration failed: HTTP ${res.status} — ${diagnostic}`);
64
+ }
65
+ return data;
66
+ }
67
+ /** Sleep on the GLOBAL timer (not `node:timers/promises`) so tests can drive it with fake timers. */
68
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
69
+ /**
70
+ * Run the scan-to-create flow (module header): begin → hand the verification URL to the caller →
71
+ * poll until the user confirms. Resolves with the new app's credentials; rejects on denial, expiry,
72
+ * abort, or a transport failure — every rejection is a plain Error whose message says what to do.
73
+ */
74
+ export async function registerFeishuApp(options) {
75
+ const feishuBase = options.accountsBaseUrl ?? FEISHU_ACCOUNTS;
76
+ const larkBase = options.larkAccountsBaseUrl ?? LARK_ACCOUNTS;
77
+ const begin = await post(feishuBase, {
78
+ action: "begin",
79
+ archetype: "PersonalAgent", // the platform's agent-app template: bot + messaging scopes + events pre-configured
80
+ auth_method: "client_secret",
81
+ request_user_info: "open_id",
82
+ });
83
+ if (!begin.device_code || !begin.verification_uri_complete) {
84
+ throw new Error(`feishu app registration: begin returned no device code (${begin.error ?? "unexpected response"}${begin.error_description ? `: ${begin.error_description}` : ""})`);
85
+ }
86
+ const url = new URL(begin.verification_uri_complete);
87
+ url.searchParams.set("from", "sdk");
88
+ url.searchParams.set("tp", "sdk");
89
+ url.searchParams.set("source", "fastagent");
90
+ if (options.name !== undefined)
91
+ url.searchParams.set("name", options.name);
92
+ if (options.desc !== undefined)
93
+ url.searchParams.set("desc", options.desc);
94
+ if (options.addons !== undefined)
95
+ url.searchParams.set("addons", encodeAddons(options.addons));
96
+ const expiresInS = begin.expires_in ?? 600;
97
+ options.onVerificationUrl({ url: url.toString(), expiresInS });
98
+ let pollBase = feishuBase;
99
+ let switched = false;
100
+ let intervalMs = (begin.interval ?? 5) * 1000;
101
+ const deadline = Date.now() + expiresInS * 1000;
102
+ for (;;) {
103
+ if (options.signal?.aborted)
104
+ throw new Error("feishu app registration was aborted");
105
+ if (Date.now() >= deadline) {
106
+ throw new Error("feishu app registration: the verification link expired before anyone confirmed — re-run to get a fresh one");
107
+ }
108
+ const poll = await post(pollBase, { action: "poll", device_code: begin.device_code });
109
+ // A Lark-tenant user: the flow continues on the Lark accounts domain (once), same device code.
110
+ if (poll.user_info?.tenant_brand === "lark" && !switched) {
111
+ switched = true;
112
+ pollBase = larkBase;
113
+ continue;
114
+ }
115
+ if (poll.client_id && poll.client_secret) {
116
+ return {
117
+ appId: poll.client_id,
118
+ appSecret: poll.client_secret,
119
+ tenantBrand: poll.user_info?.tenant_brand,
120
+ openId: poll.user_info?.open_id,
121
+ };
122
+ }
123
+ switch (poll.error) {
124
+ case "authorization_pending":
125
+ break; // the user has not confirmed yet — keep polling
126
+ case "slow_down":
127
+ intervalMs += 5000; // RFC 8628: back off and keep polling
128
+ break;
129
+ case "access_denied":
130
+ throw new Error("feishu app registration: the user declined the authorization");
131
+ case "expired_token":
132
+ throw new Error("feishu app registration: the verification link expired — re-run to get a fresh one");
133
+ default:
134
+ if (poll.error) {
135
+ throw new Error(`feishu app registration failed: ${poll.error}${poll.error_description ? ` — ${poll.error_description}` : ""}`);
136
+ }
137
+ break; // no error, no credentials — treat as pending
138
+ }
139
+ await wait(intervalMs);
140
+ }
141
+ }
@@ -0,0 +1,22 @@
1
+ import type { RegistrationOutcome } from "../registration.ts";
2
+ import { type FeishuCloudKind } from "./cloud.ts";
3
+ /**
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).
7
+ *
8
+ * Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
9
+ */
10
+ export interface FeishuManualRegistration {
11
+ consoleUrl: string;
12
+ requestUrl: string;
13
+ }
14
+ export interface RegisterFeishuWebhookOptions {
15
+ readyTimeoutMs?: number;
16
+ readyIntervalMs?: number;
17
+ retryMs?: number;
18
+ apiBase?: string;
19
+ /** Manual fallback after a definitive config error or exhausted retries. Local dev opens this App. */
20
+ onManualRegistration?: (info: FeishuManualRegistration) => void;
21
+ }
22
+ export declare function registerFeishuWebhook(baseUrl: string, kind: FeishuCloudKind, opts?: RegisterFeishuWebhookOptions): Promise<RegistrationOutcome>;