@fastagent-sh/fastagent 0.14.0 → 0.15.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 (209) hide show
  1. package/README.md +3 -3
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +8 -11
  10. package/dist/channels/feishu/context-buffer.js +6 -67
  11. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  12. package/dist/channels/feishu/feishu-api.js +20 -2
  13. package/dist/channels/feishu/feishu.d.ts +28 -16
  14. package/dist/channels/feishu/feishu.js +210 -188
  15. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  16. package/dist/channels/feishu/invoke-turn.js +10 -62
  17. package/dist/channels/feishu/model.d.ts +2 -1
  18. package/dist/channels/feishu/normalize.d.ts +2 -1
  19. package/dist/channels/feishu/owned-threads.d.ts +2 -6
  20. package/dist/channels/feishu/parse.d.ts +1 -1
  21. package/dist/channels/feishu/preview.d.ts +4 -7
  22. package/dist/channels/feishu/preview.js +24 -140
  23. package/dist/channels/feishu/register-app.d.ts +2 -1
  24. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  25. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  26. package/dist/channels/feishu/setup-mode.js +2 -0
  27. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  28. package/dist/channels/feishu/ws-ingress.js +136 -0
  29. package/dist/channels/github/github.js +8 -6
  30. package/dist/channels/http.d.ts +14 -0
  31. package/dist/channels/http.js +35 -2
  32. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  33. package/dist/channels/invoke-turn-kit.js +87 -0
  34. package/dist/channels/lark/lark.d.ts +4 -2
  35. package/dist/channels/lark/lark.js +4 -1
  36. package/dist/channels/lark/onboard.d.ts +8 -4
  37. package/dist/channels/lark/onboard.js +8 -0
  38. package/dist/channels/preview-kit.d.ts +109 -0
  39. package/dist/channels/preview-kit.js +183 -0
  40. package/dist/channels/seen.d.ts +5 -0
  41. package/dist/channels/seen.js +35 -0
  42. package/dist/channels/slack/bot-auth.d.ts +15 -0
  43. package/dist/channels/slack/bot-auth.js +146 -0
  44. package/dist/channels/slack/config-api.d.ts +60 -0
  45. package/dist/channels/slack/config-api.js +149 -0
  46. package/dist/channels/slack/context-buffer.d.ts +24 -0
  47. package/dist/channels/slack/context-buffer.js +37 -0
  48. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  49. package/dist/channels/slack/invoke-turn.js +63 -0
  50. package/dist/channels/slack/manifest.d.ts +49 -0
  51. package/dist/channels/slack/manifest.js +69 -0
  52. package/dist/channels/slack/model.d.ts +67 -0
  53. package/dist/channels/slack/model.js +2 -0
  54. package/dist/channels/slack/onboard.d.ts +41 -0
  55. package/dist/channels/slack/onboard.js +120 -0
  56. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  57. package/dist/channels/slack/onboarding-state.js +69 -0
  58. package/dist/channels/slack/owned-threads.d.ts +6 -0
  59. package/dist/channels/slack/owned-threads.js +43 -0
  60. package/dist/channels/slack/parse.d.ts +23 -0
  61. package/dist/channels/slack/parse.js +81 -0
  62. package/dist/channels/slack/preview.d.ts +24 -0
  63. package/dist/channels/slack/preview.js +359 -0
  64. package/dist/channels/slack/reaction.d.ts +24 -0
  65. package/dist/channels/slack/reaction.js +62 -0
  66. package/dist/channels/slack/register-webhook.d.ts +10 -0
  67. package/dist/channels/slack/register-webhook.js +49 -0
  68. package/dist/channels/slack/scaffold/channel.ts +33 -0
  69. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  70. package/dist/channels/slack/setup-server.d.ts +17 -0
  71. package/dist/channels/slack/setup-server.js +103 -0
  72. package/dist/channels/slack/slack-api.d.ts +77 -0
  73. package/dist/channels/slack/slack-api.js +415 -0
  74. package/dist/channels/slack/slack.d.ts +58 -0
  75. package/dist/channels/slack/slack.js +451 -0
  76. package/dist/channels/slack/welcomed.d.ts +5 -0
  77. package/dist/channels/slack/welcomed.js +32 -0
  78. package/dist/channels/state.js +3 -3
  79. package/dist/channels/stop-command.d.ts +6 -0
  80. package/dist/channels/stop-command.js +36 -0
  81. package/dist/channels/tasks.d.ts +13 -0
  82. package/dist/channels/tasks.js +10 -0
  83. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  84. package/dist/channels/telegram/context-buffer.js +6 -85
  85. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  86. package/dist/channels/telegram/invoke-turn.js +11 -58
  87. package/dist/channels/telegram/preview.d.ts +4 -7
  88. package/dist/channels/telegram/preview.js +24 -142
  89. package/dist/channels/telegram/telegram.js +23 -9
  90. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  91. package/dist/channels/{feishu/text.js → text.js} +1 -1
  92. package/dist/channels/turn-queue.js +1 -1
  93. package/dist/channels/turn-store.d.ts +1 -1
  94. package/dist/channels/turn-store.js +2 -3
  95. package/dist/cli/add-feishu.d.ts +27 -0
  96. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  97. package/dist/cli/add-slack.d.ts +10 -0
  98. package/dist/cli/add-slack.js +204 -0
  99. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  100. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  101. package/dist/cli/commands/add.d.ts +4 -0
  102. package/dist/cli/commands/add.js +152 -20
  103. package/dist/cli/commands/attach.d.ts +82 -0
  104. package/dist/cli/commands/attach.js +559 -0
  105. package/dist/cli/commands/chat.d.ts +1 -0
  106. package/dist/cli/commands/chat.js +6 -1
  107. package/dist/cli/commands/deploy.d.ts +2 -0
  108. package/dist/cli/commands/deploy.js +78 -22
  109. package/dist/cli/commands/dev.js +11 -5
  110. package/dist/cli/commands/fire.js +3 -3
  111. package/dist/cli/commands/info.js +2 -2
  112. package/dist/cli/commands/invoke.js +2 -3
  113. package/dist/cli/commands/login.js +14 -25
  114. package/dist/cli/commands/models.js +1 -1
  115. package/dist/cli/commands/start.js +15 -8
  116. package/dist/cli/commands/tool.js +2 -1
  117. package/dist/cli/fail.d.ts +0 -2
  118. package/dist/cli/fail.js +1 -1
  119. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  120. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  121. package/dist/cli/kernel.d.ts +3 -2
  122. package/dist/cli/models-view.d.ts +21 -0
  123. package/dist/cli/models-view.js +66 -0
  124. package/dist/cli/program.js +79 -21
  125. package/dist/cli/serve.d.ts +38 -18
  126. package/dist/cli/serve.js +197 -39
  127. package/dist/cli/shared.d.ts +26 -6
  128. package/dist/cli/shared.js +163 -33
  129. package/dist/collect.d.ts +14 -3
  130. package/dist/collect.js +24 -0
  131. package/dist/core.d.ts +3 -1
  132. package/dist/core.js +2 -0
  133. package/dist/deploy/docker/plan.d.ts +2 -2
  134. package/dist/deploy/docker/plan.js +8 -6
  135. package/dist/deploy/fly/plan.d.ts +3 -1
  136. package/dist/deploy/fly/plan.js +12 -7
  137. package/dist/deploy/fly/run.d.ts +5 -4
  138. package/dist/deploy/fly/run.js +16 -5
  139. package/dist/deploy/preflight.d.ts +10 -4
  140. package/dist/deploy/preflight.js +53 -9
  141. package/dist/deploy/railway/plan.d.ts +3 -1
  142. package/dist/deploy/railway/plan.js +12 -4
  143. package/dist/deploy/railway/run.d.ts +5 -4
  144. package/dist/deploy/railway/run.js +15 -5
  145. package/dist/deploy/runner.d.ts +2 -1
  146. package/dist/deploy/secrets.d.ts +2 -1
  147. package/dist/deploy/secrets.js +23 -3
  148. package/dist/dev-supervisor.d.ts +0 -2
  149. package/dist/dev-supervisor.js +7 -3
  150. package/dist/engines/pi/channel.d.ts +22 -16
  151. package/dist/engines/pi/channel.js +90 -60
  152. package/dist/engines/pi/chat.d.ts +4 -16
  153. package/dist/engines/pi/chat.js +8 -261
  154. package/dist/engines/pi/config.d.ts +14 -9
  155. package/dist/engines/pi/config.js +33 -18
  156. package/dist/engines/pi/create.d.ts +22 -2
  157. package/dist/engines/pi/create.js +20 -14
  158. package/dist/engines/pi/harness.d.ts +49 -0
  159. package/dist/engines/pi/harness.js +90 -2
  160. package/dist/engines/pi/invoke.d.ts +55 -2
  161. package/dist/engines/pi/invoke.js +304 -19
  162. package/dist/engines/pi/login.d.ts +11 -0
  163. package/dist/engines/pi/login.js +16 -4
  164. package/dist/engines/pi/models.d.ts +56 -10
  165. package/dist/engines/pi/models.js +61 -23
  166. package/dist/engines/pi/session-builder.d.ts +16 -0
  167. package/dist/engines/pi/session-builder.js +308 -0
  168. package/dist/engines/pi/session-control.d.ts +50 -0
  169. package/dist/engines/pi/session-control.js +604 -0
  170. package/dist/engines/pi/sessions.d.ts +17 -2
  171. package/dist/engines/pi/sessions.js +9 -0
  172. package/dist/engines/pi/tool-context.d.ts +18 -11
  173. package/dist/engines/pi/tool-context.js +3 -9
  174. package/dist/engines/pi/tool.d.ts +6 -8
  175. package/dist/engines/pi/tool.js +6 -1
  176. package/dist/engines/pi/wake-tool.d.ts +0 -3
  177. package/dist/engines/pi/wake-tool.js +9 -7
  178. package/dist/engines/pi/workspace.d.ts +52 -0
  179. package/dist/engines/pi/workspace.js +72 -15
  180. package/dist/feishu.d.ts +1 -1
  181. package/dist/feishu.js +1 -1
  182. package/dist/host/node.d.ts +23 -6
  183. package/dist/host/node.js +5 -4
  184. package/dist/index.d.ts +1 -0
  185. package/dist/index.js +1 -0
  186. package/dist/lark.d.ts +1 -1
  187. package/dist/lark.js +1 -1
  188. package/dist/observe.js +3 -0
  189. package/dist/pi.d.ts +5 -4
  190. package/dist/pi.js +2 -1
  191. package/dist/scaffold/add-channel.d.ts +19 -6
  192. package/dist/scaffold/add-channel.js +126 -17
  193. package/dist/scaffold/templates/fastagent.config.mjs +4 -3
  194. package/dist/schedule/wakeups.d.ts +0 -3
  195. package/dist/schedule/wakeups.js +1 -1
  196. package/dist/session-remote.d.ts +53 -0
  197. package/dist/session-remote.js +336 -0
  198. package/dist/session.d.ts +265 -0
  199. package/dist/session.js +37 -0
  200. package/dist/slack.d.ts +2 -0
  201. package/dist/slack.js +2 -0
  202. package/dist/tunnel.d.ts +7 -4
  203. package/dist/tunnel.js +21 -10
  204. package/package.json +19 -7
  205. package/dist/channels/feishu/seen.d.ts +0 -5
  206. package/dist/channels/feishu/seen.js +0 -47
  207. package/dist/cli-add-feishu.d.ts +0 -8
  208. package/dist/cli-models.d.ts +0 -11
  209. package/dist/cli-models.js +0 -20
