@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
package/README.md CHANGED
@@ -36,7 +36,7 @@ FastAgent is the missing bridge from local agent directory to live service.
36
36
  ## Features
37
37
 
38
38
  - **Vibe first — a directory is an agent.** Point FastAgent at the `AGENTS.md` + `skills/` you already vibed in a coding agent. Markdown instructions, reusable skills, and TypeScript tools stay as files you inspect, edit, and commit — no new DSL, no framework rewrite.
39
- - **Channels.** Serve the same agent as a GitHub PR reviewer, a Telegram bot, an HTTP/SSE endpoint, or your own adapter verified webhooks, streaming replies, group-aware.
39
+ - **Channels.** Serve the same agent as a GitHub PR reviewer, a Telegram bot, a Feishu or Lark bot, an HTTP/SSE endpoint, or your own adapter: verified webhooks, streaming replies, group-aware.
40
40
  - **Models, tools & skills.** Any model provider (OpenAI, Anthropic, Google, …) via OAuth or API key; typed tools discovered from `tools/` (the filename is the name, Zod-validated); Agent Skills loaded on demand. Built on the open-source [pi](https://github.com/earendil-works/pi) harness.
41
41
  - **App embedding — your stack, we plug in.** Mount the agent in your Next / Astro / Hono / Bun / Node route with one handler, or call `invoke` like any function from your own code — your auth, your database, your infra. FastAgent composes with your app, never owns it.
42
42
  - **Deploy anywhere.** No application build step — the directory is the deployable unit. `fastagent deploy docker|fly|railway` generates the container + target config and a runbook (`--run` drives it to completion). Local Docker gets user-owned Compose + durable state; optional `--tunnel` adds an ephemeral Quick Tunnel service for webhook channels. Durable ingress remains yours.
@@ -145,7 +145,7 @@ const agent = createPiAgent({
145
145
  | [Embedding](https://fastagent.sh/docs/embedding/) | Use FastAgent as a library inside your own app |
146
146
  | [Channels](https://fastagent.sh/docs/channels/) | Add webhook/bot channels |
147
147
  | [Deploy](https://fastagent.sh/docs/deploy/) | Ship the directory to Fly, Railway, or any Docker host |
148
- | [GitHub](https://fastagent.sh/docs/github/) / [Telegram](https://fastagent.sh/docs/telegram/) / [Feishu and Lark](https://fastagent.sh/docs/feishu/) | First-party channel guides |
148
+ | [GitHub](https://fastagent.sh/docs/github/) / [Telegram](https://fastagent.sh/docs/telegram/) / [Slack](https://fastagent.sh/docs/slack/) / [Feishu and Lark](https://fastagent.sh/docs/feishu/) | First-party channel guides |
149
149
  | [Channel development](https://fastagent.sh/docs/channel-development/) | Build custom channel adapters |
150
150
  | [API reference](https://fastagent.sh/docs/api-reference/) | Public TypeScript API reference |
151
151
  | [Troubleshooting](https://fastagent.sh/docs/troubleshooting/) | Common setup/runtime issues |
@@ -194,7 +194,7 @@ FastAgent is pre-1.0. The stable design center is the Agent Handler contract in
194
194
 
195
195
  The neutral contract leaves room for capabilities that are not complete product features yet:
196
196
 
197
- - **Durable execution** Telegram accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
197
+ - **Durable execution**: Telegram, Slack, and Feishu/Lark accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
198
198
  - **Sandboxed execution** — `ExecutionEnv` is an assembly seam, but the pi coding tools and project-context loader are still local; a complete sandbox adapter is future work.
199
199
  - **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
200
200
  - **More harness bindings and channels** — pi is the built-in harness; another harness can implement the Agent contract, and community channels can use the channel kit.
package/dist/agent.d.ts CHANGED
@@ -39,6 +39,17 @@ export type AgentEvent = {
39
39
  isError: boolean;
40
40
  content: Json;
41
41
  }
42
+ /** Advisory, non-terminal (engines MAY emit it): a transient internal failure scheduled a retry
43
+ * with backoff — the turn is still alive. Explains a quiet gap that would otherwise read as a
44
+ * hang; deliberately unclosed — the next event is its closure. Terminal consumers ignore it
45
+ * (SPEC MUST 4). */
46
+ | {
47
+ type: "retrying";
48
+ attempt: number;
49
+ maxAttempts: number;
50
+ delayMs: number;
51
+ reason: string;
52
+ }
42
53
  /** Terminal: success. `data` is attached only when the engine produces a structured result. */
43
54
  | {
44
55
  type: "completed";
@@ -62,6 +73,15 @@ export type AgentEvent = {
62
73
  * not a public cross-engine mandate.
63
74
  */
64
75
  export declare const SESSION_BUSY_CODE = "session_busy";
76
+ /**
77
+ * The `failed.code` set when a run was DELIBERATELY stopped — a control-plane abort, or any
78
+ * harness-level abort the engine attributes (`stopReason: "aborted"`) — rather than failing on its
79
+ * own. Channels can render cancellation distinctly from an error, and MUST treat it as a settled
80
+ * outcome — durable turn-intent cleanup included — so a deliberate stop is never replayed as a
81
+ * fresh turn on restart. Exported as a constant for the same reason as {@link SESSION_BUSY_CODE}:
82
+ * a consumer that must branch on it should not string-match.
83
+ */
84
+ export declare const ABORTED_CODE = "aborted";
65
85
  /**
66
86
  * One turn = one invoke, returning a single async event stream. The stream MUST terminate with
67
87
  * exactly one of completed / failed, or be cancelled by the caller (no terminal event). Any
package/dist/agent.js CHANGED
@@ -12,3 +12,12 @@
12
12
  * not a public cross-engine mandate.
13
13
  */
14
14
  export const SESSION_BUSY_CODE = "session_busy";
15
+ /**
16
+ * The `failed.code` set when a run was DELIBERATELY stopped — a control-plane abort, or any
17
+ * harness-level abort the engine attributes (`stopReason: "aborted"`) — rather than failing on its
18
+ * own. Channels can render cancellation distinctly from an error, and MUST treat it as a settled
19
+ * outcome — durable turn-intent cleanup included — so a deliberate stop is never replayed as a
20
+ * fresh turn on restart. Exported as a constant for the same reason as {@link SESSION_BUSY_CODE}:
21
+ * a consumer that must branch on it should not string-match.
22
+ */
23
+ export const ABORTED_CODE = "aborted";
@@ -0,0 +1,30 @@
1
+ /** How many buffered files and images (each, most recent first) a summon pulls in with the folded
2
+ * discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
3
+ * group posting many attachments between summons. Skipped ones must be counted into the prompt
4
+ * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
5
+ * each channel's attachment collector caps against this. */
6
+ export declare const BUFFER_ATTACH_MAX = 3;
7
+ export interface ContextBuffer<E> {
8
+ /** Record an un-summoned message. Persists BEFORE returning (pre-ACK; see the module header). */
9
+ push(placeKey: string, entry: E): void;
10
+ /** Render the fold text and snapshot the consumed entries (see the module header's consume protocol). */
11
+ peek(placeKey: string): {
12
+ text: string;
13
+ consumed: E[];
14
+ };
15
+ /** Remove exactly `consumed` (by identity) — call on the turn's `completed` event, when the folded
16
+ * discussion provably lives in the durable session. Consumes entries WHOLE, including ones whose
17
+ * attachments failed to load or were cap-skipped: their text is in the session (keeping them would
18
+ * re-fold duplicate text), and the prompt note said what is missing; re-post an attachment to use
19
+ * it. Post-ACK: a failed write is logged, never thrown (it must not abort the turn's delivery). */
20
+ commit(placeKey: string, consumed: E[]): void;
21
+ }
22
+ export declare function createContextBuffer<E>(options: {
23
+ path: string;
24
+ /** Log label, e.g. "[telegram]". */
25
+ label: string;
26
+ /** Shape validator for one persisted entry (the IO boundary — see the module header). */
27
+ isEntry: (value: unknown) => value is E;
28
+ /** One fold line for an entry — ALSO the eviction cost basis. */
29
+ line: (entry: E) => string;
30
+ }): ContextBuffer<E>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Generic durable context buffer — the SHARED mechanics behind each stateful channel's
3
+ * "un-summoned group discussion" module (telegram/feishu/slack `context-buffer.ts`): recent
4
+ * un-summoned messages per conversation "place", kept under a char budget and folded into the next
5
+ * answered turn in that place, so a summoned agent has the discussion it didn't see turn-by-turn.
6
+ *
7
+ * Channel-neutral and generic over the entry shape (like ../turn-store.ts): the channel supplies its
8
+ * entry type, the shape validator (state files are an IO boundary — valid JSON of the WRONG shape
9
+ * must degrade exactly like a corrupt file: warn + empty, never flow in as trusted data), the
10
+ * fold-line renderer, and its log label. What stays per channel: the entry type itself, place-key
11
+ * derivation, and buffered-attachment selection (platform resource shapes are real differences).
12
+ *
13
+ * DURABLE, with the consume protocol every channel inherits:
14
+ * - `push` persists synchronously BEFORE the transport ACK (an ACKed delivery is not redelivered,
15
+ * so ACK-then-persist would be a silent-loss window): a throw becomes the webhook's 500 and the
16
+ * platform redelivers once the disk recovers — staged on a copy and rolled back on a failed
17
+ * write, so the redelivery does not double-append the entry already in memory.
18
+ * - `peek` renders WITHOUT clearing and snapshots exactly which entries it consumed.
19
+ * - `commit` removes only that snapshot, by object identity, on the turn's `completed` — so a
20
+ * failure or crash before `completed` leaves the discussion intact for the next summon, and a
21
+ * message that arrives while the turn runs survives for the next answered turn (a whole-bucket
22
+ * delete would lose it).
23
+ */
24
+ import { log } from "../log.js";
25
+ import { loadStateFile, saveStateFile } from "./state.js";
26
+ /** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
27
+ * the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
28
+ * sparse-but-relevant lines, a busy burst is capped). The `line` renderer is the eviction cost
29
+ * basis: the budget must price what the fold actually renders, or it would systematically overrun. */
30
+ const BUFFER_MAX_CHARS = 4000;
31
+ /** How many buffered files and images (each, most recent first) a summon pulls in with the folded
32
+ * discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
33
+ * group posting many attachments between summons. Skipped ones must be counted into the prompt
34
+ * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
35
+ * each channel's attachment collector caps against this. */
36
+ export const BUFFER_ATTACH_MAX = 3;
37
+ export function createContextBuffer(options) {
38
+ const { path, label, isEntry, line } = options;
39
+ const load = () => {
40
+ const raw = loadStateFile(path);
41
+ if (raw === undefined)
42
+ return new Map();
43
+ if (typeof raw === "object" &&
44
+ raw !== null &&
45
+ !Array.isArray(raw) &&
46
+ Object.values(raw).every((entries) => Array.isArray(entries) && entries.every(isEntry))) {
47
+ return new Map(Object.entries(raw));
48
+ }
49
+ log.warn(`${label} unexpected shape in ${path} — starting with an empty context buffer`);
50
+ return new Map();
51
+ };
52
+ const buffers = load();
53
+ const persist = () => saveStateFile(path, Object.fromEntries(buffers));
54
+ return {
55
+ push(placeKey, entry) {
56
+ const previous = buffers.get(placeKey);
57
+ const entries = previous ? [...previous] : [];
58
+ entries.push(entry);
59
+ let total = entries.reduce((sum, candidate) => sum + line(candidate).length + 1, 0);
60
+ while (entries.length > 1 && total > BUFFER_MAX_CHARS) {
61
+ const dropped = entries.shift();
62
+ if (dropped)
63
+ total -= line(dropped).length + 1;
64
+ }
65
+ buffers.set(placeKey, entries);
66
+ try {
67
+ persist();
68
+ }
69
+ catch (error) {
70
+ if (previous)
71
+ buffers.set(placeKey, previous);
72
+ else
73
+ buffers.delete(placeKey);
74
+ throw error;
75
+ }
76
+ },
77
+ peek(placeKey) {
78
+ const entries = buffers.get(placeKey) ?? [];
79
+ return { text: entries.map(line).join("\n"), consumed: [...entries] };
80
+ },
81
+ commit(placeKey, consumed) {
82
+ const entries = buffers.get(placeKey);
83
+ if (!entries)
84
+ return;
85
+ const remaining = entries.filter((entry) => !consumed.includes(entry));
86
+ if (remaining.length === 0)
87
+ buffers.delete(placeKey);
88
+ else
89
+ buffers.set(placeKey, remaining);
90
+ try {
91
+ persist();
92
+ }
93
+ catch (error) {
94
+ log.error(`${label} context-buffer write failed post-ACK (a restart may re-fold answered discussion): ${String(error)}`);
95
+ }
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,28 @@
1
+ import { type SessionControl, type SessionEvent } from "../session.ts";
2
+ import type { Agent } from "../agent.ts";
3
+ import type { Routes } from "../host/node.ts";
4
+ /** The SSE payload: one control-plane event in its transport envelope. */
5
+ export interface WireEvent {
6
+ sessionId: string;
7
+ /** Serving-process incarnation (per `controlRoutes` call). A change means the server restarted:
8
+ * live continuity is gone — run the reconnect steps (entries cursor + state). */
9
+ epoch: string;
10
+ /** Per-connection monotonic counter. A gap means events were lost in transit on THIS connection. */
11
+ seq: number;
12
+ event: SessionEvent;
13
+ }
14
+ export interface ControlRoutesOptions {
15
+ /** Shared bearer secret, required on every route. Never optional: an unauthenticated
16
+ * remote-control endpoint must not be constructible by omission. */
17
+ token: string;
18
+ /** The DATA plane over the wire: when provided, `POST /control/invoke` mounts the standard
19
+ * invoke handler behind the same bearer token — a remote client (Web panel, desktop app,
20
+ * `attach`) can START runs regardless of which channels occupy `/invoke`. Same contract, same
21
+ * SSE event stream; disconnecting the response cancels the run (SPEC cancellation). */
22
+ agent?: Agent;
23
+ }
24
+ /**
25
+ * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
26
+ * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
27
+ */
28
+ export declare function controlRoutes(control: SessionControl, options: ControlRoutesOptions): Routes;
@@ -0,0 +1,214 @@
1
+ import { INVALID_COMMAND_CODE } from "../session.js";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { readBodyCapped } from "./body.js";
4
+ import { MAX_BODY_BYTES, createInvokeHandler, sseHeartbeat } from "./http.js";
5
+ import { text } from "./respond.js";
6
+ const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
7
+ // ONE constant for every Prompt-bearing wire surface (imported from the invoke channel — the two
8
+ // caps cannot drift apart): commands carry Prompts, which may ride base64 images.
9
+ const DISPATCH_BODY_LIMIT = MAX_BODY_BYTES;
10
+ /**
11
+ * Parse-don't-validate at the wire: a remote client can send any JSON, and the hub's inner layers
12
+ * trust command shapes (a malformed `steer` would surface as an ENGINE failure misclassified as
13
+ * `run_command_failed`). Returns the typed command, or undefined for anything malformed — which
14
+ * answers protocol-level `invalid_command`, same responsibility as the hub's unknown-type default.
15
+ */
16
+ function parseWireCommand(raw) {
17
+ // COMPILE-TIME drift guard, variant level: this switch hand-mirrors the SessionCommand union,
18
+ // and a new variant added in session.ts would otherwise compile clean while the wire answers it
19
+ // `invalid_command` — silently breaking local/remote isomorphism. A new variant must break THIS
20
+ // line first, forcing the decision of how the wire carries it.
21
+ const _commandDriftGuard = {
22
+ steer: true,
23
+ follow_up: true,
24
+ abort: true,
25
+ compact: true,
26
+ set_model: true,
27
+ set_thinking: true,
28
+ };
29
+ void _commandDriftGuard;
30
+ if (typeof raw !== "object" || raw === null)
31
+ return undefined;
32
+ const c = raw;
33
+ const imageOk = (i) => typeof i === "object" &&
34
+ i !== null &&
35
+ typeof i.data === "string" &&
36
+ typeof i.mimeType === "string";
37
+ const promptOk = (p) => {
38
+ if (typeof p !== "object" || p === null)
39
+ return false;
40
+ if (typeof p.text !== "string")
41
+ return false;
42
+ const images = p.images;
43
+ // Element-level: `images: [42]` reaching the engine would resurface exactly the misclassified
44
+ // failure this parser exists to prevent (ImageRef shape from src/session.ts's Prompt).
45
+ return images === undefined || (Array.isArray(images) && images.every(imageOk));
46
+ };
47
+ // REBUILD, never pass raw through: "typed command out" must be construction, not assertion — a
48
+ // passed-through object would carry arbitrary extra keys into the engine.
49
+ const rebuildPrompt = (p) => {
50
+ const images = p.images;
51
+ return {
52
+ text: p.text,
53
+ ...(images ? { images: images.map((i) => ({ data: i.data, mimeType: i.mimeType })) } : {}),
54
+ };
55
+ };
56
+ // COMPILE-TIME drift guard: whitelist reconstruction silently strips any field it does not know.
57
+ // A new Prompt field must break THIS line (non-empty Exclude → {} unassignable), not vanish on
58
+ // the wire while the client believes it was sent.
59
+ const _promptDriftGuard = {};
60
+ void _promptDriftGuard;
61
+ // Same guard one level down: the image whitelist ({data, mimeType}) must break when ImageRef
62
+ // grows a field — top-level coverage alone would let element fields vanish silently.
63
+ const _imageDriftGuard = {};
64
+ void _imageDriftGuard;
65
+ switch (c.type) {
66
+ case "steer":
67
+ case "follow_up":
68
+ return promptOk(c.prompt) ? { type: c.type, prompt: rebuildPrompt(c.prompt) } : undefined;
69
+ case "abort":
70
+ return { type: "abort" };
71
+ case "compact":
72
+ return c.instructions === undefined || typeof c.instructions === "string"
73
+ ? { type: "compact", instructions: c.instructions }
74
+ : undefined;
75
+ case "set_model":
76
+ return typeof c.model === "string" ? { type: "set_model", model: c.model } : undefined;
77
+ case "set_thinking":
78
+ return typeof c.level === "string" ? { type: "set_thinking", level: c.level } : undefined;
79
+ default:
80
+ return undefined;
81
+ }
82
+ }
83
+ /**
84
+ * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
85
+ * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
86
+ */
87
+ export function controlRoutes(control, options) {
88
+ const { token } = options;
89
+ if (!token)
90
+ throw new Error("controlRoutes: a bearer token is required (empty tokens are not a mode)");
91
+ const epoch = crypto.randomUUID();
92
+ // Timing-safe: the bearer token is this surface's ONLY auth (and the --tunnel warning names it
93
+ // as the sole protection on a public URL) — a plain === would leak byte-by-byte via timing.
94
+ const expected = Buffer.from(`Bearer ${token}`);
95
+ const authed = (req) => {
96
+ const header = Buffer.from(req.headers.get("authorization") ?? "");
97
+ return header.length === expected.length && timingSafeEqual(header, expected);
98
+ };
99
+ const invokeHandler = options.agent ? createInvokeHandler(options.agent) : undefined;
100
+ /** Wrap a handler with auth + the session query param most routes need. */
101
+ const guard = (handler) => (req) => {
102
+ if (!authed(req))
103
+ return text("unauthorized\n", 401);
104
+ return handler(req, new URL(req.url));
105
+ };
106
+ // Extraction only — each route still answers its own 400 (the name must not imply enforcement).
107
+ const sessionParam = (url) => url.searchParams.get("session") ?? undefined;
108
+ return {
109
+ ...(invokeHandler ? { "POST /control/invoke": guard((req) => invokeHandler(req)) } : {}),
110
+ "GET /control/capabilities": guard(() => json(control.capabilities())),
111
+ "GET /control/state": guard(async (_req, url) => {
112
+ const session = sessionParam(url);
113
+ if (!session)
114
+ return text("missing ?session\n", 400);
115
+ return json(await control.state(session));
116
+ }),
117
+ "GET /control/entries": guard(async (_req, url) => {
118
+ const session = sessionParam(url);
119
+ if (!session)
120
+ return text("missing ?session\n", 400);
121
+ const since = url.searchParams.get("since") ?? undefined;
122
+ return json(await control.entries(session, since !== undefined ? { since } : undefined));
123
+ }),
124
+ "POST /control/dispatch": guard(async (req) => {
125
+ const body = await readBodyCapped(req, DISPATCH_BODY_LIMIT);
126
+ // The 413 names the ceiling: the docs promise images on this plane, and an unexplained
127
+ // rejection would send a client author hunting everywhere but the cap.
128
+ if ("tooLarge" in body) {
129
+ // Derived from the constant — a hardcoded "1 MiB" would lie the day the cap changes.
130
+ return text(`body too large (limit ${MAX_BODY_BYTES >> 20} MiB — images count base64-inflated)\n`, 413);
131
+ }
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(body.text);
135
+ }
136
+ catch {
137
+ return text("invalid JSON\n", 400);
138
+ }
139
+ if (typeof parsed.session !== "string") {
140
+ return text("expected { session: string, command: SessionCommand }\n", 400);
141
+ }
142
+ const command = parseWireCommand(parsed.command);
143
+ if (!command) {
144
+ // Malformed shape = a protocol-level answer, mirrored from the hub's unknown-type default.
145
+ return json({
146
+ ok: false,
147
+ error: { code: INVALID_COMMAND_CODE, message: "malformed command", retryable: false },
148
+ });
149
+ }
150
+ // The result rides HTTP 200 either way: `ok: false` is a protocol-level answer (rejected
151
+ // before acceptance), not a transport failure.
152
+ return json(await control.dispatch(parsed.session, command));
153
+ }),
154
+ "GET /control/events": guard((_req, url) => {
155
+ const session = sessionParam(url);
156
+ if (!session)
157
+ return text("missing ?session\n", 400);
158
+ const iterator = control.events(session)[Symbol.asyncIterator]();
159
+ // EAGER registration: issue the first pull NOW, before the Response (and thus the client's
160
+ // fetch resolution) exists — hub subscription is registered synchronously inside next(), so
161
+ // "the client saw response headers" implies "events from that moment on will be delivered".
162
+ // Shrinks the subscribe/backfill race to network reordering instead of a full pull cycle.
163
+ let pending = iterator.next();
164
+ // Observed here so a client that disconnects BEFORE the first pull cannot turn a rejecting
165
+ // events iterator (this is the neutral contract face — any implementation may reject) into a
166
+ // process-killing unhandledRejection; awaiting `pending` at pull still surfaces the error.
167
+ pending.catch(() => { });
168
+ let seq = 0;
169
+ let stopHeartbeat = () => { };
170
+ const encoder = new TextEncoder();
171
+ const stream = new ReadableStream({
172
+ start(controller) {
173
+ stopHeartbeat = sseHeartbeat(controller);
174
+ },
175
+ async pull(controller) {
176
+ let next;
177
+ try {
178
+ next = await (pending ?? iterator.next());
179
+ }
180
+ catch (error) {
181
+ // A rejecting implementation (the neutral contract permits it) must not leak its
182
+ // subscription: an errored stream never gets cancel(), so the unsubscribe and the
183
+ // heartbeat teardown happen HERE.
184
+ stopHeartbeat();
185
+ void iterator.return?.(undefined)?.catch?.(() => { });
186
+ controller.error(error);
187
+ return;
188
+ }
189
+ pending = undefined;
190
+ if (next.done) {
191
+ stopHeartbeat();
192
+ controller.close();
193
+ return;
194
+ }
195
+ const wire = { sessionId: session, epoch, seq: seq++, event: next.value };
196
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(wire)}\n\n`));
197
+ },
198
+ cancel() {
199
+ stopHeartbeat();
200
+ // Same neutral-contract defense as the pull error path: a rejecting return() on client
201
+ // disconnect must not become a process-level unhandledRejection.
202
+ void iterator.return?.(undefined)?.catch?.(() => { });
203
+ },
204
+ });
205
+ return new Response(stream, {
206
+ headers: {
207
+ "content-type": "text/event-stream",
208
+ "cache-control": "no-cache",
209
+ connection: "keep-alive",
210
+ },
211
+ });
212
+ }),
213
+ };
214
+ }
@@ -11,7 +11,7 @@
11
11
  * Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
12
12
  * well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
13
13
  */
14
- import { truncateCodePointPrefix } from "./text.js";
14
+ import { truncateCodePointPrefix } from "../text.js";
15
15
  /** The one streamed element's id — shared by create (card.ts) and update (preview.ts). */
16
16
  export const ANSWER_ELEMENT_ID = "answer";
17
17
  /** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
+ * ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
4
+ * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
+ * concrete thread root) and folded into the next answered turn in that place.
6
+ */
7
+ import { type ContextBuffer } from "../context-buffer.ts";
1
8
  import type { NormalizedFeishuMessage } from "./model.ts";
2
9
  export interface FeishuBufferedResource {
3
10
  messageId: string;
@@ -32,15 +39,5 @@ export declare function collectFeishuBufferedAttachments(consumed: FeishuBufferE
32
39
  images: FeishuBufferedRef[];
33
40
  skipped: number;
34
41
  };
35
- export interface FeishuContextBuffer {
36
- /** Persist before webhook ACK. A failed write throws and rolls memory back for safe redelivery. */
37
- push(placeKey: string, entry: FeishuBufferEntry): void;
38
- /** Render and snapshot without clearing. */
39
- peek(placeKey: string): {
40
- text: string;
41
- consumed: FeishuBufferEntry[];
42
- };
43
- /** Remove only the consumed snapshot after `completed`; a post-ACK write failure is logged. */
44
- commit(placeKey: string, consumed: FeishuBufferEntry[]): void;
45
- }
42
+ export type FeishuContextBuffer = ContextBuffer<FeishuBufferEntry>;
46
43
  export declare function createFeishuContextBuffer(path: string, label: string): FeishuContextBuffer;
@@ -1,13 +1,10 @@
1
1
  /**
2
- * Durable context buffer for human group messages that do NOT currently summon the Feishu/Lark Agent.
3
- * Entries are bucketed by conversation place (main chat, or one concrete thread root) and folded into
4
- * the next answered turn in that place. The consume protocol mirrors Telegram: peek without clearing,
5
- * then commit exactly that snapshot only after the Agent emits `completed`.
2
+ * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
+ * ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
4
+ * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
+ * concrete thread root) and folded into the next answered turn in that place.
6
6
  */
7
- import { log } from "../../log.js";
8
- import { loadStateFile, saveStateFile } from "../state.js";
9
- const BUFFER_MAX_CHARS = 4000;
10
- const BUFFER_ATTACH_MAX = 3;
7
+ import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
11
8
  function bufferLine(entry) {
12
9
  const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
13
10
  .filter(Boolean)
@@ -71,63 +68,5 @@ function isEntry(value) {
71
68
  resources(entry.images));
72
69
  }
73
70
  export function createFeishuContextBuffer(path, label) {
74
- const load = () => {
75
- const raw = loadStateFile(path);
76
- if (raw === undefined)
77
- return new Map();
78
- if (typeof raw === "object" &&
79
- raw !== null &&
80
- !Array.isArray(raw) &&
81
- Object.values(raw).every((entries) => Array.isArray(entries) && entries.every(isEntry))) {
82
- return new Map(Object.entries(raw));
83
- }
84
- log.warn(`${label} unexpected shape in ${path} — starting with an empty context buffer`);
85
- return new Map();
86
- };
87
- const buffers = load();
88
- const persist = () => saveStateFile(path, Object.fromEntries(buffers));
89
- return {
90
- push(placeKey, entry) {
91
- const previous = buffers.get(placeKey);
92
- const entries = previous ? [...previous] : [];
93
- entries.push(entry);
94
- let total = entries.reduce((sum, candidate) => sum + bufferLine(candidate).length + 1, 0);
95
- while (entries.length > 1 && total > BUFFER_MAX_CHARS) {
96
- const dropped = entries.shift();
97
- if (dropped)
98
- total -= bufferLine(dropped).length + 1;
99
- }
100
- buffers.set(placeKey, entries);
101
- try {
102
- persist();
103
- }
104
- catch (error) {
105
- if (previous)
106
- buffers.set(placeKey, previous);
107
- else
108
- buffers.delete(placeKey);
109
- throw error;
110
- }
111
- },
112
- peek(placeKey) {
113
- const entries = buffers.get(placeKey) ?? [];
114
- return { text: entries.map(bufferLine).join("\n"), consumed: [...entries] };
115
- },
116
- commit(placeKey, consumed) {
117
- const entries = buffers.get(placeKey);
118
- if (!entries)
119
- return;
120
- const remaining = entries.filter((entry) => !consumed.includes(entry));
121
- if (remaining.length === 0)
122
- buffers.delete(placeKey);
123
- else
124
- buffers.set(placeKey, remaining);
125
- try {
126
- persist();
127
- }
128
- catch (error) {
129
- log.error(`${label} context-buffer write failed post-ACK (a restart may re-fold answered discussion): ${String(error)}`);
130
- }
131
- },
132
- };
71
+ return createGenericContextBuffer({ path, label, isEntry, line: bufferLine });
133
72
  }
@@ -34,6 +34,11 @@ export interface FeishuApiOptions {
34
34
  appId: string;
35
35
  appSecret: string;
36
36
  }
37
+ export interface FeishuAppScope {
38
+ name: string;
39
+ grantStatus: number;
40
+ type?: "user" | "tenant";
41
+ }
37
42
  /**
38
43
  * The Feishu Open API client: one instance per channel, holding the token cache. Every method rides the
39
44
  * single pipeline (module header). Throws {@link FeishuApiError} on any failure.
@@ -85,6 +90,12 @@ export interface FeishuApi {
85
90
  verificationToken?: string;
86
91
  encryptionKey?: string;
87
92
  }>;
93
+ /** List the app's scopes and grant state. Used by onboarding/runtime to make group visibility
94
+ * explicit instead of silently assuming unmentioned group events are delivered. */
95
+ listAppScopes(): Promise<FeishuAppScope[]>;
96
+ /** Add tenant scopes to the app draft through application-v7 config. Approval + version publishing
97
+ * remain console actions; this method only removes the error-prone manual draft edit. */
98
+ addAppScopes(appId: string, scopeNames: string[]): Promise<void>;
88
99
  /** Update the app's own event subscription (application-v7 config PATCH — tenant token can only
89
100
  * operate on itself; the request-URL change takes effect immediately, no version publish). The
90
101
  * platform VERIFIES `requestUrl` with a url_verification challenge during this call, so the server
@@ -100,9 +111,6 @@ export interface FeishuApi {
100
111
  /** Replace a card entity's content (the settle write; also flips streaming_mode off via the JSON). */
101
112
  updateCard(cardId: string, cardJson: string, sequence: number): Promise<void>;
102
113
  }
103
- /** The platform caps a text-message request body at 150 KB; stay well under it (the content is a JSON
104
- * envelope around the text, and multi-byte characters inflate the byte count). */
105
- export declare const FEISHU_MAX_TEXT_BYTES: number;
106
114
  /** Split text into chunks whose UTF-8 size fits the message cap, preferring a newline boundary. */
107
115
  export declare function chunkFeishuText(text: string, maxBytes?: number): string[];
108
116
  export declare function createFeishuApi(opts: FeishuApiOptions): FeishuApi;
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { mkdir, writeFile } from "node:fs/promises";
23
23
  import { join } from "node:path";
24
- import { utf8Prefix } from "./text.js";
24
+ import { utf8Prefix } from "../text.js";
25
25
  /** Per-attempt timeout for a JSON API call — small JSON round-trips, so 30s is generous. */
26
26
  const API_TIMEOUT_MS = 30_000;
27
27
  /** Timeout for downloading resource bytes — sized for a slow link, not a JSON call. */
@@ -79,7 +79,7 @@ export function isTransientFeishuRegistrationError(e) {
79
79
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
80
80
  /** The platform caps a text-message request body at 150 KB; stay well under it (the content is a JSON
81
81
  * envelope around the text, and multi-byte characters inflate the byte count). */
82
- export const FEISHU_MAX_TEXT_BYTES = 100 * 1024;
82
+ const FEISHU_MAX_TEXT_BYTES = 100 * 1024;
83
83
  /** Split text into chunks whose UTF-8 size fits the message cap, preferring a newline boundary. */
84
84
  export function chunkFeishuText(text, maxBytes = FEISHU_MAX_TEXT_BYTES) {
85
85
  if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
@@ -296,6 +296,24 @@ export function createFeishuApi(opts) {
296
296
  encryptionKey: data.data?.app?.encryption?.encryption_key,
297
297
  };
298
298
  },
299
+ async listAppScopes() {
300
+ const data = await call("listAppScopes", "GET", "/open-apis/application/v6/scopes");
301
+ return (data.data?.scopes ?? []).flatMap((scope) => {
302
+ if (typeof scope.scope_name !== "string" || typeof scope.grant_status !== "number")
303
+ return [];
304
+ const type = scope.scope_type === "user" || scope.scope_type === "tenant" ? scope.scope_type : undefined;
305
+ return [{ name: scope.scope_name, grantStatus: scope.grant_status, type }];
306
+ });
307
+ },
308
+ async addAppScopes(appId, scopeNames) {
309
+ if (scopeNames.length === 0)
310
+ return;
311
+ await call("addAppScopes", "PATCH", `/open-apis/application/v7/applications/${encodeURIComponent(appId)}/config`, {
312
+ scope: {
313
+ add_scopes: scopeNames.map((scopeName) => ({ scope_name: scopeName, token_type: "tenant" })),
314
+ },
315
+ });
316
+ },
299
317
  async updateEventSubscription(appId, cfg) {
300
318
  await call("updateEventSubscription", "PATCH", `/open-apis/application/v7/applications/${encodeURIComponent(appId)}/config`, {
301
319
  event: { subscription_type: cfg.subscriptionType, request_url: cfg.requestUrl },