@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
@@ -12,9 +12,32 @@ import { Readable } from "node:stream";
12
12
  import { log } from "../log.js";
13
13
  import { readBodyCapped } from "./body.js";
14
14
  import { text, textHeaders } from "./respond.js";
15
- /** Request body cap (1 MiB). */
16
- const MAX_BODY_BYTES = 1 << 20;
15
+ /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
16
+ * dispatch imports it), so the two caps cannot drift apart. */
17
+ export const MAX_BODY_BYTES = 1 << 20;
17
18
  const encoder = new TextEncoder();
19
+ /** SSE comment-heartbeat interval, shared by every SSE surface (the control events route imports
20
+ * it, and the remote client sizes its dead-connection watchdog as a multiple of it). */
21
+ export const SSE_HEARTBEAT_MS = 30_000;
22
+ /** The emitting half of the heartbeat contract (the client watchdog is the other): starts the
23
+ * `: ping` comment interval on an SSE stream controller and returns its stop function — ONE
24
+ * implementation for every SSE surface, so the emission side cannot regress on one route while
25
+ * the shared client watchdog keeps assuming it. Self-stops if the controller is already closed. */
26
+ export function sseHeartbeat(controller) {
27
+ const encoder = new TextEncoder();
28
+ const timer = setInterval(() => {
29
+ try {
30
+ controller.enqueue(encoder.encode(": ping\n\n"));
31
+ }
32
+ catch {
33
+ clearInterval(timer);
34
+ }
35
+ }, SSE_HEARTBEAT_MS);
36
+ return () => clearInterval(timer);
37
+ }
38
+ /** A valid example request body for the invoke handler — lives HERE, next to the shape check it must
39
+ * satisfy, so the CLI's "try it" hint can't drift from the protocol. */
40
+ export const INVOKE_EXAMPLE_BODY = '{"session":"dev","text":"hello"}';
18
41
  /**
19
42
  * Fetch-shaped invoke handler. Mount it at any route in the host app; it accepts POST only.
20
43
  * Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
@@ -37,19 +60,29 @@ export function createInvokeHandler(agent) {
37
60
  if (typeof session !== "string" || typeof promptText !== "string") {
38
61
  return text('need { "session": string, "text": string }\n', 400);
39
62
  }
63
+ // ^ the request shape INVOKE_EXAMPLE_BODY (below) must keep satisfying.
40
64
  // Take the iterator explicitly so the stream's cancel() (consumer disconnect) can return() it and
41
65
  // run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
42
66
  const iterator = agent.invoke({ session }, { text: promptText })[Symbol.asyncIterator]();
67
+ // Heartbeats: a QUIET stream (a long tool call, no events) is normal here — remote consumers
68
+ // distinguish "quiet but alive" from a dead connection by byte arrival, so silence must not
69
+ // look identical to a black hole (SSE comments are ignored by spec-conforming parsers).
70
+ let stopHeartbeat = () => { };
43
71
  const stream = new ReadableStream({
72
+ start(controller) {
73
+ stopHeartbeat = sseHeartbeat(controller);
74
+ },
44
75
  async pull(controller) {
45
76
  const { value, done } = await iterator.next();
46
77
  if (done) {
78
+ stopHeartbeat();
47
79
  controller.close();
48
80
  return;
49
81
  }
50
82
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
51
83
  },
52
84
  async cancel() {
85
+ stopHeartbeat();
53
86
  await iterator.return?.();
54
87
  },
55
88
  });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared pieces of the channels' invoke-turn modules (telegram/feishu/slack `invoke-turn.ts`) — the
3
+ * halves that are channel-independent, so a retry-policy or prompt-wording change lands ONCE:
4
+ *
5
+ * - {@link streamTurnWithBusyRetry}: the busy-retry loop around `agent.invoke`, with the
6
+ * `onCompleted` durable-commit point;
7
+ * - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
8
+ * {@link missingAttachmentsNote}, {@link attributedFileName}.
9
+ *
10
+ * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
+ * message-scoped Feishu keys, Slack file objects) are real differences.
12
+ */
13
+ import { type Agent, type AgentEvent, type Prompt } from "../agent.ts";
14
+ /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
15
+ * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
16
+ * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
17
+ * always an outside holder — wait for it like a queued turn, instead of erroring at the user. */
18
+ export interface BusyRetry {
19
+ delayMs: number;
20
+ maxWaitMs: number;
21
+ }
22
+ export declare const DEFAULT_BUSY_RETRY: BusyRetry;
23
+ /**
24
+ * Stream one Agent turn with the shared busy-wait. `onCompleted` (if given) fires on the turn's
25
+ * `completed` event — the durable-commit point: only then does the turn provably live in the session,
26
+ * so a failure or crash at ANY earlier point leaves the caller's pre-ACK state (turn intent, context
27
+ * buffer) intact for replay/the next summon. The caller uses it to remove the turn intent AND commit
28
+ * the context buffer, in that order, so a crash between the two clears cannot replay a
29
+ * context-stripped turn.
30
+ *
31
+ * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
32
+ * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user
33
+ * sees the channel's "Thinking…" placeholder while waiting (the mirror of the scheduler deferring a
34
+ * wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a FIRST-event
35
+ * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
36
+ * is ever re-run.
37
+ */
38
+ export declare function streamTurnWithBusyRetry(agent: Agent, session: string, prompt: Prompt, options: {
39
+ label: string;
40
+ onCompleted?: () => void;
41
+ busyRetry?: BusyRetry;
42
+ }): AsyncIterable<AgentEvent>;
43
+ /** What the attached-files manifest renders per file: display name, byte size, absolute local path. */
44
+ export interface ManifestFile {
45
+ name: string;
46
+ size: number;
47
+ path: string;
48
+ }
49
+ /** The downloaded-file manifest appended to the prompt — the agent reads the paths with its tools.
50
+ * Empty input renders nothing. */
51
+ export declare function attachedFilesManifest(files: readonly ManifestFile[]): string;
52
+ /** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
53
+ * the way the fold attributes text lines. */
54
+ export declare function attributedFileName(name: string, from: string, msg?: string | number): string;
55
+ /** The manifest attributing background vision images folded in from the earlier discussion — images
56
+ * carry no per-image label inline, so position ("appended after N primary") is the attribution.
57
+ * Channels whose image refs carry no attribution (telegram) simply don't render one. */
58
+ export declare function backgroundImagesManifest(primaryCount: number, refs: readonly {
59
+ from: string;
60
+ messageId: string;
61
+ }[]): string;
62
+ /** The prompt note counting EVERY background attachment the turn does not carry (load failures +
63
+ * cap-skipped) — without it, the model holds fold references it silently cannot open and may
64
+ * pretend it read them. Neutral wording (platforms differ on WHY: expired file_ids, deleted files). */
65
+ export declare function missingAttachmentsNote(missing: number): string;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared pieces of the channels' invoke-turn modules (telegram/feishu/slack `invoke-turn.ts`) — the
3
+ * halves that are channel-independent, so a retry-policy or prompt-wording change lands ONCE:
4
+ *
5
+ * - {@link streamTurnWithBusyRetry}: the busy-retry loop around `agent.invoke`, with the
6
+ * `onCompleted` durable-commit point;
7
+ * - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
8
+ * {@link missingAttachmentsNote}, {@link attributedFileName}.
9
+ *
10
+ * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
+ * message-scoped Feishu keys, Slack file objects) are real differences.
12
+ */
13
+ import { SESSION_BUSY_CODE } from "../agent.js";
14
+ import { log } from "../log.js";
15
+ // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
16
+ // free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
17
+ // real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
18
+ // this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
19
+ // turn forever.
20
+ export const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
21
+ /**
22
+ * Stream one Agent turn with the shared busy-wait. `onCompleted` (if given) fires on the turn's
23
+ * `completed` event — the durable-commit point: only then does the turn provably live in the session,
24
+ * so a failure or crash at ANY earlier point leaves the caller's pre-ACK state (turn intent, context
25
+ * buffer) intact for replay/the next summon. The caller uses it to remove the turn intent AND commit
26
+ * the context buffer, in that order, so a crash between the two clears cannot replay a
27
+ * context-stripped turn.
28
+ *
29
+ * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
30
+ * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user
31
+ * sees the channel's "Thinking…" placeholder while waiting (the mirror of the scheduler deferring a
32
+ * wake INTO a busy session), and only an exhausted wait surfaces the busy failure. Only a FIRST-event
33
+ * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
34
+ * is ever re-run.
35
+ */
36
+ export async function* streamTurnWithBusyRetry(agent, session, prompt, options) {
37
+ const { label, onCompleted, busyRetry = DEFAULT_BUSY_RETRY } = options;
38
+ const deadline = Date.now() + busyRetry.maxWaitMs;
39
+ for (;;) {
40
+ let retryBusy = false;
41
+ let first = true;
42
+ for await (const e of agent.invoke({ session }, prompt)) {
43
+ if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
44
+ retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
45
+ break;
46
+ }
47
+ first = false;
48
+ if (e.type === "completed")
49
+ onCompleted?.(); // the turn is durably in the session — commit point
50
+ yield e;
51
+ }
52
+ if (!retryBusy)
53
+ return;
54
+ log.info(`${label} session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
55
+ await new Promise((r) => setTimeout(r, busyRetry.delayMs));
56
+ }
57
+ }
58
+ /** The downloaded-file manifest appended to the prompt — the agent reads the paths with its tools.
59
+ * Empty input renders nothing. */
60
+ export function attachedFilesManifest(files) {
61
+ return files.length
62
+ ? `\n\n[attached files — read them with your tools:\n${files.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
63
+ : "";
64
+ }
65
+ /** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
66
+ * the way the fold attributes text lines. */
67
+ export function attributedFileName(name, from, msg) {
68
+ return `${name} (from ${from}${msg !== undefined ? `, msg ${msg}` : ""}, earlier discussion)`;
69
+ }
70
+ /** The manifest attributing background vision images folded in from the earlier discussion — images
71
+ * carry no per-image label inline, so position ("appended after N primary") is the attribution.
72
+ * Channels whose image refs carry no attribution (telegram) simply don't render one. */
73
+ export function backgroundImagesManifest(primaryCount, refs) {
74
+ return refs.length
75
+ ? `\n\n[background vision images from earlier discussion — appended after ${primaryCount} primary image(s):\n${refs
76
+ .map((ref, index) => `- vision image ${primaryCount + index + 1}: from ${ref.from}, msg ${ref.messageId}`)
77
+ .join("\n")}\n]`
78
+ : "";
79
+ }
80
+ /** The prompt note counting EVERY background attachment the turn does not carry (load failures +
81
+ * cap-skipped) — without it, the model holds fold references it silently cannot open and may
82
+ * pretend it read them. Neutral wording (platforms differ on WHY: expired file_ids, deleted files). */
83
+ export function missingAttachmentsNote(missing) {
84
+ return missing > 0
85
+ ? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (no longer available, or older than the most recent few)]`
86
+ : "";
87
+ }
@@ -3,9 +3,10 @@
3
3
  * this module binds it to Lark's cloud profile and exposes natural Lark-branded public names. Lark's
4
4
  * weaker control-plane capabilities live in onboarding/registration, not in a fork of the turn engine.
5
5
  */
6
- import type { ChannelModule } from "../../host/node.ts";
7
- import { type FeishuChannelOptions, type FeishuFailure, type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, defaultFeishuRoute } from "../feishu/feishu.ts";
6
+ import type { ChannelModule, LongConnectionChannelModule } from "../../host/node.ts";
7
+ import { type FeishuChannelOptions, type FeishuFailure, type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, type FeishuWebSocketChannelOptions, defaultFeishuRoute } from "../feishu/feishu.ts";
8
8
  export type LarkChannelOptions = FeishuChannelOptions;
9
+ export type LarkWebSocketChannelOptions = FeishuWebSocketChannelOptions;
9
10
  export type LarkFailure = FeishuFailure;
10
11
  export type LarkMessage = FeishuMessage;
11
12
  export type LarkMessageEvent = FeishuMessageEvent;
@@ -13,3 +14,4 @@ export type LarkRoute = FeishuRoute;
13
14
  export declare const defaultLarkRoute: typeof defaultFeishuRoute;
14
15
  export declare function larkEnvelope(event: LarkMessageEvent): string;
15
16
  export declare function larkChannel(opts: LarkChannelOptions): ChannelModule;
17
+ export declare function larkWebSocketChannel(opts: LarkWebSocketChannelOptions): LongConnectionChannelModule;
@@ -1,5 +1,5 @@
1
1
  import { LARK_COMPAT_CLOUD } from "../feishu/cloud.js";
2
- import { buildFeishuChannel, defaultFeishuRoute, } from "../feishu/feishu.js";
2
+ import { buildFeishuChannel, buildFeishuWebSocketChannel, defaultFeishuRoute, } from "../feishu/feishu.js";
3
3
  import { cloudEnvelope } from "../feishu/parse.js";
4
4
  export const defaultLarkRoute = defaultFeishuRoute;
5
5
  export function larkEnvelope(event) {
@@ -8,3 +8,6 @@ export function larkEnvelope(event) {
8
8
  export function larkChannel(opts) {
9
9
  return buildFeishuChannel(LARK_COMPAT_CLOUD, opts, larkChannel.name);
10
10
  }
11
+ export function larkWebSocketChannel(opts) {
12
+ return buildFeishuWebSocketChannel(LARK_COMPAT_CLOUD, opts, larkWebSocketChannel.name);
13
+ }
@@ -1,3 +1,4 @@
1
+ import type { FeishuGroupBehavior, FeishuSubscriptionMode } from "../feishu/setup-mode.ts";
1
2
  /**
2
3
  * Guided Lark-international onboarding. The intl cloud cannot complete the BOUND scan-to-create flow,
3
4
  * so a new/partial setup opens its unbound one-click launcher and collects one App-scoped credential
@@ -17,7 +18,7 @@ export interface LarkOnboardIO {
17
18
  hidden?: boolean;
18
19
  }): Promise<string | undefined>;
19
20
  }
20
- export interface LarkBootstrapResult {
21
+ interface LarkBootstrapResult {
21
22
  /** Challenge-captured token: the PATCH also switched Subscription mode to webhook. */
22
23
  token?: string;
23
24
  /** Present only for a definitive config-route 404; tells the user why the manual path is active. */
@@ -26,14 +27,17 @@ export interface LarkBootstrapResult {
26
27
  export interface LarkOnboardOptions {
27
28
  /** Existing active .env values. A complete credential pair is reused (and still validated). */
28
29
  existing?: Readonly<Record<string, string | undefined>>;
30
+ ingress?: FeishuSubscriptionMode;
31
+ groupBehavior?: FeishuGroupBehavior;
29
32
  verifyCredentials(appId: string, appSecret: string): Promise<void>;
30
- bootstrapWebhook(appId: string, appSecret: string): Promise<LarkBootstrapResult>;
33
+ bootstrapWebhook?(appId: string, appSecret: string): Promise<LarkBootstrapResult>;
31
34
  }
32
- export interface LarkOnboardCredentials extends Record<string, string> {
35
+ export interface LarkOnboardCredentials {
33
36
  LARK_APP_ID: string;
34
37
  LARK_APP_SECRET: string;
35
- LARK_VERIFICATION_TOKEN: string;
38
+ LARK_VERIFICATION_TOKEN?: string;
36
39
  }
37
40
  /** Open the stable app console and collect everything the runtime needs. Cancellation is a visible
38
41
  * failure: the scaffold remains and `add lark` is deliberately re-runnable to resume onboarding. */
39
42
  export declare function onboardLarkApp(io: LarkOnboardIO, opts: LarkOnboardOptions): Promise<LarkOnboardCredentials>;
43
+ export {};
@@ -39,7 +39,15 @@ export async function onboardLarkApp(io, opts) {
39
39
  const eventSecurityUrl = larkEventSecurityUrl(appId);
40
40
  io.note(`App ID / Secret verified. Opening Events & Callbacks → Security: ${eventSecurityUrl}`);
41
41
  io.openUrl(eventSecurityUrl);
42
+ if (opts.ingress === "websocket") {
43
+ io.note(opts.groupBehavior === "mentions"
44
+ ? "Choose long connection, subscribe im.message.receive_v1, then create + publish an app version. No Verification Token or Request URL is needed."
45
+ : "Choose long connection and subscribe im.message.receive_v1, but do not publish yet — context-aware group permission setup follows. No Verification Token or Request URL is needed.");
46
+ return { LARK_APP_ID: appId, LARK_APP_SECRET: appSecret };
47
+ }
42
48
  io.note("Trying automatic webhook-mode + Verification-Token bootstrap…");
49
+ if (!opts.bootstrapWebhook)
50
+ throw new Error("Lark webhook onboarding requires bootstrapWebhook");
43
51
  const bootstrap = await opts.bootstrapWebhook(appId, appSecret);
44
52
  if (bootstrap.token) {
45
53
  io.note("Verification Token captured; Subscription mode changed to webhook in the app draft.");
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Channel-neutral live-preview pieces shared by every messaging channel's preview renderer
3
+ * (telegram/preview.ts, feishu/preview.ts, slack/preview.ts): the turn-view REDUCER (the one
4
+ * event → view-state machine every renderer consumes), its line renderers, the terminal-failure
5
+ * shape a channel hands to its `onError`, the customer-facing wording for it, and the serialized
6
+ * single-writer pump. DELIVERY stays per-platform — message edits vs streaming cards vs chat.update,
7
+ * pacing constants, reveal timing, and terminal-write policies are real platform differences — but
8
+ * everything platform-independent lives here, so a new event type or a wording change lands in ONE
9
+ * place instead of one hunk per channel.
10
+ */
11
+ import type { AgentEvent } from "../agent.ts";
12
+ /** A terminal failure, as a channel hands it to its `onError`. */
13
+ export interface ChannelFailure {
14
+ details: string;
15
+ retryable: boolean;
16
+ }
17
+ /** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
18
+ * offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
19
+ * The non-retryable branch keeps the "something went wrong" phrase deliberately — it is neutral (we only
20
+ * know a boolean, never the specific limitation) and shared verbatim across channels. */
21
+ export declare function defaultErrorMessage(failed: ChannelFailure): string;
22
+ /** Customer-facing live-preview line for an engine-internal retry backoff (the advisory `retrying`
23
+ * event): neutral, no leaked internals — the reason stays in operator logs. */
24
+ export declare const RETRY_NOTICE = "\u23F3 Temporary problem \u2014 retrying\u2026";
25
+ /** The placeholder shown before any reasoning/tool/text arrives. */
26
+ export declare const THINKING_PLACEHOLDER = "\uD83D\uDCAD Thinking\u2026";
27
+ /** One tool call's line in the live view. */
28
+ interface ToolLine {
29
+ label: string;
30
+ status: "running" | "ok" | "error";
31
+ }
32
+ /**
33
+ * The channel-neutral view STATE of one in-flight turn. Renderers own everything after this state:
34
+ * when to reveal the young answer (age vs timer), how to format (HTML / card markdown / mrkdwn),
35
+ * and how to deliver frames. Terminal events are deliberately NOT view state — completed/failed
36
+ * resolve the preview into a final write, which is each platform's terminal-write policy.
37
+ */
38
+ export interface TurnView {
39
+ thinking: string;
40
+ tools: ToolLine[];
41
+ /** tool-call id → its line, for `tool_ended` status flips (bookkeeping; renderers read `tools`). */
42
+ toolById: Map<string, ToolLine>;
43
+ answer: string;
44
+ /** Arrival time of the first non-empty answer delta; age-based reveal policies read it. */
45
+ answerSince?: number;
46
+ /** An advisory retry backoff is in progress (closed again by any subsequent progress event). */
47
+ retrying: boolean;
48
+ }
49
+ export declare function createTurnView(): TurnView;
50
+ /**
51
+ * Apply one event to the view state. Returns true when the view changed (the caller repaints).
52
+ * This is the ONE place the shared view rules live: tool labels are humanized with a compact arg
53
+ * summary, and any progress event closes an open retry notice (a stale "retrying" line must never
54
+ * outlive actual progress). Terminal events only close the notice — they are the caller's business.
55
+ */
56
+ export declare function applyTurnEvent(view: TurnView, e: AgentEvent): boolean;
57
+ /** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
58
+ export declare function toolLines(view: TurnView): string;
59
+ /** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe.
60
+ * Process, not the answer — renderers show it live only, never in the persisted final message. */
61
+ export declare function thinkingLine(view: TurnView, maxTail: number): string;
62
+ /**
63
+ * The shared answer-reveal policy: the answer stays hidden until its first delta has aged one
64
+ * throttle window (`ageMs` — each platform passes its own pacing constant). The pump's leading-edge
65
+ * flush would otherwise turn the very first content delta (often a lone character or unbalanced
66
+ * markup) into its own frame — the short-reply flicker (placeholder → "O" → "OK."). Aging is
67
+ * anchored at delta ARRIVAL (`answerSince`, set by the reducer) so an in-flight write can't skew the
68
+ * clock, and there is deliberately NO timer at the boundary: a young answer surfaces on the next
69
+ * content-driven pass, so a turn completing within the window delivers the final answer only.
70
+ */
71
+ export declare function revealedAnswer(view: TurnView, ageMs: number): string;
72
+ /** Compose body parts (thinking/tools/retry/answer) into one frame: skip empties, blank-line joins. */
73
+ export declare function composeTurnBody(parts: readonly string[]): string;
74
+ /**
75
+ * The serialized live-preview writer shared by the edit/snapshot renderers (telegram, feishu).
76
+ * Events mark the view dirty; the pump repaints to the LATEST view with at most ONE write in
77
+ * flight, paced by `throttleMs`. One-in-flight is the whole point: concurrent writes can land out
78
+ * of order — an older frame over a newer one is the "shows 3-4 steps, blanks, re-fills" flicker —
79
+ * so a single writer keeps frames monotonic (and makes feishu's strictly-increasing card `sequence`
80
+ * correct by construction). NOT used by slack-classic: its pacing lives inside the flush (the 3s
81
+ * chat.update rate slot), not at the frame boundary.
82
+ */
83
+ export interface PreviewPump {
84
+ /** Mark the view dirty and ensure the single writer runs (an in-flight write picks the new state
85
+ * up on its next loop). Synchronous — callers never await a network write. */
86
+ touch(): void;
87
+ /** Stop the pump, cut an in-flight throttle short, and await any in-flight write — so the
88
+ * caller's terminal write is strictly the LAST one (no stale frame landing after the answer). */
89
+ finish(): Promise<void>;
90
+ }
91
+ export declare function createPreviewPump(opts: {
92
+ /** Write the LATEST view. Best-effort — the terminal write is authoritative. */
93
+ flush: () => Promise<void>;
94
+ /** Pace + coalesce a burst into one write; finish() interrupts a throttle in progress. */
95
+ throttleMs: number;
96
+ /** Called for the FIRST failing flush only (the pump keeps running): a never-rendering preview
97
+ * must be diagnosable, not silent — and not a log flood. */
98
+ onError: (error: unknown) => void;
99
+ }): PreviewPump;
100
+ /**
101
+ * A plain-language label for a tool call, following Slack's agent-design guidance to name what a tool
102
+ * does rather than expose a raw identifier ("Create issue", not "create_issue"; "Github: create issue",
103
+ * not "mcp__github__create_issue"). Deliberately generic and engine-neutral: it only reshapes the
104
+ * identifier string — it never invents semantics and never exposes arguments. An `mcp__server__tool`
105
+ * identifier becomes `server: tool`; any other identifier has its separators normalized to spaces and
106
+ * its first letter capitalized.
107
+ */
108
+ export declare function humanizeToolName(name: string): string;
109
+ export {};
@@ -0,0 +1,183 @@
1
+ import { truncateCodePointPrefix, truncateCodePointSuffix } from "./text.js";
2
+ /** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
3
+ * offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
4
+ * The non-retryable branch keeps the "something went wrong" phrase deliberately — it is neutral (we only
5
+ * know a boolean, never the specific limitation) and shared verbatim across channels. */
6
+ export function defaultErrorMessage(failed) {
7
+ return failed.retryable
8
+ ? "⚠️ Temporary problem — please try again in a moment."
9
+ : "⚠️ Sorry, something went wrong. Try rephrasing, or check I have access to what you need.";
10
+ }
11
+ /** Customer-facing live-preview line for an engine-internal retry backoff (the advisory `retrying`
12
+ * event): neutral, no leaked internals — the reason stays in operator logs. */
13
+ export const RETRY_NOTICE = "⏳ Temporary problem — retrying…";
14
+ /** The placeholder shown before any reasoning/tool/text arrives. */
15
+ export const THINKING_PLACEHOLDER = "💭 Thinking…";
16
+ export function createTurnView() {
17
+ return { thinking: "", tools: [], toolById: new Map(), answer: "", retrying: false };
18
+ }
19
+ /**
20
+ * Apply one event to the view state. Returns true when the view changed (the caller repaints).
21
+ * This is the ONE place the shared view rules live: tool labels are humanized with a compact arg
22
+ * summary, and any progress event closes an open retry notice (a stale "retrying" line must never
23
+ * outlive actual progress). Terminal events only close the notice — they are the caller's business.
24
+ */
25
+ export function applyTurnEvent(view, e) {
26
+ const closedRetry = view.retrying && e.type !== "retrying";
27
+ if (closedRetry)
28
+ view.retrying = false;
29
+ switch (e.type) {
30
+ case "text":
31
+ view.answer += e.delta;
32
+ if (view.answerSince === undefined && view.answer.trim() !== "")
33
+ view.answerSince = Date.now();
34
+ return true;
35
+ case "thinking":
36
+ view.thinking += e.delta;
37
+ return true;
38
+ case "tool_started": {
39
+ const arg = summarizeToolArgs(e.args);
40
+ const name = humanizeToolName(e.name);
41
+ const line = { label: arg ? `${name} ${arg}` : name, status: "running" };
42
+ view.tools.push(line);
43
+ view.toolById.set(e.id, line);
44
+ return true;
45
+ }
46
+ case "tool_ended": {
47
+ const line = view.toolById.get(e.id);
48
+ if (line)
49
+ line.status = e.isError ? "error" : "ok";
50
+ return true;
51
+ }
52
+ case "retrying":
53
+ view.retrying = true;
54
+ return true;
55
+ default:
56
+ return closedRetry;
57
+ }
58
+ }
59
+ const TOOL_MARK = { running: "…", ok: "✓", error: "✗" };
60
+ /** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
61
+ export function toolLines(view) {
62
+ return view.tools.map((t) => `🔧 ${t.label} ${TOOL_MARK[t.status]}`).join("\n");
63
+ }
64
+ /** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe.
65
+ * Process, not the answer — renderers show it live only, never in the persisted final message. */
66
+ export function thinkingLine(view, maxTail) {
67
+ const t = view.thinking.replace(/\s+/g, " ").trim();
68
+ return t === "" ? "" : `💭 ${truncateCodePointSuffix(t, maxTail)}`;
69
+ }
70
+ /**
71
+ * The shared answer-reveal policy: the answer stays hidden until its first delta has aged one
72
+ * throttle window (`ageMs` — each platform passes its own pacing constant). The pump's leading-edge
73
+ * flush would otherwise turn the very first content delta (often a lone character or unbalanced
74
+ * markup) into its own frame — the short-reply flicker (placeholder → "O" → "OK."). Aging is
75
+ * anchored at delta ARRIVAL (`answerSince`, set by the reducer) so an in-flight write can't skew the
76
+ * clock, and there is deliberately NO timer at the boundary: a young answer surfaces on the next
77
+ * content-driven pass, so a turn completing within the window delivers the final answer only.
78
+ */
79
+ export function revealedAnswer(view, ageMs) {
80
+ if (view.answer.trim() === "" || view.answerSince === undefined)
81
+ return "";
82
+ return Date.now() - view.answerSince >= ageMs ? view.answer : "";
83
+ }
84
+ /** Compose body parts (thinking/tools/retry/answer) into one frame: skip empties, blank-line joins. */
85
+ export function composeTurnBody(parts) {
86
+ return parts
87
+ .filter((s) => s.trim() !== "")
88
+ .join("\n\n")
89
+ .trim();
90
+ }
91
+ export function createPreviewPump(opts) {
92
+ let dirty = false;
93
+ let pumping = false;
94
+ let stopped = false;
95
+ let errored = false;
96
+ let pumpDone;
97
+ let wakeThrottle; // set while mid-throttle; finish() cuts it short
98
+ const runPump = async () => {
99
+ pumping = true;
100
+ try {
101
+ while (dirty && !stopped) {
102
+ dirty = false;
103
+ try {
104
+ await opts.flush();
105
+ }
106
+ catch (error) {
107
+ if (!errored) {
108
+ errored = true;
109
+ opts.onError(error);
110
+ }
111
+ }
112
+ if (dirty && !stopped) {
113
+ await new Promise((resolve) => {
114
+ const t = setTimeout(resolve, opts.throttleMs);
115
+ wakeThrottle = () => {
116
+ clearTimeout(t);
117
+ resolve();
118
+ };
119
+ });
120
+ wakeThrottle = undefined;
121
+ }
122
+ }
123
+ }
124
+ finally {
125
+ pumping = false;
126
+ }
127
+ };
128
+ return {
129
+ touch() {
130
+ dirty = true;
131
+ if (!pumping)
132
+ pumpDone = runPump();
133
+ },
134
+ async finish() {
135
+ stopped = true;
136
+ wakeThrottle?.();
137
+ await pumpDone?.catch(() => { });
138
+ },
139
+ };
140
+ }
141
+ /** Max length (code points) of a tool's arg preview in the live view. */
142
+ const TOOL_ARG_MAX = 48;
143
+ /** Max length (code points) of a humanized tool label. */
144
+ const TOOL_NAME_MAX = 80;
145
+ /** One-line, truncated at code-point boundaries: collapse whitespace so a multi-line command/arg
146
+ * stays on one line, and never tear a surrogate pair mid-emoji. */
147
+ function clip(s) {
148
+ const one = s.replace(/\s+/g, " ").trim();
149
+ return truncateCodePointPrefix(one, TOOL_ARG_MAX);
150
+ }
151
+ /**
152
+ * A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
153
+ * rather than just `🔧 read`. Generic (a channel knows no tool schemas): show the salient value — the
154
+ * first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
155
+ */
156
+ function summarizeToolArgs(args) {
157
+ if (args === null || typeof args !== "object" || Array.isArray(args))
158
+ return clip(String(args));
159
+ const values = Object.values(args);
160
+ const primary = values.find((v) => typeof v === "string" || typeof v === "number");
161
+ if (primary !== undefined)
162
+ return clip(String(primary));
163
+ return values.length > 0 ? clip(JSON.stringify(args)) : "";
164
+ }
165
+ /**
166
+ * A plain-language label for a tool call, following Slack's agent-design guidance to name what a tool
167
+ * does rather than expose a raw identifier ("Create issue", not "create_issue"; "Github: create issue",
168
+ * not "mcp__github__create_issue"). Deliberately generic and engine-neutral: it only reshapes the
169
+ * identifier string — it never invents semantics and never exposes arguments. An `mcp__server__tool`
170
+ * identifier becomes `server: tool`; any other identifier has its separators normalized to spaces and
171
+ * its first letter capitalized.
172
+ */
173
+ export function humanizeToolName(name) {
174
+ const normalize = (s) => s
175
+ .replace(/[_\-.]+/g, " ")
176
+ .replace(/\s+/g, " ")
177
+ .trim();
178
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(name);
179
+ const base = mcp ? `${normalize(mcp[1] ?? "")}: ${normalize(mcp[2] ?? "")}` : normalize(name);
180
+ const label = base.trim() || name.trim() || "Tool";
181
+ const capitalized = label.charAt(0).toUpperCase() + label.slice(1);
182
+ return truncateCodePointPrefix(capitalized, TOOL_NAME_MAX);
183
+ }
@@ -0,0 +1,5 @@
1
+ export interface SeenRing {
2
+ has(id: string): boolean;
3
+ add(id: string): void;
4
+ }
5
+ export declare function createSeenRing(path: string, label: string, cap?: number): SeenRing;
@@ -0,0 +1,35 @@
1
+ /** Best-effort bounded durable dedup ring, recorded only after the caller's pre-ACK side effect is durable. */
2
+ import { log } from "../log.js";
3
+ import { loadStateFile, saveStateFile } from "./state.js";
4
+ export function createSeenRing(path, label, cap = 2000) {
5
+ const raw = loadStateFile(path);
6
+ const order = raw === undefined
7
+ ? []
8
+ : Array.isArray(raw) && raw.every((id) => typeof id === "string")
9
+ ? raw.slice(-cap)
10
+ : undefined;
11
+ if (order === undefined)
12
+ log.warn(`${label} unexpected shape in ${path} — starting with no seen ids`);
13
+ const values = order ?? [];
14
+ const ids = new Set(values);
15
+ return {
16
+ has: (id) => ids.has(id),
17
+ add(id) {
18
+ if (ids.has(id))
19
+ return;
20
+ ids.add(id);
21
+ values.push(id);
22
+ while (values.length > cap) {
23
+ const evicted = values.shift();
24
+ if (evicted !== undefined)
25
+ ids.delete(evicted);
26
+ }
27
+ try {
28
+ saveStateFile(path, values);
29
+ }
30
+ catch (error) {
31
+ log.warn(`${label} seen-ring write failed (delivery dedup is in-memory until restart): ${String(error)}`);
32
+ }
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,15 @@
1
+ export interface SlackBotTokenProviderOptions {
2
+ statePath: string;
3
+ botToken: string;
4
+ botRefreshToken?: string;
5
+ clientId?: string;
6
+ clientSecret?: string;
7
+ botTokenExpiresAt?: number;
8
+ apiBaseUrl?: string;
9
+ fetch?: typeof fetch;
10
+ }
11
+ /** Latest local rotating credentials for `deploy --run`. The remote runtime still prefers its own
12
+ * durable volume; this overlay prevents a locally consumed refresh token from being redeployed. */
13
+ export declare function readSlackBotAuthEnv(statePath: string): Record<string, string>;
14
+ /** Resolve the current bot token, refreshing once per process when it approaches expiry. */
15
+ export declare function createSlackBotTokenProvider(options: SlackBotTokenProviderOptions): () => Promise<string>;