@@ -1,29 +1,10 @@
1
1
  /**
2
- * Group-context buffer: recent UN-summoned messages per Telegram "place" (chat[:thread]), kept under a
3
- * char budget and folded into the next answered turn's prompt, so a summoned agent has the discussion
4
- * it didn't see turn-by-turn. Bucketed by place (not session): an un-summoned message has no route
5
- * session, and the flush feeds whatever turn answers that place.
6
- *
7
- * DURABLE: persisted synchronously before the webhook 200 (Telegram never redelivers an ACKed update,
8
- * so ACK-then-persist would be a silent-loss window) and reloaded on start. The consume protocol is
9
- * peek → (turn completes) → commit: peek renders WITHOUT clearing and snapshots exactly which entries
10
- * it consumed; commit removes only those, by object identity — so a failure or crash before the turn's
11
- * `completed` leaves them intact for the next summon, and a message that arrives while the turn runs
12
- * survives for the next answered turn (a whole-bucket delete would lose it).
2
+ * Telegram's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
3
+ * the entry shape, its fold-line rendering, and buffered-attachment selection. Bucketed by Telegram
4
+ * "place" (chat[:thread]), not session: an un-summoned message has no route session, and the flush
5
+ * feeds whatever turn answers that place.
13
6
  */
14
- import { log } from "../../log.js";
15
- import { loadStateFile, saveStateFile } from "../state.js";
16
- /** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
17
- * the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
18
- * sparse-but-relevant lines, a busy burst is capped). */
19
- const BUFFER_MAX_CHARS = 4000;
20
- /** How many buffered files and images (each, most recent first) a summon pulls in with the folded
21
- * discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
22
- * group posting many attachments between summons. Skipped ones are counted into the prompt note, so
23
- * the model never sees an attachment reference it silently cannot open. */
24
- const BUFFER_ATTACH_MAX = 3;
25
- /** One fold line. ALSO the eviction cost basis: the budget must price what the fold actually renders
26
- * (sender + body + the msg/reply meta), or the fold would systematically overrun BUFFER_MAX_CHARS. */
7
+ import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
27
8
  function bufferLine(e) {
28
9
  const meta = [
29
10
  e.messageId !== undefined ? `msg ${e.messageId}` : undefined,
@@ -64,8 +45,6 @@ export function collectAttachments(consumed, primary) {
64
45
  skipped: Math.max(0, files.length - BUFFER_ATTACH_MAX) + Math.max(0, images.length - BUFFER_ATTACH_MAX),
65
46
  };
66
47
  }
67
- /** State files are an IO boundary: valid JSON of the WRONG SHAPE (hand-edited, version drift) must
68
- * degrade exactly like a corrupt file — warn + empty — not flow in as trusted data. */
69
48
  function isBufferEntry(e) {
70
49
  const t = e;
71
50
  const strings = (v) => v === undefined || (Array.isArray(v) && v.every((x) => typeof x === "string"));
@@ -77,63 +56,5 @@ function isBufferEntry(e) {
77
56
  strings(t.imageIds));
78
57
  }
79
58
  export function createContextBuffer(path) {
80
- const load = () => {
81
- const raw = loadStateFile(path);
82
- if (raw === undefined)
83
- return new Map();
84
- if (typeof raw === "object" &&
85
- raw !== null &&
86
- !Array.isArray(raw) &&
87
- Object.values(raw).every((v) => Array.isArray(v) && v.every(isBufferEntry))) {
88
- return new Map(Object.entries(raw));
89
- }
90
- log.warn(`[telegram] unexpected shape in ${path} — starting with an empty buffer`);
91
- return new Map();
92
- };
93
- const buffers = load();
94
- const persist = () => saveStateFile(path, Object.fromEntries(buffers));
95
- return {
96
- push(placeKey, entry) {
97
- const prev = buffers.get(placeKey);
98
- const buf = prev ? [...prev] : [];
99
- buf.push(entry);
100
- let total = buf.reduce((n, e) => n + bufferLine(e).length + 1, 0);
101
- while (buf.length > 1 && total > BUFFER_MAX_CHARS) {
102
- const dropped = buf.shift();
103
- if (dropped)
104
- total -= bufferLine(dropped).length + 1;
105
- }
106
- buffers.set(placeKey, buf);
107
- try {
108
- persist();
109
- }
110
- catch (e) {
111
- if (prev)
112
- buffers.set(placeKey, prev);
113
- else
114
- buffers.delete(placeKey);
115
- throw e;
116
- }
117
- },
118
- peek(placeKey) {
119
- const buf = buffers.get(placeKey) ?? [];
120
- return { text: buf.map(bufferLine).join("\n"), consumed: [...buf] };
121
- },
122
- commit(placeKey, consumed) {
123
- const buf = buffers.get(placeKey);
124
- if (!buf)
125
- return;
126
- const remaining = buf.filter((e) => !consumed.includes(e));
127
- if (remaining.length === 0)
128
- buffers.delete(placeKey);
129
- else
130
- buffers.set(placeKey, remaining);
131
- try {
132
- persist();
133
- }
134
- catch (e) {
135
- log.error(`[telegram] buffer write failed post-ACK (a restart may re-fold answered discussion): ${String(e)}`);
136
- }
137
- },
138
- };
59
+ return createGenericContextBuffer({ path, label: "[telegram]", isEntry: isBufferEntry, line: bufferLine });
139
60
  }
@@ -5,7 +5,8 @@
5
5
  * pure) because this half touches the Bot API + disk; split from telegram.ts so the factory keeps only
