@fastagent-sh/fastagent 0.19.0 → 0.21.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 (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
@@ -1,12 +1,21 @@
1
- import { INVALID_COMMAND_CODE } from "../session.js";
2
- import { timingSafeEqual } from "node:crypto";
1
+ import { INVALID_COMMAND_CODE, SESSIONS_UNAVAILABLE_CODE, UNSUPPORTED_CAPABILITY_CODE, UPDATE_FIELDS, } from "../session.js";
3
2
  import { parseRouteKey, withoutBody } from "./serve.js";
4
3
  import { log } from "../log.js";
5
4
  import { readBodyCapped } from "./body.js";
6
- import { MAX_BODY_BYTES, createInvokeHandler, sseHeartbeat } from "./http.js";
5
+ import { MAX_BODY_BYTES, createInvokeHandler } from "./http.js";
6
+ import { sseResponse } from "./sse.js";
7
7
  import { text } from "./respond.js";
8
+ import { secretEquals } from "./secret.js";
8
9
  /** The prefix this plane OWNS: everything under it is the plane's to answer. */
9
10
  const CONTROL_PREFIX = "/control";
11
+ /** The one variable segment in this plane's paths: a percent-encoded session id. Written into route
12
+ * keys so the table reads like the URLs it serves. */
13
+ const SESSION_SEGMENT = "{session}";
14
+ /** The token, when the DEPLOYER owns it rather than the box (`mountSessionControl` reads it, `deploy`
15
+ * carries it). Declared here with the prefix because both are the plane's public names: the serving
16
+ * side and the deploy side must spell it identically, and a rename that hits only one of them fails
17
+ * silently — the box mints its own and every caller the runbook told gets a 401. */
18
+ export const CONTROL_TOKEN_ENV = "FASTAGENT_CONTROL_TOKEN";
10
19
  const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
11
20
  /**
12
21
  * The plane as one mounted sub-application rather than routes sharing a prefix.
@@ -26,23 +35,58 @@ const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`,
26
35
  * the WRITE routes unreachable while reads work.
27
36
  */
28
37
  function planeApp(routes) {
29
- // Normalised keys (see serve.ts): the method is upper-cased when parsed.
30
- const byKey = new Map(Object.entries(routes).map(([key, handler]) => {
38
+ /** A route key's path split into segments, with `{session}` marked. Paths are matched SEGMENT BY
39
+ * SEGMENT rather than by regex because a session id is an opaque Caller string: percent-encoded
40
+ * it can contain anything, and `URL.pathname` leaves `%2F` encoded — so splitting on `/` cannot
41
+ * be fooled by an id that contains one. */
42
+ const compiled = Object.entries(routes).map(([key, handler]) => {
31
43
  const { method, path } = parseRouteKey(key);
32
- return [method ? `${method} ${path}` : path, handler];
33
- }));
34
- const methodsByPath = new Map();
35
- for (const key of Object.keys(routes)) {
36
- const { method, path } = parseRouteKey(key);
37
- const methods = methodsByPath.get(path) ?? new Set();
38
- if (method)
39
- methods.add(method);
40
- methodsByPath.set(path, methods);
41
- }
44
+ return { method, path, segments: path.split("/"), handler };
45
+ });
46
+ const match = (path) => {
47
+ const segments = path.split("/");
48
+ const hits = [];
49
+ for (const route of compiled) {
50
+ if (route.segments.length !== segments.length)
51
+ continue;
52
+ let session;
53
+ let ok = true;
54
+ for (const [i, expected] of route.segments.entries()) {
55
+ const actual = segments[i];
56
+ if (expected === SESSION_SEGMENT) {
57
+ // The one place a path segment becomes a Caller id again. An empty segment is not an id —
58
+ // it would address a session no other call can name.
59
+ if (actual === "") {
60
+ ok = false;
61
+ break;
62
+ }
63
+ try {
64
+ session = decodeURIComponent(actual);
65
+ }
66
+ catch {
67
+ // `%zz` and friends: not an id any client could have produced, so this path matches
68
+ // nothing and falls through to the plane's own 404. Decoding runs BEFORE the try that
69
+ // guards the handlers, so letting it throw would leave the boundary entirely — no CORS
70
+ // headers, no log line, and a rejected promise for an embedder mounting this handler
71
+ // directly. The query-parameter form this replaced decoded leniently and could not.
72
+ ok = false;
73
+ break;
74
+ }
75
+ }
76
+ else if (expected !== actual) {
77
+ ok = false;
78
+ break;
79
+ }
80
+ }
81
+ if (ok)
82
+ hits.push({ route, session });
83
+ }
84
+ return hits;
85
+ };
42
86
  // Per PATH, stating what it actually serves — omitting a method it does serve has the browser
43
87
  // refuse a call that would have worked. `HEAD` is that case: every GET route answers it.
44
- const allowMethods = (path, requested) => {
45
- const methods = new Set(methodsByPath.get(path) ?? []);
88
+ const allowMethods = (hits, requested) => {
89
+ const methods = new Set(hits.flatMap((h) => (h.route.method ? [h.route.method] : [])));
46
90
  if (methods.has("GET"))
47
91
  methods.add("HEAD");
48
92
  // The requested method is always allowed, even where this path does not serve it: preflight is a
@@ -55,22 +99,18 @@ function planeApp(routes) {
55
99
  };
56
100
  return async (req) => {
57
101
  const path = new URL(req.url).pathname;
58
- const known = methodsByPath.has(path);
102
+ const hits = match(path);
59
103
  const answer = async () => {
60
104
  // A preflight carries no token — that is its purpose — so it is answered before auth, and for
61
105
  // ANY path under the prefix: gating it would stop the request the 404 below is waiting for.
62
106
  if (req.method === "OPTIONS")
63
107
  return new Response(null, { status: 204 });
64
- const handler = byKey.get(`${req.method} ${path}`) ?? byKey.get(path);
65
- if (handler)
66
- return await handler(req);
67
- if (known && req.method === "HEAD") {
68
- const get = byKey.get(`GET ${path}`);
69
- if (get)
70
- return await get(req);
71
- }
108
+ const hit = hits.find((h) => h.route.method === req.method) ??
109
+ (req.method === "HEAD" ? hits.find((h) => h.route.method === "GET") : undefined);
110
+ if (hit)
111
+ return await hit.route.handler(req, hit.session ?? "");
72
112
  // 404 vs 405 as in the host router: a client reads 404 as "this serve predates the route".
73
- if (known)
113
+ if (hits.length > 0)
74
114
  return text("method not allowed\n", 405);
75
115
  return text("not found\n", 404);
76
116
  };
@@ -89,34 +129,31 @@ function planeApp(routes) {
89
129
  // THE single exit. Every reply above — route, preflight, 404, 405, 500 — leaves through here.
90
130
  res.headers.set("access-control-allow-origin", "*");
91
131
  res.headers.set("access-control-allow-headers", "authorization, content-type");
92
- res.headers.set("access-control-allow-methods", allowMethods(path, req.headers.get("access-control-request-method")));
132
+ res.headers.set("access-control-allow-methods", allowMethods(hits, req.headers.get("access-control-request-method")));
93
133
  return res;
94
134
  };
95
135
  }
96
136
  // ONE constant for every Prompt-bearing wire surface (imported from the invoke channel — the two
97
- // caps cannot drift apart): commands carry Prompts, which may ride base64 images.
98
- const DISPATCH_BODY_LIMIT = MAX_BODY_BYTES;
137
+ // caps cannot drift apart): actions carry Prompts, which may ride base64 images.
138
+ const ACTION_BODY_LIMIT = MAX_BODY_BYTES;
99
139
  /**
100
140
  * Parse-don't-validate at the wire: a remote client can send any JSON, and the hub's inner layers
101
- * trust command shapes (a malformed `steer` would surface as an ENGINE failure misclassified as
102
- * `run_command_failed`). Returns the typed command, or undefined for anything malformed — which
141
+ * trust action shapes (a malformed `steer` would surface as an ENGINE failure misclassified as
142
+ * `run_command_failed`). Returns the typed action, or undefined for anything malformed — which
103
143
  * answers protocol-level `invalid_command`, same responsibility as the hub's unknown-type default.
104
144
  */
105
- function parseWireCommand(raw) {
106
- // COMPILE-TIME drift guard, variant level: this switch hand-mirrors the SessionCommand union,
107
- // and a new variant added in session.ts would otherwise compile clean while the wire answers it
145
+ function parseWireAction(raw) {
146
+ // COMPILE-TIME drift guard, variant level: this switch hand-mirrors the SessionAction union, and a
147
+ // new variant added in session.ts would otherwise compile clean while the wire answers it
108
148
  // `invalid_command` — silently breaking local/remote isomorphism. A new variant must break THIS
109
149
  // line first, forcing the decision of how the wire carries it.
110
- const _commandDriftGuard = {
150
+ const _actionDriftGuard = {
111
151
  steer: true,
112
152
  follow_up: true,
113
153
  abort: true,
114
154
  compact: true,
115
- set_model: true,
116
- set_thinking: true,
117
- navigate: true,
118
155
  };
119
- void _commandDriftGuard;
156
+ void _actionDriftGuard;
120
157
  if (typeof raw !== "object" || raw === null)
121
158
  return undefined;
122
159
  const c = raw;
@@ -134,8 +171,8 @@ function parseWireCommand(raw) {
134
171
  // failure this parser exists to prevent (ImageRef shape from src/session.ts's Prompt).
135
172
  return images === undefined || (Array.isArray(images) && images.every(imageOk));
136
173
  };
137
- // REBUILD, never pass raw through: "typed command out" must be construction, not assertion — a
138
- // passed-through object would carry arbitrary extra keys into the engine.
174
+ // REBUILD, never pass raw through: "typed out" must be construction, not assertion — a passed-
175
+ // through object would carry arbitrary extra keys into the engine.
139
176
  const rebuildPrompt = (p) => {
140
177
  const images = p.images;
141
178
  return {
@@ -162,159 +199,216 @@ function parseWireCommand(raw) {
162
199
  return c.instructions === undefined || typeof c.instructions === "string"
163
200
  ? { type: "compact", instructions: c.instructions }
164
201
  : undefined;
165
- case "set_model":
166
- return typeof c.model === "string" ? { type: "set_model", model: c.model } : undefined;
167
- case "set_thinking":
168
- return typeof c.level === "string" ? { type: "set_thinking", level: c.level } : undefined;
169
- case "navigate":
170
- return typeof c.targetId === "string" ? { type: "navigate", targetId: c.targetId } : undefined;
171
202
  default:
172
203
  return undefined;
173
204
  }
174
205
  }
175
206
  /**
176
- * Create the control plane as a mountable prefix owner: `GET
177
- * /control/capabilities|commands|state|entries|events` + `POST /control/dispatch`, all
178
- * bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
179
- * The plane OWNS {@link CONTROL_PREFIX}: it is mounted as one sub-application, answers its own
180
- * 404/405/preflight, and puts CORS headers on every reply see {@link planeApp}.
207
+ * Parse a PATCH body into a session update or into the REASON it is not one, because the two
208
+ * reasons are different answers to the client. An unknown KEY means this serve does not have that
209
+ * field (a newer client talking to an older serve, or a typo), and the client's move is to drop it:
210
+ * `unsupported_capability`, the same code the in-process path answers, so the two planes stay
211
+ * isomorphic. A wrong VALUE TYPE is a malformed payload: `invalid_command`. Neither is dropped
212
+ * silently, which would answer `ok: true` for a patch that set nothing.
213
+ */
214
+ function parseWireUpdate(raw) {
215
+ if (typeof raw !== "object" || raw === null) {
216
+ return { code: INVALID_COMMAND_CODE, message: "expected an object of session properties" };
217
+ }
218
+ const c = raw;
219
+ const unknown = Object.keys(c).filter((key) => !UPDATE_FIELDS.includes(key));
220
+ if (unknown.length > 0) {
221
+ return {
222
+ code: UNSUPPORTED_CAPABILITY_CODE,
223
+ message: `update field(s) ${unknown.join(", ")} — capabilities().updatable lists what this serve sets`,
224
+ };
225
+ }
226
+ const patch = {};
227
+ // The field list is the CONTRACT's (`UPDATE_FIELDS`), not a copy: a field added to SessionUpdate
228
+ // travels here without anyone remembering to, and one removed cannot linger.
229
+ for (const field of UPDATE_FIELDS) {
230
+ const value = c[field];
231
+ if (value === undefined)
232
+ continue;
233
+ if (typeof value !== "string") {
234
+ return { code: INVALID_COMMAND_CODE, message: `${field} must be a string` };
235
+ }
236
+ patch[field] = value;
237
+ }
238
+ return { patch };
239
+ }
240
+ /**
241
+ * Create the control plane as a mountable prefix owner — a RESTful surface over
242
+ * {@link CONTROL_PREFIX}: sessions are a collection, a session is a resource, its history and event
243
+ * stream are sub-resources, and the things that HAPPEN to a run are posted to `…/actions`.
244
+ * The plane OWNS the prefix: it answers its own 404/405/preflight and puts CORS headers on every
245
+ * reply — see {@link planeApp}.
181
246
  */
182
247
  export function createControlPlane(control, options) {
183
248
  return mountControlPlane(controlPlaneRoutes(control, options));
184
249
  }
185
250
  /** Mount a plane route table as a {@link PrefixMount} — the plane owns a PREFIX, while a route
186
- * table is a set of literal paths. */
251
+ * table is a set of paths, at most one segment of which is a session id. */
187
252
  export function mountControlPlane(routes) {
188
253
  return { prefix: CONTROL_PREFIX, handler: planeApp(routes) };
189
254
  }
190
- /** The plane's route table. Exported so the conformance sweeps derive their route list from what is
191
- * actually mounted, rather than from a hand-kept copy that cannot notice a new route. */
255
+ /**
256
+ * The plane's route table. Exported so the conformance sweeps derive their route list from what is
257
+ * actually mounted, rather than from a hand-kept copy that cannot notice a new route.
258
+ *
259
+ * The shape mirrors the contract: a collection, a resource, its sub-resources, and one action
260
+ * endpoint. What a session IS gets `GET`; what a session HAS gets `PATCH` (properties, last-wins);
261
+ * what happens TO a run gets `POST …/actions` (not a property — an event in time). `PUT` on a
262
+ * session id is the fork, and it is a PUT because a fork is idempotent by construction: the id is
263
+ * the caller's, the body says where the history came from, and repeating it changes nothing.
264
+ */
192
265
  export function controlPlaneRoutes(control, options) {
193
266
  const { token } = options;
194
267
  if (!token)
195
268
  throw new Error("createControlPlane: a bearer token is required (empty tokens are not a mode)");
196
269
  const epoch = crypto.randomUUID();
197
- // Timing-safe: the bearer token is this surface's ONLY auth (and the --tunnel warning names it
198
- // as the sole protection on a public URL) — a plain === would leak byte-by-byte via timing.
199
- const expected = Buffer.from(`Bearer ${token}`);
200
- const authed = (req) => {
201
- const header = Buffer.from(req.headers.get("authorization") ?? "");
202
- return header.length === expected.length && timingSafeEqual(header, expected);
203
- };
270
+ // The bearer token is this surface's ONLY auth (and the --tunnel warning names it as the sole
271
+ // protection on a public URL).
272
+ const expected = `Bearer ${token}`;
273
+ const authed = (req) => secretEquals(req.headers.get("authorization"), expected);
204
274
  const invokeHandler = options.agent ? createInvokeHandler(options.agent) : undefined;
205
- /** Wrap a handler with auth + the session query param most routes need. */
206
- const guard = (handler) => (req) => {
275
+ /** Authenticate, then hand the handler the pieces every route wants: the request, the URL (for
276
+ * query parameters), and the session the PATH named — `""` on the routes that have no id in
277
+ * them, which those handlers never read. */
278
+ const guard = (handler) => (req, session) => {
207
279
  if (!authed(req))
208
280
  return text("unauthorized\n", 401);
209
- return handler(req, new URL(req.url));
281
+ return handler(req, new URL(req.url), session);
282
+ };
283
+ /** Read a JSON body under the shared cap. Answers the Response to send on failure, so a route can
284
+ * `if ("error" in read) return read.error`. */
285
+ const readJson = async (req) => {
286
+ const body = await readBodyCapped(req, ACTION_BODY_LIMIT);
287
+ // The 413 names the ceiling: the docs promise images on this plane, and an unexplained
288
+ // rejection would send a client author hunting everywhere but the cap. Derived from the
289
+ // constant — a hardcoded "1 MiB" would lie the day the cap changes.
290
+ if ("tooLarge" in body) {
291
+ return {
292
+ error: text(`body too large (limit ${MAX_BODY_BYTES >> 20} MiB — images count base64-inflated)\n`, 413),
293
+ };
294
+ }
295
+ // An empty body is an empty object: `POST …/actions` always carries one, but `PATCH` with
296
+ // nothing to set is a legal no-op and a client should not have to send `{}` to say so.
297
+ if (body.text.trim() === "")
298
+ return { value: {} };
299
+ try {
300
+ return { value: JSON.parse(body.text) };
301
+ }
302
+ catch {
303
+ return { error: text("invalid JSON\n", 400) };
304
+ }
210
305
  };
211
- // Extraction only — each route still answers its own 400 (the name must not imply enforcement).
212
- const sessionParam = (url) => url.searchParams.get("session") ?? undefined;
213
306
  return {
307
+ // The DATA plane, at the prefix rather than under a session: its body already carries the scope
308
+ // (SPEC `invoke(scope, prompt)`), so a session in the path would be a second place to say it —
309
+ // and two places to say one thing is a place for them to disagree.
214
310
  ...(invokeHandler ? { "POST /control/invoke": guard((req) => invokeHandler(req)) } : {}),
215
311
  "GET /control/capabilities": guard(() => json(control.capabilities())),
216
312
  "GET /control/commands": guard(async () => json(await control.commands())),
217
- "GET /control/state": guard(async (_req, url) => {
218
- const session = sessionParam(url);
219
- if (!session)
220
- return text("missing ?session\n", 400);
221
- return json(await control.state(session));
222
- }),
223
- "GET /control/entries": guard(async (_req, url) => {
224
- const session = sessionParam(url);
225
- if (!session)
226
- return text("missing ?session\n", 400);
227
- const since = url.searchParams.get("since") ?? undefined;
228
- return json(await control.entries(session, since !== undefined ? { since } : undefined));
229
- }),
230
- "POST /control/dispatch": guard(async (req) => {
231
- const body = await readBodyCapped(req, DISPATCH_BODY_LIMIT);
232
- // The 413 names the ceiling: the docs promise images on this plane, and an unexplained
233
- // rejection would send a client author hunting everywhere but the cap.
234
- if ("tooLarge" in body) {
235
- // Derived from the constant — a hardcoded "1 MiB" would lie the day the cap changes.
236
- return text(`body too large (limit ${MAX_BODY_BYTES >> 20} MiB — images count base64-inflated)\n`, 413);
237
- }
238
- let parsed;
313
+ // The DEPLOYMENT's conversation list — and the one read that may fail: `[]` is what an empty
314
+ // deployment answers, so a store that cannot be enumerated gets a coded non-2xx instead. 503 +
315
+ // the code, because the alternative (#309's lesson) is a client that can only classify a bare
316
+ // 500 as "the endpoint is unreachable" and burns its reconnect budget on a condition
317
+ // reconnecting cannot fix.
318
+ "GET /control/sessions": guard(async () => {
239
319
  try {
240
- parsed = JSON.parse(body.text);
320
+ return json(await control.sessions.list());
241
321
  }
242
- catch {
243
- return text("invalid JSON\n", 400);
322
+ catch (error) {
323
+ // ONLY a store fault becomes the retryable code. A TypeError from our own row building is a
324
+ // bug, and answering `retryable: true` for it has a client poll forever on something no
325
+ // retry can fix — so it goes back to the plane's totality boundary, which logs it and
326
+ // answers 500. An IO error carries an ERRNO `code` (EACCES, ENOTDIR); that is the shape of a
327
+ // condition the operator can act on.
328
+ //
329
+ // The errno SHAPE, not merely "has a string code": Node's own argument-validation errors are
330
+ // TypeErrors carrying `ERR_INVALID_ARG_TYPE`/`ERR_OUT_OF_RANGE`, so a bug in row building
331
+ // would otherwise be handed to a client as retryable — the exact case this excludes. Read
332
+ // through `?.` because a thrown null must reach the boundary as itself, not as a TypeError
333
+ // from this line that replaces it in the log.
334
+ const code = error?.code;
335
+ if (typeof code !== "string" || !/^E[A-Z]+$/.test(code))
336
+ throw error;
337
+ // Logged as well as answered: the catch would otherwise be the one place a store fault is
338
+ // invisible on the server, since it preempts the boundary that does the logging.
339
+ log.error(`[control] GET /control/sessions failed: ${String(error)}`);
340
+ return json({ code: SESSIONS_UNAVAILABLE_CODE, message: String(error), retryable: true }, 503);
244
341
  }
245
- if (typeof parsed.session !== "string") {
246
- return text("expected { session: string, command: SessionCommand }\n", 400);
342
+ }),
343
+ // PUT, because a fork is idempotent: this id, holding the history that was at `from`@`at`.
344
+ // Repeating it answers ok and writes nothing; naming an id that holds a different history is a
345
+ // conflict, not an overwrite.
346
+ [`PUT /control/sessions/${SESSION_SEGMENT}`]: guard(async (req, _url, session) => {
347
+ const read = await readJson(req);
348
+ if ("error" in read)
349
+ return read.error;
350
+ // `JSON.parse("null")` is null, and a body is whatever the client sent: reaching into it
351
+ // unguarded turns a malformed request into a 500 the client cannot act on.
352
+ const body = read.value;
353
+ if (typeof body?.from !== "string" || typeof body.at !== "string") {
354
+ return text("expected { from: string, at: string }\n", 400);
355
+ }
356
+ return json(await control.sessions.fork({ from: body.from, at: body.at, into: session }));
357
+ }),
358
+ [`GET /control/sessions/${SESSION_SEGMENT}`]: guard(async (_req, _url, session) => json(await control.sessions.get(session).state())),
359
+ // PATCH, because these are session PROPERTIES: last-wins, durable, applied by the next turn.
360
+ [`PATCH /control/sessions/${SESSION_SEGMENT}`]: guard(async (req, _url, session) => {
361
+ const read = await readJson(req);
362
+ if ("error" in read)
363
+ return read.error;
364
+ const parsed = parseWireUpdate(read.value);
365
+ // A protocol-level answer carrying the SAME code the hub would have used — the wire must not
366
+ // be where a client loses the difference between "drop that field" and "fix that value".
367
+ if (!("patch" in parsed)) {
368
+ return json({ ok: false, error: { ...parsed, retryable: false } });
247
369
  }
248
- const command = parseWireCommand(parsed.command);
249
- if (!command) {
250
- // Malformed shape = a protocol-level answer, mirrored from the hub's unknown-type default.
370
+ return json(await control.sessions.get(session).update(parsed.patch));
371
+ }),
372
+ [`DELETE /control/sessions/${SESSION_SEGMENT}`]: guard(async (_req, _url, session) => json(await control.sessions.get(session).delete())),
373
+ [`GET /control/sessions/${SESSION_SEGMENT}/entries`]: guard(async (_req, url, session) => {
374
+ const since = url.searchParams.get("since") ?? undefined;
375
+ return json(await control.sessions.get(session).entries(since !== undefined ? { since } : undefined));
376
+ }),
377
+ // The run actions. Not PATCH: none of them SETS anything — they join, queue, stop, or summarize,
378
+ // and the outcome arrives on the event stream rather than in the resource's next read.
379
+ [`POST /control/sessions/${SESSION_SEGMENT}/actions`]: guard(async (req, _url, session) => {
380
+ const read = await readJson(req);
381
+ if ("error" in read)
382
+ return read.error;
383
+ const action = parseWireAction(read.value);
384
+ if (!action) {
251
385
  return json({
252
386
  ok: false,
253
- error: { code: INVALID_COMMAND_CODE, message: "malformed command", retryable: false },
387
+ error: { code: INVALID_COMMAND_CODE, message: "malformed action", retryable: false },
254
388
  });
255
389
  }
390
+ const s = control.sessions.get(session);
256
391
  // The result rides HTTP 200 either way: `ok: false` is a protocol-level answer (rejected
257
392
  // before acceptance), not a transport failure.
258
- return json(await control.dispatch(parsed.session, command));
393
+ switch (action.type) {
394
+ case "steer":
395
+ return json(await s.steer(action.prompt));
396
+ case "follow_up":
397
+ return json(await s.followUp(action.prompt));
398
+ case "abort":
399
+ return json(await s.abort());
400
+ case "compact":
401
+ return json(await s.compact(action.instructions !== undefined ? { instructions: action.instructions } : {}));
402
+ }
259
403
  }),
260
- "GET /control/events": guard((_req, url) => {
261
- const session = sessionParam(url);
262
- if (!session)
263
- return text("missing ?session\n", 400);
264
- const iterator = control.events(session)[Symbol.asyncIterator]();
265
- // EAGER registration: issue the first pull NOW, before the Response (and thus the client's
266
- // fetch resolution) exists — hub subscription is registered synchronously inside next(), so
267
- // "the client saw response headers" implies "events from that moment on will be delivered".
268
- // Shrinks the subscribe/backfill race to network reordering instead of a full pull cycle.
269
- let pending = iterator.next();
270
- // Observed here so a client that disconnects BEFORE the first pull cannot turn a rejecting
271
- // events iterator (this is the neutral contract face — any implementation may reject) into a
272
- // process-killing unhandledRejection; awaiting `pending` at pull still surfaces the error.
273
- pending.catch(() => { });
404
+ [`GET /control/sessions/${SESSION_SEGMENT}/events`]: guard((_req, _url, session) => {
274
405
  let seq = 0;
275
- let stopHeartbeat = () => { };
276
- const encoder = new TextEncoder();
277
- const stream = new ReadableStream({
278
- start(controller) {
279
- stopHeartbeat = sseHeartbeat(controller);
280
- },
281
- async pull(controller) {
282
- let next;
283
- try {
284
- next = await (pending ?? iterator.next());
285
- }
286
- catch (error) {
287
- // A rejecting implementation (the neutral contract permits it) must not leak its
288
- // subscription: an errored stream never gets cancel(), so the unsubscribe and the
289
- // heartbeat teardown happen HERE.
290
- stopHeartbeat();
291
- void iterator.return?.(undefined)?.catch?.(() => { });
292
- controller.error(error);
293
- return;
294
- }
295
- pending = undefined;
296
- if (next.done) {
297
- stopHeartbeat();
298
- controller.close();
299
- return;
300
- }
301
- const wire = { sessionId: session, epoch, seq: seq++, event: next.value };
302
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(wire)}\n\n`));
303
- },
304
- cancel() {
305
- stopHeartbeat();
306
- // Same neutral-contract defense as the pull error path: a rejecting return() on client
307
- // disconnect must not become a process-level unhandledRejection.
308
- void iterator.return?.(undefined)?.catch?.(() => { });
309
- },
310
- });
311
- return new Response(stream, {
312
- headers: {
313
- "content-type": "text/event-stream",
314
- "cache-control": "no-cache",
315
- connection: "keep-alive",
316
- },
317
- });
406
+ return sseResponse(control.sessions.get(session).events(), (event) => ({
407
+ sessionId: session,
408
+ epoch,
409
+ seq: seq++,
410
+ event,
411
+ }));
318
412
  }),
319
413
  };
320
414
  }
@@ -10,15 +10,37 @@ export interface LoadedLongConnectionChannel {
10
10
  name: string;
11
11
  connect(signal: AbortSignal): LongConnection;
12
12
  }
13
+ /**
14
+ * HOW A CHANNEL IS REACHED — the authored structural fact, and the ONE shape it travels in.
15
+ *
16
+ * A webhook channel is reached at a URL someone must set; a long-connection channel dials out, so
17
+ * there is no URL and setting one breaks it (Telegram answers `getUpdates` with 409 once a webhook
18
+ * exists). Everything downstream — which secrets to carry, what the runbook says, what `--run` and
19
+ * `--tunnel` register — is a question about THIS.
20
+ *
21
+ * It is one list because it used to be three (`channels` + `routeChannels` + `longConnectionChannels`,
22
+ * two of them including custom channels and one not), and every consumer re-derived the answer from
23
+ * whichever pair it happened to hold. Two deploys shipped a webhook for a long-connection channel
24
+ * that way. A list of pairs cannot be recombined wrongly, and a consumer that needs a subset asks for
25
+ * it here rather than trusting its caller to have filtered.
26
+ */
27
+ export type ChannelIngress = "webhook" | "long-connection";
28
+ /** One channel a directory declares, with the ingress its module shape says it has. `name` is the
29
+ * basename, which is a {@link ChannelKind} for the first-party ones and anything for a custom one. */
30
+ export interface DeclaredChannel {
31
+ name: string;
32
+ ingress: ChannelIngress;
33
+ }
34
+ /** Declared channels from basenames that share one ingress: the serving surface's mounted route list
35
+ * (a long-connection channel mounts no HTTP route, so every route IS a webhook channel), and fixtures. */
36
+ export declare function declaredChannels(names: readonly string[], ingress?: ChannelIngress): DeclaredChannel[];
13
37
  /**
14
38
  * Import channel files without mounting route factories or opening connections. Deployment needs only
15
- * the authored structural fact: function exports are route channels; `{ connect() }` exports are
39
+ * the authored structural fact: function exports are webhook channels; `{ connect() }` exports are
16
40
  * long-connection channels. There is no second ingress/lifecycle declaration to keep in sync.
17
41
  */
18
42
  export declare function inspectChannels(dir: string): Promise<{
19
- channels: string[];
20
- routeChannels: string[];
21
- longConnectionChannels: string[];
43
+ channels: DeclaredChannel[];
22
44
  failures: ModuleLoadFailure[];
23
45
  }>;
24
46
  /**
@@ -31,7 +53,6 @@ export declare function loadChannels(dir: string, ctx: ChannelContext): Promise<
31
53
  routes: Routes;
32
54
  longConnections: LoadedLongConnectionChannel[];
33
55
  routeChannels: string[];
34
- longConnectionChannels: string[];
35
56
  collisions: ChannelCollision[];
36
57
  failures: ModuleLoadFailure[];
37
58
  }>;