@fastagent-sh/fastagent 0.18.0 → 0.20.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 (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * How a {@link Routes} table becomes a running server: the path rule, dispatch, the totality
3
+ * boundary, and the node:http binding.
4
+ *
5
+ * Shared ground, not a deployment target — every host in `src/deploy/` runs this same process; what
6
+ * differs between them is process, storage, credentials and deployment (core.md §1), none of it
7
+ * here.
8
+ *
9
+ * Dispatch is a Map lookup rather than a router because a deployment mounts a handful of LITERAL
10
+ * paths. A routing library would answer through a pattern language we do not use, and its extra
11
+ * semantics (decode-before-match, HEAD fallback, wildcard precedence) would have to be predicted by
12
+ * every collision check.
13
+ *
14
+ * The types a channel author or an embedder writes stay pure Fetch — SPEC §11 fixes the gateway
15
+ * contract as `(Request) => Response`, and an embedding app may already run its own framework.
16
+ * `overrideGlobalObjects: false` is part of that: the adapter otherwise swaps
17
+ * `globalThis.Request`/`Response` process-wide, which breaks a channel holding a `Response`
18
+ * captured before mount (it fails `instanceof` in {@link totalFetch} and is answered 500).
19
+ *
20
+ * Post-ACK work (a webhook channel's fire-and-forget turns) runs on this event loop and is lost on
21
+ * shutdown — the accepted tradeoff until durable execution (the K axis) exists.
22
+ */
23
+ import { serve, getRequestListener } from "@hono/node-server";
24
+ import { log } from "../log.js";
25
+ import { text } from "./respond.js";
26
+ /** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method).
27
+ * An empty method (`" /x"`) parses as `""`, which {@link assertRouteKey} refuses — see there. */
28
+ export function parseRouteKey(key) {
29
+ const sp = key.indexOf(" ");
30
+ return sp === -1 ? { path: key } : { method: key.slice(0, sp).toUpperCase(), path: key.slice(sp + 1) };
31
+ }
32
+ /**
33
+ * A route key is `"METHOD /path"` or `"/path"`, with a literal path.
34
+ *
35
+ * Every rule here exists because breaking it costs SOMEONE ELSE. Two keys naming one route, a route
36
+ * under a mount, a path a URL rewrites (`/a/../x` and `/x` are one route spelled two ways, while
37
+ * the conflict check compares strings) — in each case a channel goes dark and its author did
38
+ * nothing wrong.
39
+ *
40
+ * Mistakes that only cost their own author a debugging session are NOT policed: `:id` and `*` are
41
+ * literal path characters here, so such a key simply never matches, and an unusual method is the
42
+ * same. Refusing those would trade a working capability for a lecture.
43
+ */
44
+ export function assertRouteKey(key, describe) {
45
+ const { method, path } = parseRouteKey(key);
46
+ if (method === "")
47
+ throw new Error(describe('a leading space is not a method — write "/path" for any method'));
48
+ if (!path.startsWith("/"))
49
+ throw new Error(describe('must start with "/"'));
50
+ // Asked of `URL` rather than by listing what it rewrites (`?`/`#`, `.`/`..`, `\`, `%2e`). A key it
51
+ // rewrites is unreachable AND compares as a different string, hiding that `/a/../x` and `/x` are
52
+ // one route.
53
+ const arrives = new URL(path, "http://x").pathname;
54
+ if (arrives !== path) {
55
+ throw new Error(describe(`is not the path a request would carry — that request arrives as "${arrives}"`));
56
+ }
57
+ }
58
+ /** Do these two keys fight over the same request? Equal paths, and a method each answers; a key
59
+ * with no method answers all of them. */
60
+ export function routeKeysConflict(a, b) {
61
+ const ka = parseRouteKey(a);
62
+ const kb = parseRouteKey(b);
63
+ if (ka.path !== kb.path)
64
+ return false;
65
+ return ka.method === undefined || kb.method === undefined || ka.method === kb.method;
66
+ }
67
+ /** Same status and headers, no content (RFC 9110's HEAD). The discarded body is cancelled, or a
68
+ * streaming producer keeps running with no reader. Shared with the control plane, which answers
69
+ * HEAD too. */
70
+ export function withoutBody(res) {
71
+ void res.body?.cancel().catch(() => { });
72
+ return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
73
+ }
74
+ /** Is `path` inside `prefix`? Segment-wise, so `/controlled` is not inside `/control`. */
75
+ export function pathUnderPrefix(path, prefix) {
76
+ return path === prefix || path.startsWith(`${prefix}/`);
77
+ }
78
+ /**
79
+ * Compose a {@link Routes} table and its {@link PrefixMount}s into one handler.
80
+ *
81
+ * Refuses at assembly anything that could not receive a request: a key naming the same route as
82
+ * another (`"/x"` and `"GET /x"`), a route inside a mount, two mounts claiming the same ground. A
83
+ * channel must never go dark unannounced.
84
+ *
85
+ * 404 and 405 stay distinct: a remote client reads 404 as "this serve predates the route" (version
86
+ * skew) rather than as a fault.
87
+ */
88
+ export function router(routes, mounts = []) {
89
+ for (const [i, mount] of mounts.entries()) {
90
+ assertRouteKey(mount.prefix, (problem) => `mount prefix "${mount.prefix}" is invalid — ${problem}`);
91
+ if (mount.prefix === "/") {
92
+ throw new Error(`mount prefix "/" is invalid — a handler owning every path IS that handler; serve it directly`);
93
+ }
94
+ if (mount.prefix.endsWith("/")) {
95
+ throw new Error(`mount prefix "${mount.prefix}" is invalid — no trailing slash (write "/control")`);
96
+ }
97
+ const clash = mounts
98
+ .slice(0, i)
99
+ .find((other) => pathUnderPrefix(mount.prefix, other.prefix) || pathUnderPrefix(other.prefix, mount.prefix));
100
+ if (clash) {
101
+ throw new Error(`mount "${mount.prefix}" overlaps "${clash.prefix}" — one of them would never receive a request`);
102
+ }
103
+ }
104
+ const byKey = new Map();
105
+ const paths = new Set();
106
+ for (const [key, handler] of Object.entries(routes)) {
107
+ assertRouteKey(key, (problem) => `route "${key}" is not a valid route key — ${problem}`);
108
+ const { path } = parseRouteKey(key);
109
+ for (const mount of mounts) {
110
+ if (pathUnderPrefix(path, mount.prefix)) {
111
+ throw new Error(`route "${key}" is inside the mount "${mount.prefix}" — it would never receive a request`);
112
+ }
113
+ }
114
+ const shadowed = [...byKey.keys()].find((other) => routeKeysConflict(other, key));
115
+ if (shadowed) {
116
+ throw new Error(`route "${key}" conflicts with "${shadowed}" — one of them would never receive a request`);
117
+ }
118
+ // Stored normalised: `parseRouteKey` upper-cases the method, so `"get /x"` validates under
119
+ // `GET` and would otherwise be looked up under a name nothing stores.
120
+ const { method } = parseRouteKey(key);
121
+ byKey.set(method ? `${method} ${path}` : path, handler);
122
+ paths.add(path);
123
+ }
124
+ return (req) => {
125
+ // `URL` normalises the path (`/a/../x` → `/x`) and drops query/fragment.
126
+ const path = new URL(req.url).pathname;
127
+ const answer = () => {
128
+ for (const mount of mounts)
129
+ if (pathUnderPrefix(path, mount.prefix))
130
+ return mount.handler(req);
131
+ const exact = byKey.get(`${req.method} ${path}`) ?? byKey.get(path);
132
+ if (exact)
133
+ return exact(req);
134
+ if (req.method === "HEAD") {
135
+ const get = byKey.get(`GET ${path}`);
136
+ if (get)
137
+ return get(req);
138
+ }
139
+ return paths.has(path) ? text("method not allowed\n", 405) : text("not found\n", 404);
140
+ };
141
+ // ONE exit, so the HEAD rule holds for every reply — a mount's, a route's, the GET fallback's,
142
+ // and the 404/405 this router writes itself. RFC 9110: HEAD is GET without the content.
143
+ const res = answer();
144
+ if (req.method !== "HEAD")
145
+ return res;
146
+ return res instanceof Promise ? res.then(withoutBody) : withoutBody(res);
147
+ };
148
+ }
149
+ /**
150
+ * The totality boundary every serving path shares. `loadChannels` imports arbitrary author code, so
151
+ * a channel that throws — or forgets to return — must become a logged 500 rather than escape as an
152
+ * unhandled rejection. Both `nodeListener` and `serveNode` route through it.
153
+ *
154
+ * The message stays internal: an adapter's default error page would echo exception text (a stack, a
155
+ * path, a key inside an error string) to whoever made the request. Visibility is the LOG's job.
156
+ */
157
+ function totalFetch(handler) {
158
+ return async (req) => {
159
+ try {
160
+ const response = await handler(req);
161
+ if (!(response instanceof Response))
162
+ throw new TypeError("handler did not return a Response");
163
+ return response;
164
+ }
165
+ catch (error) {
166
+ log.error(`[serve] request failed: ${String(error)}`);
167
+ return text("internal error\n", 500);
168
+ }
169
+ };
170
+ }
171
+ /**
172
+ * Has upstream middleware already drained the request body?
173
+ *
174
+ * Node's `req` is one-shot, so a body parser mounted ahead of this route consumes it — a property of
175
+ * the Node/Fetch seam, not of this adapter. What is avoidable is the diagnosis: undici answers
176
+ * `TypeError: Body is unusable`, naming neither the cause nor the fix, and for a webhook channel
177
+ * that reads as "the integration is broken and the platform keeps retrying".
178
+ *
179
+ * Answers only when CERTAIN — a positive `content-length`. `readableEnded` alone is true for any
180
+ * request something upstream merely touched, and an empty chunked body is legal, arrives drained,
181
+ * and is indistinguishable from an eaten one. Guessing there rejects valid requests, making this
182
+ * guard the outage it explains.
183
+ */
184
+ function bodyAlreadyRead(req) {
185
+ const length = Number(req.headers["content-length"]);
186
+ return Number.isFinite(length) && length > 0 && req.readableEnded;
187
+ }
188
+ /** The node:http adapter for a Fetch handler — the embedded server uses it, and an embedder mounting
189
+ * fastagent on its OWN node:http server can too.
190
+ *
191
+ * Takes the same {@link ChannelHandler} `serveNode` does, so both doors accept the same thing: a
192
+ * handler may answer synchronously, and `router()` returns exactly that. Requiring a Promise here
193
+ * made the most natural mount — `nodeListener(router(routes))`, the whole agent on the app's own
194
+ * server — a type error. */
195
+ export function nodeListener(handler) {
196
+ const listener = getRequestListener(totalFetch(handler), { overrideGlobalObjects: false });
197
+ return (req, res) => {
198
+ if (bodyAlreadyRead(req)) {
199
+ log.error(`[serve] ${req.method} ${req.url}: the request body was already read by upstream middleware ` +
200
+ `(e.g. express.json()) — mount fastagent BEFORE the body parser, or scope the parser away ` +
201
+ `from this route. Channels that verify webhook signatures need the RAW body.`);
202
+ res.writeHead(500, { "content-type": "text/plain" });
203
+ res.end("internal error\n");
204
+ return;
205
+ }
206
+ listener(req, res);
207
+ };
208
+ }
209
+ /**
210
+ * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
211
+ * accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
212
+ * `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
213
+ * `host` is the bind address; unset means all interfaces (what containers need).
214
+ */
215
+ export function serveNode(handler, options) {
216
+ let onListening;
217
+ let onBindError;
218
+ const listening = new Promise((resolve, reject) => {
219
+ onListening = resolve;
220
+ onBindError = reject;
221
+ });
222
+ // serve() types its return as the union of every server it CAN build (incl. http2). We never pass
223
+ // a createServer/http2 option, so it is always node:http's Server — the one carrying
224
+ // closeAllConnections, which the caller-owned force-close depends on.
225
+ const server = serve({
226
+ fetch: totalFetch(handler),
227
+ overrideGlobalObjects: false,
228
+ port: options.port,
229
+ ...(options.host !== undefined ? { hostname: options.host } : {}),
230
+ }, (info) => {
231
+ // Detach before resolving: this listener answers the BIND, and leaving it attached would let
232
+ // a later runtime error call reject() on a settled promise — swallowed, with nothing raised
233
+ // anywhere. Detached, an error after bind is an unhandled 'error' event, which is loud.
234
+ // (Not reachable from a test without forging an event on a server this function does not
235
+ // expose; the bind-failure half below is covered.)
236
+ server.off("error", onBindError);
237
+ onListening(info.port);
238
+ });
239
+ server.once("error", onBindError); // a bind failure surfaces here, before "listening"
240
+ const close = () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
241
+ const closeAllConnections = () => server.closeAllConnections();
242
+ return { listening, close, closeAllConnections };
243
+ }
@@ -1,6 +1,6 @@
1
1
  /** Rotating Slack bot-token provider backed by owner-only channel state. */
2
- import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
2
+ import { readFileSync } from "node:fs";
3
+ import { writeFileAtomic } from "../../atomic-write.js";
4
4
  const REFRESH_EARLY_MS = 5 * 60_000;
5
5
  const REQUEST_TIMEOUT_MS = 30_000;
6
6
  function isState(value) {
@@ -35,18 +35,7 @@ function load(path) {
35
35
  }
36
36
  }
37
37
  function save(path, state) {
38
- mkdirSync(dirname(path), { recursive: true });
39
- const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
40
- try {
41
- writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
42
- chmodSync(temp, 0o600);
43
- renameSync(temp, path);
44
- chmodSync(path, 0o600);
45
- }
46
- catch (error) {
47
- rmSync(temp, { force: true });
48
- throw error;
49
- }
38
+ writeFileAtomic(path, JSON.stringify(state), 0o600);
50
39
  }
51
40
  async function refreshSlackBotToken(input) {
52
41
  const authorization = Buffer.from(`${input.clientId}:${input.clientSecret}`, "utf8").toString("base64");
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Slack's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
2
+ * Slack's half of the shared context buffer (mechanics + consume protocol: ../kit/context-buffer.ts):
3
3
  * the entry shape, its fold-line rendering, and buffered-file selection. Durable, bounded
4
4
  * unsummoned Slack discussion folded into the next answered turn in the same place.
5
5
  */
6
- import { type ContextBuffer } from "../context-buffer.ts";
6
+ import { type ContextBuffer } from "../kit/context-buffer.ts";
7
7
  export interface SlackBufferEntry {
8
8
  sender: string;
9
9
  body: string;
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Slack's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
2
+ * Slack's half of the shared context buffer (mechanics + consume protocol: ../kit/context-buffer.ts):
3
3
  * the entry shape, its fold-line rendering, and buffered-file selection. Durable, bounded
4
4
  * unsummoned Slack discussion folded into the next answered turn in the same place.
5
5
  */
6
- import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
6
+ import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
7
7
  function bufferLine(entry) {
8
8
  const meta = [`msg ${entry.messageId}`, entry.replyTo ? `thread root ${entry.replyTo}` : undefined]
9
9
  .filter(Boolean)
@@ -1,8 +1,8 @@
1
1
  /** Resolve Slack file IDs at dequeue, then stream one engine-neutral Agent turn. */
2
2
  import type { Agent, AgentEvent } from "../../agent.ts";
3
- import { type BusyRetry } from "../invoke-turn-kit.ts";
3
+ import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
4
4
  import type { SlackBufferedFileRef } from "./context-buffer.ts";
5
- import type { SlackApi } from "./slack-api.ts";
5
+ import { type SlackApi } from "./slack-api.ts";
6
6
  export interface SlackTurnTransport {
7
7
  api: SlackApi;
8
8
  channelId: string;
@@ -1,11 +1,12 @@
1
1
  import { log } from "../../log.js";
2
- import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
2
+ import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
3
+ import { SlackApiError } from "./slack-api.js";
3
4
  const MARKDOWN_INSTRUCTION = "\n\n(Format your reply as standard Markdown. Slack renders it natively. Do not use HTML or Slack control-mention syntax such as <!here>, <!channel>, or <!everyone>.)";
4
5
  async function resolveFile(transport, fileId) {
5
6
  const info = await transport.api.fileInfo(fileId);
6
- return info.mimetype?.toLowerCase().startsWith("image/")
7
- ? { image: await transport.api.fetchImage(info) }
8
- : { file: await transport.api.fetchFile(info, transport.channelId, transport.filesDir) };
7
+ if (info.mimetype?.toLowerCase().startsWith("image/"))
8
+ return { image: await transport.api.fetchImage(info) };
9
+ return { file: await transport.api.fetchFile(info, transport.channelId, transport.filesDir) };
9
10
  }
10
11
  async function resolveInputs(transport, attachments) {
11
12
  const images = [];
@@ -20,7 +21,10 @@ async function resolveInputs(transport, attachments) {
20
21
  const backgroundImages = [];
21
22
  const backgroundFiles = [];
22
23
  let lost = 0;
23
- const results = await Promise.allSettled(attachments.buffered.files.map(async (ref) => ({ ref, resolved: await resolveFile(transport, ref.id) })));
24
+ const results = await Promise.allSettled(attachments.buffered.files.map(async (ref) => ({
25
+ ref,
26
+ resolved: await resolveFile(transport, ref.id),
27
+ })));
24
28
  for (const result of results) {
25
29
  if (result.status === "fulfilled") {
26
30
  const { ref, resolved } = result.value;
@@ -55,7 +59,10 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
55
59
  resolved = await resolveInputs(transport, attachments);
56
60
  }
57
61
  catch (error) {
58
- yield { type: "failed", details: `could not load Slack attachment: ${String(error)}`, retryable: true };
62
+ // transient (network, exhausted 429 retries, Slack 5xx) is worth re-sending; an access or shape
63
+ // error reads the same every time, and "try again in a moment" is the wrong thing to tell the user
64
+ const retryable = error instanceof SlackApiError && (error.status === 0 || error.status === 429 || error.status >= 500);
65
+ yield { type: "failed", details: `could not load Slack attachment: ${String(error)}`, retryable };
59
66
  return;
60
67
  }
61
68
  const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
@@ -22,7 +22,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
22
22
  // Record BEFORE the non-idempotent API call. A transport/internal failure may have created the app;
23
23
  // refusing a blind retry is safer than silently producing duplicates.
24
24
  state = { ...state, createAttemptedAt: new Date().toISOString() };
25
- await writeSlackOnboardingState(input.stateRoot, state);
25
+ writeSlackOnboardingState(input.stateRoot, state);
26
26
  let created;
27
27
  try {
28
28
  created = await (deps.createApp ?? createSlackApp)(current.token, manifest);
@@ -32,7 +32,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
32
32
  ["fatal_error", "internal_error", "request_timeout", "service_unavailable", "failed_creating_app"].includes(error.code);
33
33
  if (!ambiguous) {
34
34
  state = { ...state, createAttemptedAt: undefined };
35
- await writeSlackOnboardingState(input.stateRoot, state);
35
+ writeSlackOnboardingState(input.stateRoot, state);
36
36
  }
37
37
  throw error;
38
38
  }
@@ -45,7 +45,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
45
45
  signingSecret: created.signingSecret,
46
46
  };
47
47
  // Irreversible boundary first: a cancellation or .env write failure can resume without creating a duplicate.
48
- await writeSlackOnboardingState(input.stateRoot, state);
48
+ writeSlackOnboardingState(input.stateRoot, state);
49
49
  io.note(`Created Slack app ${created.appId}; credentials captured locally.`);
50
50
  }
51
51
  else {
@@ -59,7 +59,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
59
59
  if (state.signingSecret) {
60
60
  await io.writeRuntimeSecrets({ signingSecret: state.signingSecret });
61
61
  state = { ...state, signingSecret: undefined };
62
- await writeSlackOnboardingState(input.stateRoot, state);
62
+ writeSlackOnboardingState(input.stateRoot, state);
63
63
  }
64
64
  if (!state.appId || !state.clientId || !state.clientSecret) {
65
65
  throw new Error("Slack onboarding state lost app OAuth credentials before installation");
@@ -104,7 +104,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
104
104
  teamName: oauth.teamName,
105
105
  installedAt: new Date().toISOString(),
106
106
  };
107
- await writeSlackOnboardingState(input.stateRoot, state);
107
+ writeSlackOnboardingState(input.stateRoot, state);
108
108
  return state;
109
109
  }
110
110
  export function newSlackOnboardingState(input) {
@@ -18,9 +18,13 @@ export interface SlackOnboardingState {
18
18
  teamName?: string;
19
19
  installedAt?: string;
20
20
  }
21
- export declare function readSlackOnboardingState(stateRoot: string): Promise<SlackOnboardingState | undefined>;
22
- /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh token. */
23
- export declare function writeSlackOnboardingState(stateRoot: string, state: SlackOnboardingState): Promise<void>;
21
+ export declare function readSlackOnboardingState(stateRoot: string): SlackOnboardingState | undefined;
22
+ /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
23
+ * token. Synchronous, through the shared writer, like every other piece of state this repo keeps
24
+ * (kit/state.ts): the file is ~1 KB and its writers are `fastagent add slack` and one config-token
25
+ * rotation at tunnel startup, so the async spelling bought nothing and cost a fifth set of temp-name
26
+ * and permission rules to keep true. */
27
+ export declare function writeSlackOnboardingState(stateRoot: string, state: SlackOnboardingState): void;
24
28
  export declare function currentSlackConfigToken(stateRoot: string, state: SlackOnboardingState, options?: {
25
29
  now?: number;
26
30
  apiBaseUrl?: string;
@@ -1,5 +1,6 @@
1
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeFileAtomic } from "../../atomic-write.js";
3
4
  import { rotateSlackConfigToken } from "./config-api.js";
4
5
  function slackOnboardingStatePath(stateRoot) {
5
6
  return join(stateRoot, "channels", "slack", "onboarding.json");
@@ -15,11 +16,11 @@ function validState(value) {
15
16
  typeof state.configRefreshToken === "string" &&
16
17
  typeof state.configTokenExpiresAt === "number");
17
18
  }
18
- export async function readSlackOnboardingState(stateRoot) {
19
+ export function readSlackOnboardingState(stateRoot) {
19
20
  const file = slackOnboardingStatePath(stateRoot);
20
21
  let raw;
21
22
  try {
22
- raw = await readFile(file, "utf8");
23
+ raw = readFileSync(file, "utf8");
23
24
  }
24
25
  catch (error) {
25
26
  if (error.code === "ENOENT")
@@ -36,21 +37,13 @@ export async function readSlackOnboardingState(stateRoot) {
36
37
  throw new Error(`invalid Slack onboarding state ${file}: ${error.message}`);
37
38
  }
38
39
  }
39
- /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh token. */
40
- export async function writeSlackOnboardingState(stateRoot, state) {
41
- const file = slackOnboardingStatePath(stateRoot);
42
- await mkdir(dirname(file), { recursive: true });
43
- const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
44
- try {
45
- await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
46
- await chmod(temp, 0o600);
47
- await rename(temp, file);
48
- await chmod(file, 0o600);
49
- }
50
- catch (error) {
51
- await rm(temp, { force: true }).catch(() => { });
52
- throw error;
53
- }
40
+ /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
41
+ * token. Synchronous, through the shared writer, like every other piece of state this repo keeps
42
+ * (kit/state.ts): the file is ~1 KB and its writers are `fastagent add slack` and one config-token
43
+ * rotation at tunnel startup, so the async spelling bought nothing and cost a fifth set of temp-name
44
+ * and permission rules to keep true. */
45
+ export function writeSlackOnboardingState(stateRoot, state) {
46
+ writeFileAtomic(slackOnboardingStatePath(stateRoot), `${JSON.stringify(state, null, 2)}\n`, 0o600);
54
47
  }
55
48
  export async function currentSlackConfigToken(stateRoot, state, options = {}) {
56
49
  const now = options.now ?? Date.now();
@@ -64,6 +57,6 @@ export async function currentSlackConfigToken(stateRoot, state, options = {}) {
64
57
  configTokenExpiresAt: rotated.expiresAt,
65
58
  teamId: state.teamId ?? rotated.teamId,
66
59
  };
67
- await writeSlackOnboardingState(stateRoot, next);
60
+ writeSlackOnboardingState(stateRoot, next);
68
61
  return { token: next.configToken, state: next };
69
62
  }
@@ -1,5 +1,5 @@
1
- import { BUFFER_LINE_MAX_CHARS } from "../context-buffer.js";
2
- import { codePointPrefix, truncateCodePointPrefix } from "../text.js";
1
+ import { BUFFER_LINE_MAX_CHARS } from "../kit/context-buffer.js";
2
+ import { codePointPrefix, truncateCodePointPrefix } from "../kit/text.js";
3
3
  const HUMAN_MESSAGE_SUBTYPES = new Set(["file_share", "thread_broadcast"]);
4
4
  /**
5
5
  * Both forms Slack writes a mention in: `<@U123>` and the labelled `<@U123|name>`. ONE definition,
@@ -1,6 +1,6 @@
1
1
  /** Slack reply rendering: native Agent streams first, rate-safe edited-message compatibility second. */
2
2
  import type { AgentEvent } from "../../agent.ts";
3
- import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
3
+ import { type ChannelFailure, defaultErrorMessage } from "../kit/preview-kit.ts";
4
4
  import { type SlackApi, type SlackTarget } from "./slack-api.ts";
5
5
  export type SlackFailure = ChannelFailure;
6
6
  export type SlackRendering = "native" | "classic";
@@ -1,5 +1,5 @@
1
1
  import { log } from "../../log.js";
2
- import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createTurnView, defaultErrorMessage, humanizeToolName, revealedAnswer, summarizeToolArgs, toolLines, } from "../preview-kit.js";
2
+ import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createTurnView, defaultErrorMessage, humanizeToolName, revealedAnswer, summarizeToolArgs, toolLines, } from "../kit/preview-kit.js";
3
3
  import { chunkSlackMarkdown, chunkSlackText, isSlackNativeUnavailable, } from "./slack-api.js";
4
4
  export { defaultErrorMessage };
5
5
  const CLASSIC_UPDATE_INTERVAL_MS = 3_000;
@@ -175,7 +175,11 @@ async function streamClassicSlackReply(events, api, target, formatError, initial
175
175
  if (event.type === "failed") {
176
176
  await finishPump();
177
177
  finalized = true;
178
- const notice = formatError({ details: event.details, retryable: event.retryable }) ?? "";
178
+ const notice = formatError({
179
+ details: event.details,
180
+ retryable: event.retryable,
181
+ ...(event.code !== undefined ? { code: event.code } : {}),
182
+ }) ?? "";
179
183
  await finalize(notice).catch((error) => log.error(`${label} failed to deliver the agent-failure notice: ${String(error)}`));
180
184
  throw new Error(`agent failed: ${event.details} (retryable=${event.retryable})`);
181
185
  }
@@ -367,7 +371,11 @@ async function streamNativeSlackReply(events, api, target, formatError, initialP
367
371
  }
368
372
  else if (event.type === "failed") {
369
373
  finalized = true;
370
- const notice = formatError({ details: event.details, retryable: event.retryable }) ?? "";
374
+ const notice = formatError({
375
+ details: event.details,
376
+ retryable: event.retryable,
377
+ ...(event.code !== undefined ? { code: event.code } : {}),
378
+ }) ?? "";
371
379
  if (notice) {
372
380
  pendingText += `${fullAnswer.trim() ? "\n\n" : ""}${notice}`;
373
381
  fullAnswer += `${fullAnswer.trim() ? "\n\n" : ""}${notice}`;
@@ -8,7 +8,7 @@ export async function registerSlackWebhook(baseUrl, options) {
8
8
  const publicBaseUrl = baseUrl.replace(/\/$/, "");
9
9
  let state;
10
10
  try {
11
- state = await readSlackOnboardingState(options.stateRoot);
11
+ state = readSlackOnboardingState(options.stateRoot);
12
12
  }
13
13
  catch (error) {
14
14
  note(`[fastagent] slack: cannot read local onboarding state: ${String(error)}`);
@@ -71,22 +71,6 @@ async function callSlack<T extends { ok?: boolean; error?: string }>(
71
71
  }
72
72
  }
73
73
 
74
- function trustedUploadUrl(value: string): URL {
75
- const url = new URL(value);
76
- const host = url.hostname.toLowerCase();
77
- const slackHost =
78
- host === "slack-files.com" ||
79
- host.endsWith(".slack-files.com") ||
80
- host === "slack.com" ||
81
- host.endsWith(".slack.com") ||
82
- host === "slack-edge.com" ||
83
- host.endsWith(".slack-edge.com");
84
- if (url.protocol !== "https:" || !slackHost) {
85
- throw new Error(`Slack returned an untrusted upload URL host: ${host}`);
86
- }
87
- return url;
88
- }
89
-
90
74
  export default defineTool({
91
75
  description:
92
76
  "Upload one local file to Slack (`path`), or send a message (`text`) for a turn NO channel is " +
@@ -140,7 +124,8 @@ export default defineTool({
140
124
  let byteResponse: Response;
141
125
  const handle = await open(filePath, "r");
142
126
  try {
143
- byteResponse = await fetch(trustedUploadUrl(upload.upload_url), {
127
+ // where getUploadURLExternal points is Slack's call: these bytes are on their way to Slack either way
128
+ byteResponse = await fetch(upload.upload_url, {
144
129
  method: "POST",
145
130
  headers: { "content-type": "application/octet-stream" },
146
131
  body: handle.readableWebStream(),