6
6
  * wiring and the per-turn lifecycle.
7
7
  */
8
- import { type Agent, type AgentEvent } from "../../agent.ts";
8
+ import type { Agent, AgentEvent } from "../../agent.ts";
9
+ import { type BusyRetry } from "../invoke-turn-kit.ts";
9
10
  import type { BufferedRef } from "./context-buffer.ts";
10
11
  /** Everything the transport needs to fetch a turn's attachments. */
11
12
  export interface TurnTransport {
@@ -27,27 +28,9 @@ export interface TurnAttachments {
27
28
  skipped: number;
28
29
  };
29
30
  }
30
- /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
31
- * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
32
- * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
33
- * always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
34
- export interface BusyRetry {
35
- delayMs: number;
36
- maxWaitMs: number;
37
- }
38
31
  /**
39
- * Run one turn: resolve its attachments, then stream agent.invoke. A primary-attachment failure surfaces
40
- * as a `failed` event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed`
41
- * event — the durable-commit point: only then does the folded discussion provably live in the session,
42
- * so a failure or crash at ANY earlier point leaves the buffer intact for the next summon (a re-folded
43
- * block beats lost context). The caller uses it to remove the turn intent AND commit the context buffer,
44
- * in that order (see the call site) so a crash between the two clears cannot replay a context-stripped turn.
45
- *
46
- * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn (e.g. a self-scheduled
47
- * wake) holds this session's lease and OUR turn never started — replay-safe. Retry (bounded) instead of
48
- * yielding it: the user sees the "Thinking…" placeholder while waiting (the mirror of the scheduler
49
- * deferring a wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a
50
- * FIRST-event busy retries — attachments are already resolved, and a fail-fast reject is the only shape
51
- * the engine emits it in, so nothing that started is ever re-run.
32
+ * Run one turn: resolve its attachments, then stream agent.invoke with the shared busy-wait
33
+ * (invoke-turn-kit `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
34
+ * primary-attachment failure surfaces as a `failed` event (never a silent drop).
52
35
  */
53
36
  export declare function invokeTurn(agent: Agent, session: string, text: string, transport: TurnTransport, attachments: TurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;
@@ -1,12 +1,5 @@
1
- /**
2
- * Run one turn (the IO half of Telegram→Agent translation): assemble its inputs — resolve attachments
3
- * (download files to disk, load vision images) — and stream `agent.invoke` with the assembled prompt.
4
- * `invokeTurn` is the export; attachment resolution is an internal step. Split from parse.ts (which is
5
- * pure) because this half touches the Bot API + disk; split from telegram.ts so the factory keeps only
6
- * wiring and the per-turn lifecycle.
7
- */
8
- import { SESSION_BUSY_CODE } from "../../agent.js";
9
1
  import { log } from "../../log.js";
2
+ import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
10
3
  import { resolveFiles, resolveImages } from "./telegram-api.js";
11
4
  /** Appended to the prompt (not the system prompt): the channel owns Telegram-HTML formatting. */
12
5
  const HTML_INSTRUCTION = "\n\n(Format your reply in Telegram-supported HTML — <b> <i> <u> <s> <code> <pre> <a href> — not Markdown.)";
@@ -52,10 +45,7 @@ async function resolveTurnAttachments(t, attachments) {
52
45
  log.warn(`[telegram] could not load an earlier (buffered) attachment: ${String(r.reason)}`);
53
46
  }
54
47
  }
55
- const missing = lost + buffered.skipped;
56
- const bufferedNote = missing > 0
57
- ? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (expired, or older than the most recent few)]`
58
- : "";
48
+ const missingNote = missingAttachmentsNote(lost + buffered.skipped);
59
49
  // PRIMARY first, background after — consistent with "primary wins": what the user pointed at this
60
50
  // turn leads. Buffered file entries are attributed like the fold's text lines ("the file Bob sent"
61
51
  // resolves); buffered PHOTOS cannot be (ImageRef carries no label), so their attribution stops at
@@ -63,37 +53,18 @@ async function resolveTurnAttachments(t, attachments) {
63
53
  // documented limit.
64
54
  const allFiles = [
65
55
  ...(files ?? []),
66
- ...bufferedFiles.map(({ file, ref }) => ({
67
- ...file,
68
- name: `${file.name} (from ${ref.from}${ref.msg !== undefined ? `, msg ${ref.msg}` : ""}, earlier discussion)`,
69
- })),
56
+ ...bufferedFiles.map(({ file, ref }) => ({ ...file, name: attributedFileName(file.name, ref.from, ref.msg) })),
70
57
  ];
71
- const manifest = allFiles.length
72
- ? `\n\n[attached files — read them with your tools:\n${allFiles.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
73
- : "";
74
58
  const allImages = [...(images ?? []), ...bufferedImages];
75
- return { images: allImages.length ? allImages : undefined, promptSuffix: `${bufferedNote}${manifest}` };
59
+ return {
60
+ images: allImages.length ? allImages : undefined,
61
+ promptSuffix: `${missingNote}${attachedFilesManifest(allFiles)}`,
62
+ };
76
63
  }
77
- // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
78
- // free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
79
- // real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
80
- // this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
81
- // turn forever.
82
- const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
83
64
  /**
84
- * Run one turn: resolve its attachments, then stream agent.invoke. A primary-attachment failure surfaces
85
- * as a `failed` event (never a silent drop). `onCompleted` (if given) fires on the turn's `completed`
86
- * event — the durable-commit point: only then does the folded discussion provably live in the session,
87
- * so a failure or crash at ANY earlier point leaves the buffer intact for the next summon (a re-folded
88
- * block beats lost context). The caller uses it to remove the turn intent AND commit the context buffer,
89
- * in that order (see the call site) so a crash between the two clears cannot replay a context-stripped turn.
90
- *
91
- * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn (e.g. a self-scheduled
92
- * wake) holds this session's lease and OUR turn never started — replay-safe. Retry (bounded) instead of
93
- * yielding it: the user sees the "Thinking…" placeholder while waiting (the mirror of the scheduler
94
- * deferring a wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a
95
- * FIRST-event busy retries — attachments are already resolved, and a fail-fast reject is the only shape
96
- * the engine emits it in, so nothing that started is ever re-run.
65
+ * Run one turn: resolve its attachments, then stream agent.invoke with the shared busy-wait
66
+ * (invoke-turn-kit `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
67
+ * primary-attachment failure surfaces as a `failed` event (never a silent drop).
97
68
  */
98
69
  export async function* invokeTurn(agent, session, text, transport, attachments, onCompleted, busyRetry = DEFAULT_BUSY_RETRY) {
99
70
  let resolved;
@@ -105,23 +76,5 @@ export async function* invokeTurn(agent, session, text, transport, attachments,
105
76
  return;
106
77
  }
107
78
  const prompt = { text: `${text}${resolved.promptSuffix}${HTML_INSTRUCTION}`, images: resolved.images };
108
- const deadline = Date.now() + busyRetry.maxWaitMs;
109
- for (;;) {
110
- let retryBusy = false;
111
- let first = true;
112
- for await (const e of agent.invoke({ session }, prompt)) {
113
- if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
114
- retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
115
- break;
116
- }
117
- first = false;
118
- if (e.type === "completed")
119
- onCompleted?.(); // the turn is durably in the session — commit point
120
- yield e;
121
- }
122
- if (!retryBusy)
123
- return;
124
- log.info(`[telegram] session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
125
- await new Promise((r) => setTimeout(r, busyRetry.delayMs));
126
- }
79
+ yield* streamTurnWithBusyRetry(agent, session, prompt, { label: "[telegram]", onCompleted, busyRetry });
127
80
  }
@@ -6,14 +6,11 @@
6
6
  * message, works in groups and private (unlike sendMessageDraft, which is private/forum-topic only).
7
7
  */
8
8
  import type { AgentEvent } from "../../agent.ts";
9
+ import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
9
10
  import { type Target } from "./telegram-api.ts";
10
- /** A terminal failure, as the channel hands it to `onError`. */
11
- export interface TelegramFailure {
12
- details: string;
13
- retryable: boolean;
14
- }
15
- /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
16
- export declare function defaultErrorMessage(failed: TelegramFailure): string;
11
+ /** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
12
+ export type TelegramFailure = ChannelFailure;
13
+ export { defaultErrorMessage };
17
14
  /**
18
15
  * Consume one turn's event stream into a Telegram chat, live (see the module header for the preview
19
16
  * model). Preview edits are best-effort (logged once if they fail); the final write is authoritative
@@ -1,37 +1,14 @@
1
+ import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
1
2
  import { log } from "../../log.js";
2
3
  import { TELEGRAM_MAX_TEXT, callApi, editMessageText, sendMessage } from "./telegram-api.js";
3
- /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
4
- export function defaultErrorMessage(failed) {
5
- return failed.retryable ? "⚠️ Temporary problem — please try again." : "⚠️ Sorry, something went wrong.";
6
- }
4
+ export { defaultErrorMessage };
7
5
  /** How often (ms) to edit the live-preview message; tool events still flush on the next loop. Edits to
8
6
  * one message are rate-limited tighter than sends, so pace them ~1.5s (vs every token). Doubles as the
9
7
  * answer-preview aging window (see answerView): partial answer text stays hidden until it has existed
10
8
  * this long — one knob, same order of magnitude. */
11
9
  const EDIT_THROTTLE_MS = 1500;
12
- /** Max length of a tool's arg preview in the live view. */
13
- const TOOL_ARG_MAX = 48;
14
10
  /** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
15
11
  const THINKING_PREVIEW = 280;
16
- /** One-line, truncated: collapse whitespace so a multi-line command/arg stays on one line. */
17
- function clip(s) {
18
- const one = s.replace(/\s+/g, " ").trim();
19
- return one.length > TOOL_ARG_MAX ? `${one.slice(0, TOOL_ARG_MAX - 1)}…` : one;
20
- }
21
- /**
22
- * A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
23
- * rather than just `🔧 read`. Generic (the channel knows no tool schemas): show the salient value — the
24
- * first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
25
- */
26
- function summarizeArgs(args) {
27
- if (args === null || typeof args !== "object" || Array.isArray(args))
28
- return clip(String(args));
29
- const values = Object.values(args);
30
- const primary = values.find((v) => typeof v === "string" || typeof v === "number");
31
- if (primary !== undefined)
32
- return clip(String(primary));
33
- return values.length > 0 ? clip(JSON.stringify(args)) : "";
34
- }
35
12
  /**
36
13
  * The terminal-write POLICY: resolve the single preview message into `text`. streamReply owns the
37
14
  * preview lifecycle, so this composition of transport primitives lives here, not in telegram-api. One
@@ -73,39 +50,18 @@ async function finalize(api, botToken, target, messageId, text, opts = {}) {
73
50
  * and surfaces a real failure (bad token, etc.).
74
51
  */
75
52
  export async function streamReply(events, api, botToken, target, formatError, previewId) {
76
- const tools = [];
77
- const toolIndexById = new Map();
78
- let thinking = "";
79
- let answer = "";
80
- let answerPreviewSince;
81
- const mark = { running: "…", ok: "✓", error: "✗" };
82
- const toolView = () => tools.map((t) => `🔧 ${t.label} ${mark[t.status]}`).join("\n");
83
- // Reasoning is process, not the answer: shown (capped to its tail) in the live preview only, never
84
- // in the persisted final message (which is `answer` alone).
85
- const thinkingView = () => {
86
- const t = thinking.replace(/\s+/g, " ").trim();
87
- if (t === "")
88
- return "";
89
- return `💭 ${t.length > THINKING_PREVIEW ? `…${t.slice(t.length - THINKING_PREVIEW + 1)}` : t}`;
90
- };
91
- // The answer is hidden until its first delta has aged one EDIT_THROTTLE_MS: the pump's leading-edge
92
- // flush would otherwise turn the very first content delta (often a lone character or unbalanced markup)
93
- // into its own Telegram edit — the short-reply flicker (placeholder → "O" → "OK."). Aging is anchored
94
- // at delta ARRIVAL (set in the event loop, not here) so an in-flight edit can't skew the clock, and
95
- // there is deliberately NO timer at the boundary: a young answer surfaces on the next content-driven
96
- // preview pass, so a turn completing within the window sends the final answer edit only.
97
- const answerView = () => {
98
- if (answer.trim() === "" || answerPreviewSince === undefined)
99
- return "";
100
- return Date.now() - answerPreviewSince >= EDIT_THROTTLE_MS ? answer : "";
101
- };
53
+ // Event view-state reduction is the shared machine (preview-kit); this renderer owns the reveal
54
+ // policy, formatting, and delivery below.
55
+ const turn = createTurnView();
102
56
  const view = () => {
103
- const v = [thinkingView(), toolView(), answerView()]
104
- .filter((s) => s.trim() !== "")
105
- .join("\n\n")
106
- .trim();
57
+ const v = composeTurnBody([
58
+ thinkingLine(turn, THINKING_PREVIEW),
59
+ toolLines(turn),
60
+ turn.retrying ? RETRY_NOTICE : "",
61
+ revealedAnswer(turn, EDIT_THROTTLE_MS),
62
+ ]);
107
63
  // Before any reasoning/tool/text arrives, show an explicit placeholder rather than an empty edit.
108
- return v === "" ? "💭 Thinking…" : v;
64
+ return v === "" ? THINKING_PLACEHOLDER : v;
109
65
  };
110
66
  // The live preview is ONE real message: sent once (capturing its id + threading under the asker),
111
67
  // then edited in place. messageId/lastSent are shared with the final write on completion.
@@ -134,103 +90,27 @@ export async function streamReply(events, api, botToken, target, formatError, pr
134
90
  if (messageId === undefined)
135
91
  throw new Error("telegram sendMessage returned ok without a message_id — live preview disabled for this turn");
136
92
  };
137
- // ── Live-preview pump: a SINGLE serialized writer. ──────────────────────────────────────────
138
- // Events mutate state (thinking / tools / answer) and mark the preview dirty; the pump edits the
139
- // message to the LATEST view() with at most ONE edit in flight, paced by a throttle. One-in-flight is
140
- // the whole point: concurrent edits can reach Telegram out of order — an older frame landing over a
141
- // newer one is the "shows 3-4 steps, blanks, re-fills" flicker. Serializing keeps frames monotonic.
142
- // (No keepalive: a real message does not expire, unlike a Bot API `sendMessageDraft` (30s window).)
143
- let dirty = false;
144
- let pumping = false;
145
- let stopped = false;
146
- let previewErrLogged = false;
147
- let pumpDone;
148
- let wakeThrottle; // set while the pump is mid-throttle; finish() cuts it short
149
- const runPump = async () => {
150
- pumping = true;
151
- try {
152
- while (dirty && !stopped) {
153
- dirty = false;
154
- try {
155
- await flushPreview();
156
- }
157
- catch (e) {
158
- // Best-effort preview (the final write is authoritative), but a failing edit must be visible —
159
- // log once per turn so a never-rendering preview is diagnosable, not silent.
160
- if (!previewErrLogged) {
161
- previewErrLogged = true;
162
- log.warn(`[telegram] live preview failed (final reply still sends): ${String(e)}`);
163
- }
164
- }
165
- if (dirty && !stopped) {
166
- // Pace + coalesce a burst into one edit. Interruptible: finish() cuts this short so the final
167
- // write is not delayed by up to EDIT_THROTTLE_MS after the turn completes.
168
- await new Promise((resolve) => {
169
- const t = setTimeout(resolve, EDIT_THROTTLE_MS);
170
- wakeThrottle = () => {
171
- clearTimeout(t);
172
- resolve();
173
- };
174
- });
175
- wakeThrottle = undefined;
176
- }
177
- }
178
- }
179
- finally {
180
- pumping = false;
181
- }
182
- };
183
- // Mark the preview dirty and ensure the single writer is running (an edit already in flight picks up
184
- // the new state on its next loop). Synchronous — callers never await a network write.
185
- const touch = () => {
186
- dirty = true;
187
- if (!pumping)
188
- pumpDone = runPump();
189
- };
93
+ // The shared single-writer pump (preview-kit) serializes edits to the one preview message. (No
94
+ // keepalive: a real message does not expire, unlike a Bot API `sendMessageDraft` (30s window).)
95
+ const { touch, finish } = createPreviewPump({
96
+ flush: flushPreview,
97
+ throttleMs: EDIT_THROTTLE_MS,
98
+ onError: (e) => log.warn(`[telegram] live preview failed (final reply still sends): ${String(e)}`),
99
+ });
190
100
  touch(); // send the "💭 Thinking…" placeholder immediately
191
- // Stop the pump and await any in-flight edit, so the final write below is strictly the LAST one to the
192
- // preview message (no stale frame landing after the answer).
193
- const finish = async () => {
194
- stopped = true;
195
- wakeThrottle?.(); // cut an in-flight throttle so the final write is not delayed up to EDIT_THROTTLE_MS
196
- await pumpDone?.catch(() => { });
197
- };
198
101
  try {
199
102
  for await (const e of events) {
200
- if (e.type === "text") {
201
- answer += e.delta;
202
- if (answerPreviewSince === undefined && answer.trim() !== "")
203
- answerPreviewSince = Date.now();
204
- touch();
205
- }
206
- else if (e.type === "thinking") {
207
- thinking += e.delta;
208
- touch();
209
- }
210
- else if (e.type === "tool_started") {
211
- const arg = summarizeArgs(e.args);
212
- toolIndexById.set(e.id, tools.length);
213
- tools.push({ label: arg ? `${e.name} ${arg}` : e.name, status: "running" });
214
- touch();
215
- }
216
- else if (e.type === "tool_ended") {
217
- const i = toolIndexById.get(e.id);
218
- const t = i === undefined ? undefined : tools[i];
219
- if (t)
220
- t.status = e.isError ? "error" : "ok";
221
- touch();
222
- }
223
- else if (e.type === "completed") {
103
+ if (e.type === "completed") {
224
104
  await finish();
225
105
  // Edit the preview into the final answer (HTML, plain fallback); the persisted message is the
226
106
  // answer alone — the process (thinking/tools) was preview-only. Mark finalized BEFORE delivering:
227
107
  // the terminal was reached, so a delivery failure here is a plain failure, not an "abnormal exit"
228
108
  // (which would wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
229
109
  finalized = true;
230
- await finalize(api, botToken, target, messageId, answer.trim() !== "" ? answer : "(no reply)");
110
+ await finalize(api, botToken, target, messageId, turn.answer.trim() !== "" ? turn.answer : "(no reply)");
231
111
  return;
232
112
  }
233
- else if (e.type === "failed") {
113
+ if (e.type === "failed") {
234
114
  await finish();
235
115
  // Two audiences: the chat (customer-facing — formatError, neutral by default) and the operator
236
116
  // log (dev-facing — the full details, via the throw below + the handler's catch). Same terminal
@@ -245,6 +125,8 @@ export async function streamReply(events, api, botToken, target, formatError, pr
245
125
  }
246
126
  throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
247
127
  }
128
+ if (applyTurnEvent(turn, e))
129
+ touch();
248
130
  }
249
131
  throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
250
132
  }
@@ -32,6 +32,7 @@ import { collectAttachments, createContextBuffer } from "./context-buffer.js";
32
32
  import { attachmentSummary, defaultTelegramRoute, extractFiles, extractImages, fromLabel, messageText, ownFiles, ownImages, pickMessage, telegramEnvelope, } from "./parse.js";
33
33
  import { defaultErrorMessage, streamReply } from "./preview.js";
34
34
  import { ensureStateHome } from "../state.js";
35
+ import { dispatchStop } from "../stop-command.js";
35
36
  import { callApi, editMessageText, sendMessage } from "./telegram-api.js";
36
37
  import { createTurnQueue } from "../turn-queue.js";
37
38
  import { createTurnStore } from "./turn-store.js";
@@ -67,15 +68,14 @@ function tokenMatches(header, secret) {
67
68
  * share the first's turn-store/context-buffer. One telegram instance per workspace (single-process).
68
69
  */
69
70
  export function telegramChannel({ secretToken, botToken, route, onError, botUsername, apiBaseUrl = "https://api.telegram.org", }) {
70
- // Both are mandatory: an unset secret_token would accept forged updates (the endpoint is public);
71
- // the bot token is required to send the reply. Fail at construction (startup), not silently.
72
- if (!secretToken) {
73
- throw new Error("telegramChannel requires a non-empty secretToken (the webhook secret_token; an unset one accepts forged updates)");
74
- }
75
- if (!botToken) {
76
- throw new Error("telegramChannel requires a non-empty botToken (used to send the agent's reply)");
77
- }
78
- return ({ agent, stateRoot }) => {
71
+ return ({ agent, stateRoot, control }) => {
72
+ // Validate at activation so deploy may inspect the module shape before secrets are provisioned.
73
+ if (!secretToken) {
74
+ throw new Error("telegramChannel requires a non-empty secretToken (the webhook secret_token; an unset one accepts forged updates)");
75
+ }
76
+ if (!botToken) {
77
+ throw new Error("telegramChannel requires a non-empty botToken (used to send the agent's reply)");
78
+ }
79
79
  const formatError = onError ?? defaultErrorMessage;
80
80
  // One getMe at startup: the bot's @username (for the default route's group @mention summon, only when
81
81
  // not supplied) and the group-privacy flag — privacy mode off is required to receive the un-summoned
@@ -323,6 +323,20 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
323
323
  // that explicitly returns the same chat/thread still quotes.
324
324
  const threadId = r.threadId ?? m.message_thread_id;
325
325
  const sameTarget = String(chatId) === String(m.chat.id) && threadId === m.message_thread_id;
326
+ // Explicit user stop (`/stop`): a control action, never a turn — it must not queue behind the
327
+ // run it stops. `/stop@otherbot` is not ours; a bare `/stop` always is. Awaited before the ACK:
328
+ // dispatch + one sendMessage is fast, and a delivery failure logs instead of failing the webhook.
329
+ const stopMatch = /^\/stop(?:@([A-Za-z0-9_]+))?$/i.exec(messageText(m).trim());
330
+ if (stopMatch && (!stopMatch[1] || stopMatch[1].toLowerCase() === mentionName?.toLowerCase())) {
331
+ const feedback = await dispatchStop(control, session, "[telegram]");
332
+ const target = {
333
+ chatId,
334
+ threadId,
335
+ replyTo: m.chat.type !== "private" && sameTarget ? m.message_id : undefined,
336
+ };
337
+ await sendMessage(apiBaseUrl, botToken, target, feedback, { html: false }).catch((e) => log.warn(`[telegram] stop feedback failed: ${String(e)}`));
338
+ return new Response(null, { status: 200 });
339
+ }
326
340
  const baseText = r.text ?? telegramEnvelope(m);
327
341
  const imageFileIds = extractImages(m);
328
342
  const fileIds = extractFiles(m);
@@ -1,4 +1,4 @@
1
- /** Pure Unicode-safe text slicing helpers shared by Feishu/Lark rendering paths. JavaScript string
1
+ /** Pure Unicode-safe text slicing helpers shared by channel rendering paths (Feishu/Lark cards, the preview kit). JavaScript string
2
2
  * indexes are UTF-16 code units, so direct `slice()` can tear a surrogate pair and send replacement
3
3
  * characters after JSON/UTF-8 encoding. These helpers only cut at Unicode code-point boundaries. */
4
4
  /** Take at most `maxPoints` Unicode code points from the start, without adding a marker. */
@@ -1,4 +1,4 @@
1
- /** Pure Unicode-safe text slicing helpers shared by Feishu/Lark rendering paths. JavaScript string
1
+ /** Pure Unicode-safe text slicing helpers shared by channel rendering paths (Feishu/Lark cards, the preview kit). JavaScript string
2
2
  * indexes are UTF-16 code units, so direct `slice()` can tear a surrogate pair and send replacement
3
3
  * characters after JSON/UTF-8 encoding. These helpers only cut at Unicode code-point boundaries. */
4
4
  /** Take at most `maxPoints` Unicode code points from the start, without adding a marker. */
@@ -5,7 +5,7 @@
5
5
  * instead of colliding on the lease and being dropped as "busy").
6
6
  *
7
7
  * Channel-neutral (records are opaque beyond a `session` key), shared by the stateful chat channels
8
- * (telegram, Feishu; Lark reuses Feishu). Durability is layered ON TOP by the caller: turn-store.ts
8
+ * (Telegram, Feishu/Lark, Slack). Durability is layered ON TOP by the caller: turn-store.ts
9
9
  * persists an accepted turn's intent pre-ACK and replays a crash-surviving one on the next start (L1,
10
10
  * process-crash recovery, at-least-once). Exactly-once / deterministic step-replay (L2) is the K-axis
11
11
  * backend — an external queue with distributed locking (SPEC §11) — not this in-memory queue.
@@ -9,7 +9,7 @@ export interface TurnRecordBase {
9
9
  attempts: number;
10
10
  }
11
11
  export interface TurnStore<T extends TurnRecordBase> {
12
- /** Persist an accepted turn before the ACK. A failed write throws (→ webhook 500, the platform redelivers). */
12
+ /** Persist an accepted turn before the ACK. A failed write throws (→ HTTP response / WS ACK 500, so the platform redelivers). */
13
13
  add(rec: T): void;
14
14
  /** Remove a finished turn. Post-ACK: a failed write is logged, never thrown (must not abort delivery). */
15
15
  remove(id: string): void;
@@ -31,8 +31,7 @@
31
31
  *
32
32
  * Channel-neutral, generic over the record shape: the channel supplies its own record type (everything
33
33
  * its runner needs to re-execute the turn), the shape validator (state files are an IO boundary), the
34
- * arrival ordering for recovery, and its log label. telegram/turn-store.ts and the Feishu channel are the
35
- * two consumers.
34
+ * arrival ordering for recovery, and its log label. Telegram, Feishu/Lark, and Slack consume it.
36
35
  *
37
36
  * ponytail: at-least-once with a per-turn EXECUTION ceiling. A poison turn that deterministically
38
37
  * crashes the process would replay forever under a container restart policy. The counter is bumped at
@@ -84,7 +83,7 @@ export function createTurnStore(path, opts) {
84
83
  return;
85
84
  turns.set(rec.id, rec);
86
85
  try {
87
- persist(); // pre-ACK: a throw becomes the webhook's 500 and the platform redelivers
86
+ persist(); // pre-ACK: the transport maps this throw to HTTP/WS 500, so the platform redelivers
88
87
  }
89
88
  catch (e) {
90
89
  // Roll the memory back so it matches disk (mirrors context-buffer.push): otherwise the phantom
@@ -0,0 +1,27 @@
1
+ import { type FeishuGroupBehavior, type FeishuSubscriptionMode } from "../channels/feishu/setup-mode.ts";
2
+ import { type FeishuApi } from "../channels/feishu/feishu-api.ts";
3
+ import { type GroupBehaviorChoice } from "../scaffold/add-channel.ts";
4
+ export interface GroupBehaviorSetup {
5
+ /** Safe to proceed to version publishing now; false means Permissions still needs manual/admin work. */
6
+ publishReady: boolean;
7
+ }
8
+ export declare function configureGroupBehavior(input: {
9
+ kind: "feishu" | "lark";
10
+ appId: string;
11
+ apiBase: string;
12
+ api: Pick<FeishuApi, "listAppScopes" | "addAppScopes">;
13
+ behavior: FeishuGroupBehavior;
14
+ /** Whether the author chose the behavior (flag or prompt). A defaulted "context" inspects and
15
+ * reports only — it must never PATCH the sensitive scope into the app draft. */
16
+ explicit: boolean;
17
+ note?: (message: string) => void;
18
+ openUrl?: (url: string) => void;
19
+ }): Promise<GroupBehaviorSetup>;
20
+ /**
21
+ * Create or resume the platform app behind `add feishu` / `add lark`. Returns credentials for the
22
+ * caller's generic .env write (the guided Lark path), or undefined when nothing remains to write —
23
+ * the feishu path persists its own two credential stages internally (the App ID/Secret boundary is
24
+ * irreversible and must not wait for the caller). Throws on refusal (a committable .env, a
25
+ * non-interactive lark run); the caller surfaces that as a startup failure.
26
+ */
27
+ export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "lark", envIgnored: boolean, ingress?: FeishuSubscriptionMode, groupBehavior?: GroupBehaviorChoice): Promise<Record<string, string> | undefined>;