@fastagent-sh/fastagent 0.18.0 → 0.19.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 (159) 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 +19 -0
  5. package/dist/atomic-write.js +48 -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-service.d.ts +54 -0
  9. package/dist/channels/agentcore-service.js +111 -0
  10. package/dist/channels/agentcore.d.ts +12 -2
  11. package/dist/channels/agentcore.js +6 -4
  12. package/dist/channels/busy.d.ts +1 -1
  13. package/dist/channels/busy.js +1 -1
  14. package/dist/channels/control.d.ts +16 -6
  15. package/dist/channels/control.js +106 -4
  16. package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
  17. package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
  18. package/dist/channels/feishu/card.js +1 -1
  19. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  20. package/dist/channels/feishu/context-buffer.js +4 -4
  21. package/dist/channels/feishu/feishu-api.js +1 -1
  22. package/dist/channels/feishu/feishu.d.ts +1 -1
  23. package/dist/channels/feishu/feishu.js +7 -7
  24. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  25. package/dist/channels/feishu/invoke-turn.js +3 -4
  26. package/dist/channels/feishu/preview.d.ts +1 -1
  27. package/dist/channels/feishu/preview.js +7 -3
  28. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  29. package/dist/channels/github/github.d.ts +1 -1
  30. package/dist/channels/http.d.ts +11 -7
  31. package/dist/channels/http.js +1 -109
  32. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  33. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  34. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  35. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  36. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  37. package/dist/channels/{state.js → kit/state.js} +4 -7
  38. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  39. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  40. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  41. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  42. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  43. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  44. package/dist/channels/lark/lark.d.ts +1 -1
  45. package/dist/channels/serve.d.ts +73 -0
  46. package/dist/channels/serve.js +243 -0
  47. package/dist/channels/slack/bot-auth.js +3 -14
  48. package/dist/channels/slack/context-buffer.d.ts +2 -2
  49. package/dist/channels/slack/context-buffer.js +2 -2
  50. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  51. package/dist/channels/slack/invoke-turn.js +8 -5
  52. package/dist/channels/slack/parse.js +2 -2
  53. package/dist/channels/slack/preview.d.ts +1 -1
  54. package/dist/channels/slack/preview.js +11 -3
  55. package/dist/channels/slack/slack-api.js +1 -1
  56. package/dist/channels/slack/slack.d.ts +1 -1
  57. package/dist/channels/slack/slack.js +8 -8
  58. package/dist/channels/slack/welcomed.js +1 -1
  59. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  60. package/dist/channels/telegram/context-buffer.js +2 -2
  61. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  62. package/dist/channels/telegram/invoke-turn.js +1 -1
  63. package/dist/channels/telegram/parse.js +2 -2
  64. package/dist/channels/telegram/preview.d.ts +1 -1
  65. package/dist/channels/telegram/preview.js +6 -2
  66. package/dist/channels/telegram/telegram.d.ts +1 -1
  67. package/dist/channels/telegram/telegram.js +3 -3
  68. package/dist/channels/telegram/turn-store.d.ts +2 -2
  69. package/dist/channels/telegram/turn-store.js +2 -2
  70. package/dist/cli/commands/add.js +1 -1
  71. package/dist/cli/commands/dev.js +25 -13
  72. package/dist/cli/commands/fire.js +1 -1
  73. package/dist/cli/commands/info.js +7 -4
  74. package/dist/cli/commands/schedule.js +1 -1
  75. package/dist/cli/commands/start.js +63 -94
  76. package/dist/cli/commands/tool.js +5 -7
  77. package/dist/cli/program.js +1 -1
  78. package/dist/cli/serve.d.ts +18 -57
  79. package/dist/cli/serve.js +57 -243
  80. package/dist/cli/shared.d.ts +1 -1
  81. package/dist/cli/shared.js +2 -2
  82. package/dist/core.d.ts +3 -5
  83. package/dist/core.js +7 -5
  84. package/dist/deploy/preflight.js +1 -1
  85. package/dist/dev-supervisor.js +9 -3
  86. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  87. package/dist/engines/pi/agent-session-factory.js +314 -0
  88. package/dist/engines/pi/config.d.ts +4 -4
  89. package/dist/engines/pi/config.js +1 -1
  90. package/dist/engines/pi/create.d.ts +56 -43
  91. package/dist/engines/pi/create.js +141 -71
  92. package/dist/engines/pi/definition.d.ts +41 -7
  93. package/dist/engines/pi/definition.js +115 -11
  94. package/dist/engines/pi/invoke-session.d.ts +22 -19
  95. package/dist/engines/pi/invoke-session.js +286 -100
  96. package/dist/engines/pi/login.js +7 -4
  97. package/dist/engines/pi/models.d.ts +14 -0
  98. package/dist/engines/pi/models.js +9 -1
  99. package/dist/engines/pi/open.d.ts +9 -6
  100. package/dist/engines/pi/open.js +20 -10
  101. package/dist/engines/pi/report.d.ts +0 -4
  102. package/dist/engines/pi/report.js +0 -7
  103. package/dist/engines/pi/service.d.ts +23 -0
  104. package/dist/engines/pi/service.js +19 -0
  105. package/dist/engines/pi/session-builder.js +84 -88
  106. package/dist/engines/pi/session-control.d.ts +10 -9
  107. package/dist/engines/pi/session-control.js +114 -77
  108. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  109. package/dist/engines/pi/session-inheritance.js +242 -0
  110. package/dist/engines/pi/session-settings.d.ts +14 -2
  111. package/dist/engines/pi/session-settings.js +18 -0
  112. package/dist/engines/pi/session-store.d.ts +70 -0
  113. package/dist/engines/pi/session-store.js +313 -0
  114. package/dist/engines/pi/tool-context.d.ts +18 -8
  115. package/dist/engines/pi/tool-context.js +23 -1
  116. package/dist/engines/pi/tool.d.ts +5 -5
  117. package/dist/engines/pi/tool.js +1 -1
  118. package/dist/engines/pi/turn-kit.d.ts +32 -6
  119. package/dist/engines/pi/turn-kit.js +28 -1
  120. package/dist/engines/pi/wake-tool.js +1 -1
  121. package/dist/index.d.ts +1 -0
  122. package/dist/index.js +1 -0
  123. package/dist/log.d.ts +3 -0
  124. package/dist/log.js +5 -1
  125. package/dist/node.d.ts +16 -0
  126. package/dist/node.js +20 -0
  127. package/dist/paths.d.ts +7 -0
  128. package/dist/paths.js +10 -0
  129. package/dist/pi.d.ts +14 -8
  130. package/dist/pi.js +6 -5
  131. package/dist/scaffold/templates/persona.md +2 -2
  132. package/dist/schedule/state.js +4 -9
  133. package/dist/schedule/wakeups.d.ts +1 -1
  134. package/dist/schedule/wakeups.js +1 -1
  135. package/dist/service.d.ts +158 -0
  136. package/dist/service.js +354 -0
  137. package/dist/session-remote.d.ts +1 -1
  138. package/dist/session.d.ts +4 -2
  139. package/dist/session.js +1 -1
  140. package/package.json +14 -5
  141. package/dist/engines/pi/harness.d.ts +0 -115
  142. package/dist/engines/pi/harness.js +0 -154
  143. package/dist/engines/pi/invoke.d.ts +0 -60
  144. package/dist/engines/pi/invoke.js +0 -459
  145. package/dist/engines/pi/read-image.d.ts +0 -4
  146. package/dist/engines/pi/read-image.js +0 -62
  147. package/dist/engines/pi/sessions.d.ts +0 -60
  148. package/dist/engines/pi/sessions.js +0 -464
  149. package/dist/host/node.js +0 -52
  150. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  151. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  152. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  153. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  154. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  155. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  156. /package/dist/channels/{text.js → kit/text.js} +0 -0
  157. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  158. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  159. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
package/README.md CHANGED
@@ -157,21 +157,39 @@ const agent = createPiAgent({
157
157
 
158
158
  The root export intentionally contains the supported surface only.
159
159
 
160
+ Engine-neutral and runtime-neutral are different properties, and the entries are layered by them —
161
+ each layer drops one, and every layer's dependency list is asserted in CI:
162
+
163
+ | | engine-neutral | runtime-neutral | costs |
164
+ |---|---|---|---|
165
+ | `/core`, `/session` | yes | yes | nothing |
166
+ | `/node` | yes | no (filesystem, clock, environment) | `@hono/node-server`, `croner` |
167
+ | `/pi` | no | no | the pi runtime |
168
+
169
+ `Provider` and `ProviderAuth` are exported as types because our options name them; the factory that
170
+ builds one, `createProvider`, comes from `@earendil-works/pi-ai` — add it as a direct dependency when
171
+ you register a custom provider.
172
+
160
173
  | Area | Examples | Stability |
161
174
  |---|---|---|
162
175
  | Contract | `Agent`, `AgentEvent`, `collect` | Stable within SPEC v0.1 |
163
- | Channels/host | `createInvokeHandler`, `nodeListener`, `serveNode`, `router`, `Routes` | Reference implementation, pre-1.0 |
176
+ | Directory → service | `createAgentService` (pi opener), `mountAgentService` + `MountableAgent` (neutral assembly), `AgentService` | The supported way to mount an agent directory in an app |
177
+ | Mounting | `createInvokeHandler`, `Routes`, `ChannelHandler` | Reference implementation, pre-1.0 |
178
+ | Node binding | `nodeListener`, `serveNode` (from `/node`) | The one runtime-specific piece; see below |
164
179
  | pi assembly | `createPiAgentFromDir`, `createPiAgentFromDefinition`, `createPiAgent` | Usable now, may tighten before 1.0 |
165
- | Tool/channel authoring | `defineTool`, `z`, `loadTools`, `loadChannels`, `ChannelModule` | Usable now, may tighten before 1.0 |
166
- | Injection ports | `PiSessionStore`, `inMemorySessionStore`, `jsonlSessionStore`, `Lease`, `Provider`, `createProvider` | Public because options reference them |
167
- | Not exported | L0 harness adapter, pi harness factory, prompt/config internals | Internal modules; no compatibility promise |
180
+ | Tool/channel authoring | `defineTool`, `z`, `defineSchedule`, `ChannelModule` | Usable now, may tighten before 1.0 |
181
+ | Injection ports | `PiSessionRecordStore`, `piSessionRecordStore`, `piInMemorySessionRecordStore`, `Lease`, `Provider` | Public because options reference them |
182
+ | Not exported | The assembly's parts `router`, `createControlPlane`, `loadTools`/`loadChannels`/`loadSchedules`, `createScheduler` — and prompt/config internals | `createAgentService` does this; no compatibility promise |
168
183
 
169
- Subpath exports:
184
+ Subpath entry points (`./package.json` is also exported, for tools that read the version):
170
185
 
171
- - `@fastagent-sh/fastagent/core` — engine-neutral contract, consumption helpers, channel/host kit, schedules;
186
+ - `@fastagent-sh/fastagent/core` — engine-neutral contract, consumption helpers, channel kit, schedules, and the session-control clients (`connectSessionControl`, `connectAgent`). **Zero third-party dependencies**, enforced by test;
187
+ - `@fastagent-sh/fastagent/node` — the engine-neutral pieces that need a Node runtime: `mountAgentService` (the assembly), `serveNode` / `nodeListener` (the `node:http` ↔ Fetch binding);
188
+ - `@fastagent-sh/fastagent/session` — the engine-neutral session-control contract (types and error codes);
172
189
  - `@fastagent-sh/fastagent/pi` — the pi reference implementation;
173
190
  - `@fastagent-sh/fastagent/github` — GitHub webhook channel;
174
191
  - `@fastagent-sh/fastagent/telegram` — Telegram bot channel;
192
+ - `@fastagent-sh/fastagent/slack` — Slack Events API bot channel;
175
193
  - `@fastagent-sh/fastagent/feishu` — canonical Feishu bot channel (飞书, open.feishu.cn);
176
194
  - `@fastagent-sh/fastagent/lark` — Lark-international compatibility profile over the Feishu engine.
177
195
 
@@ -196,7 +214,7 @@ FastAgent is pre-1.0. The stable design center is the Agent Handler contract in
196
214
  The neutral contract leaves room for capabilities that are not complete product features yet:
197
215
 
198
216
  - **Durable execution**: Telegram, Slack, and Feishu/Lark accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
199
- - **Sandboxed execution** — `ExecutionEnv` governs the default coding tools, but ② project context and author-written `tools/` still reach the local process; a complete sandbox adapter is future work.
217
+ - **Sandboxed execution** — all seven coding tools, ② project context, channels, and author-written `tools/` reach the local process directly; a sandbox around the complete agent process is future work.
200
218
  - **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
201
219
  - **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.
202
220
  - **More deploy targets** — local Docker, Fly, Railway, and AWS Bedrock AgentCore ship today; the generated container is the portable path for other hosts.
package/dist/agent.d.ts CHANGED
@@ -86,7 +86,7 @@ export type AgentEvent = {
86
86
  export declare const SESSION_BUSY_CODE = "session_busy";
87
87
  /**
88
88
  * The `failed.code` set when a run was DELIBERATELY stopped — a control-plane abort, or any
89
- * harness-level abort the engine attributes (`stopReason: "aborted"`) — rather than failing on its
89
+ * engine-level abort it attributes (`stopReason: "aborted"`) — rather than failing on its
90
90
  * own. Channels can render cancellation distinctly from an error, and MUST treat it as a settled
91
91
  * outcome — durable turn-intent cleanup included — so a deliberate stop is never replayed as a
92
92
  * fresh turn on restart. Exported as a constant for the same reason as {@link SESSION_BUSY_CODE}:
package/dist/agent.js CHANGED
@@ -14,7 +14,7 @@
14
14
  export const SESSION_BUSY_CODE = "session_busy";
15
15
  /**
16
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
17
+ * engine-level abort it attributes (`stopReason: "aborted"`) — rather than failing on its
18
18
  * own. Channels can render cancellation distinctly from an error, and MUST treat it as a settled
19
19
  * outcome — durable turn-intent cleanup included — so a deliberate stop is never replayed as a
20
20
  * fresh turn on restart. Exported as a constant for the same reason as {@link SESSION_BUSY_CODE}:
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Write a file so a reader sees the whole thing or nothing: same-directory temp, then rename.
3
+ *
4
+ * The temp name is fixed (`<path>.tmp`). That is safe here because no two processes write one state
5
+ * root: a deployment runs one container, and `dev`'s supervisor respawns its worker only after the
6
+ * old one has EXITED (dev-supervisor.ts) — and these writes are synchronous, so an exited process
7
+ * has none in flight. The fixed name is also the seam several channel tests use to inject a write
8
+ * failure, by occupying that path with a directory. A unique name would trade that seam for a race
9
+ * this codebase does not have; revisit it if a second writer ever becomes real.
10
+ *
11
+ * `mode` is applied to the temp first, so the content is never briefly world-readable.
12
+ *
13
+ * The `chmod` is NOT redundant with the `mode` option: `writeFileSync` applies `mode` only when it
14
+ * CREATES the file, so a temp left behind by a crashed writer keeps its old, possibly loose
15
+ * permissions and the rename publishes them (verified: 0644 survives a `{ mode: 0o600 }` write).
16
+ * It runs on the temp, before the rename — the final path is then never observable with the wrong
17
+ * permissions, which a chmod after the rename cannot promise.
18
+ */
19
+ export declare function writeFileAtomic(path: string, data: string, mode?: number): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * One spelling of "a reader sees the whole file or none of it" for SYNCHRONOUS writes, after four
3
+ * copies of it drifted apart: two identical, two with different temp names and different permission
4
+ * handling.
5
+ *
6
+ * Slack's onboarding state stays on its own async path — this is deliberately not an async API, and
7
+ * converting that caller is a separate question from de-duplicating these four.
8
+ */
9
+ import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ /**
12
+ * Write a file so a reader sees the whole thing or nothing: same-directory temp, then rename.
13
+ *
14
+ * The temp name is fixed (`<path>.tmp`). That is safe here because no two processes write one state
15
+ * root: a deployment runs one container, and `dev`'s supervisor respawns its worker only after the
16
+ * old one has EXITED (dev-supervisor.ts) — and these writes are synchronous, so an exited process
17
+ * has none in flight. The fixed name is also the seam several channel tests use to inject a write
18
+ * failure, by occupying that path with a directory. A unique name would trade that seam for a race
19
+ * this codebase does not have; revisit it if a second writer ever becomes real.
20
+ *
21
+ * `mode` is applied to the temp first, so the content is never briefly world-readable.
22
+ *
23
+ * The `chmod` is NOT redundant with the `mode` option: `writeFileSync` applies `mode` only when it
24
+ * CREATES the file, so a temp left behind by a crashed writer keeps its old, possibly loose
25
+ * permissions and the rename publishes them (verified: 0644 survives a `{ mode: 0o600 }` write).
26
+ * It runs on the temp, before the rename — the final path is then never observable with the wrong
27
+ * permissions, which a chmod after the rename cannot promise.
28
+ */
29
+ export function writeFileAtomic(path, data, mode) {
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ const tmp = `${path}.tmp`;
32
+ try {
33
+ writeFileSync(tmp, data, mode === undefined ? undefined : { mode });
34
+ if (mode !== undefined)
35
+ chmodSync(tmp, mode);
36
+ renameSync(tmp, path);
37
+ }
38
+ catch (error) {
39
+ try {
40
+ rmSync(tmp, { force: true });
41
+ }
42
+ catch {
43
+ // `force` only forgives ENOENT: if the temp is a directory, rmSync throws its own error and
44
+ // would replace the write failure that actually explains what went wrong.
45
+ }
46
+ throw error;
47
+ }
48
+ }
@@ -1,8 +1,28 @@
1
- import type { Agent } from "../agent.ts";
2
- import type { SessionControl } from "../session.ts";
3
- /** A mounted request handler (a channel's fetch, or a plain route like health). */
1
+ /**
2
+ * The Channel contract the trigger side of the product boundary (core.md §1), beside `agent.ts`
3
+ * (what an engine implements) and `session.ts` (the serving control plane). Pure types, no runtime
4
+ * dependency; importing a host, a framework, or an engine here is forbidden, as in those two.
5
+ *
6
+ * §7 fixes two module forms: a function is a route channel, an object with `connect` is a long
7
+ * connection. An agent directory ships hand-written `channels/*.ts` against them, which is why they
8
+ * live away from the code that serves them — a WebSocket ingress needs `LongConnection` and has no
9
+ * HTTP in it, and a `ChannelModule` import must not drag `node:http` in behind it.
10
+ *
11
+ * How a route table becomes a running server: `channels/serve.ts`.
12
+ */
13
+ import type { Agent } from "./agent.ts";
14
+ import type { SessionControl } from "./session.ts";
15
+ /** A mounted request handler (a channel's fetch, or a plain route like health). Fetch-shaped by
16
+ * contract (SPEC §11): the one signature every runtime and embedding app already speaks. */
4
17
  export type ChannelHandler = (req: Request) => Response | Promise<Response>;
5
- /** This deployment's HTTP surface: route key → handler. Key is `"/path"` or `"METHOD /path"`. */
18
+ /**
19
+ * This deployment's HTTP surface: route key → handler.
20
+ *
21
+ * A key is `"/path"` (any method) or `"METHOD /path"`, with a LITERAL path — small on purpose, so
22
+ * that "would these two fight over a request?" is string equality rather than a prediction about a
23
+ * matcher, and no channel silently shadows another. `assertRouteKey` in `channels/serve.ts`
24
+ * enforces it; a handler owning a prefix is a `PrefixMount`, never a key.
25
+ */
6
26
  export type Routes = Record<string, ChannelHandler>;
7
27
  /**
8
28
  * What the framework hands a channel at mount time: the assembled agent plus the resolved state ROOT
@@ -34,27 +54,3 @@ export interface LongConnectionChannelModule {
34
54
  name: string;
35
55
  connect(ctx: ChannelContext, signal: AbortSignal): LongConnection;
36
56
  }
37
- /** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method). */
38
- export declare function parseRouteKey(key: string): {
39
- method?: string;
40
- path: string;
41
- };
42
- /**
43
- * Compose a {@link Routes} table into one handler: exact pathname match (optionally method-qualified),
44
- * 405 when the path exists under another method, 404 otherwise. No params/wildcards.
45
- */
46
- export declare function router(routes: Routes): ChannelHandler;
47
- /**
48
- * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
49
- * accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
50
- * `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
51
- * `host` is the bind address; unset means all interfaces (what containers need).
52
- */
53
- export declare function serveNode(handler: ChannelHandler, options: {
54
- port: number;
55
- host?: string;
56
- }): {
57
- listening: Promise<number>;
58
- close: () => Promise<void>;
59
- closeAllConnections: () => void;
60
- };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The AgentCore serving assembly — the same product as `mountAgentService`, built differently
3
+ * because the host is.
4
+ *
5
+ * Two facts drive every difference. There is no public URL (the adapter's `POST /invocations` is the
6
+ * only ingress, and cron slots arrive through it from an external clock, so no resident timers), and
7
+ * **the state mount at boot is PRE-RESTORE** — empty after every version update. Discovering channels
8
+ * eagerly would therefore cache that emptiness (thread participation, delivery dedup, pending turns)
9
+ * and then clobber the restored files with it, so channels are constructed lazily on the first
10
+ * envelope. Everywhere else the state root is durable at boot and a broken channel fails startup.
11
+ *
12
+ * That is a different assembly, not a flag on the shared one: handler shape, discovery timing, clock
13
+ * source, long-connection support and shutdown all differ. What it is NOT is a different product —
14
+ * it returns the same {@link AgentService}, so `start` picks an assembly once and everything after
15
+ * that point is common.
16
+ */
17
+ import type { Agent } from "../agent.ts";
18
+ import type { Routes } from "../channel.ts";
19
+ import type { LoadedSchedule } from "../schedule/schedule.ts";
20
+ import { type AgentService, type MountableAgent } from "../service.ts";
21
+ import { type RouteSurface } from "./agentcore.ts";
22
+ export interface MountAgentcoreServiceOptions {
23
+ /** Wrap the opened agent before anything binds to it (the CLI's turn trace). */
24
+ wrapAgent?: (agent: Agent) => Agent;
25
+ /** Runs once the state snapshot is restored. The wake-alarm reconcile passes through here because
26
+ * its sink is a PROCESS-global: the process entry owns that, not a service that can be closed. */
27
+ onStateReady?: () => void;
28
+ control?: {
29
+ tunnel?: boolean;
30
+ host?: string;
31
+ };
32
+ }
33
+ /** Is this process running inside the AgentCore Runtime? Set by the generated deploy artifacts. */
34
+ export declare function isAgentcoreRuntime(): boolean;
35
+ export declare function mountAgentcoreService(opened: MountableAgent, options?: MountAgentcoreServiceOptions): Promise<AgentService>;
36
+ /**
37
+ * Mount the AgentCore Runtime adapter (`POST /invocations` + `GET /ping`) over the serving routes —
38
+ * the deployed container's ONLY reachable surface (channels/agentcore.ts). Wired by `start` when
39
+ * `FASTAGENT_AGENTCORE=1` (set by the generated deploy artifacts, never by hand). A channel colliding
40
+ * on either path fails startup, same disposition as the control-plane mount: the adapter's paths are
41
+ * the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
42
+ */
43
+ export declare function mountAgentcore(routes: Routes, options: {
44
+ agent: Agent;
45
+ stateRoot: string;
46
+ schedules: readonly LoadedSchedule[];
47
+ onStateReady?: () => void;
48
+ /** Cancels the adapter's process-global registrations on close. */
49
+ signal?: AbortSignal;
50
+ /** The serving path's LAZY channel surface: constructed by the adapter on the first envelope
51
+ * AFTER the state-snapshot restore, never at boot (channels/agentcore.ts). When absent,
52
+ * `routes` is the dispatch target — for wirings whose state root is already authoritative. */
53
+ lazyChannels?: () => Promise<RouteSurface>;
54
+ }): Routes;
@@ -0,0 +1,111 @@
1
+ import { log } from "../log.js";
2
+ import { fireScheduleOnce } from "../schedule/scheduler.js";
3
+ import { assertNoControlPlaneCollision, mountSessionControl, routesFor, startSchedules, } from "../service.js";
4
+ import { UnknownScheduleError, agentcoreRoutes } from "./agentcore.js";
5
+ import { createStateSync } from "./agentcore-state.js";
6
+ import { activeWork } from "./busy.js";
7
+ import { routeKeysConflict, router } from "./serve.js";
8
+ /** Is this process running inside the AgentCore Runtime? Set by the generated deploy artifacts. */
9
+ export function isAgentcoreRuntime() {
10
+ return process.env.FASTAGENT_AGENTCORE === "1";
11
+ }
12
+ export async function mountAgentcoreService(opened, options = {}) {
13
+ const { agentDir, workspace, stateRoot, sessionControl } = opened;
14
+ const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
15
+ // The control plane mounts over an EMPTY route surface: the lazy channels join it later, and the
16
+ // collision rule runs again then (below) against what they actually brought.
17
+ const withControl = mountSessionControl({}, sessionControl, stateRoot, { ...options.control, agent });
18
+ const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule, {
19
+ externalClock: true,
20
+ });
21
+ const lazyChannels = async () => {
22
+ const lazy = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: false });
23
+ if (lazy.longConnections.length > 0) {
24
+ throw new Error(`long-connection channel(s) ${lazy.longConnections.map((c) => c.name).join(", ")} cannot serve on ` +
25
+ `AgentCore (scale-to-zero severs resident connections) — use the channel's webhook form`);
26
+ }
27
+ // The SAME rule mountSessionControl applies, through the same function: its check ran against an
28
+ // empty base at boot, so it has to run again once the channels are real.
29
+ for (const plane of withControl.mounts)
30
+ assertNoControlPlaneCollision(lazy.routes, plane);
31
+ return { routes: lazy.routes, mounts: withControl.mounts };
32
+ };
33
+ // The adapter registers process-global listeners; this is what takes them down on close.
34
+ const closed = new AbortController();
35
+ const adapterRoutes = mountAgentcore({}, {
36
+ signal: closed.signal,
37
+ agent,
38
+ stateRoot,
39
+ schedules: scheduled.schedules,
40
+ onStateReady: options.onStateReady,
41
+ lazyChannels,
42
+ });
43
+ const handler = router(adapterRoutes, withControl.mounts);
44
+ log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
45
+ let unannounce;
46
+ return {
47
+ handler,
48
+ agent,
49
+ // The adapter IS the surface here; the channel routes arrive lazily BEHIND it. Reporting `{}`
50
+ // would make the startup line claim nothing is served.
51
+ routes: adapterRoutes,
52
+ agentDir,
53
+ workspace,
54
+ // Unknown at boot by design — a channel list here would be the pre-restore emptiness.
55
+ channels: { routes: [], longConnections: [], builtinInvoke: false },
56
+ schedules: scheduled.schedules,
57
+ ready: Promise.resolve(), // nothing to open: no port of our own, no resident connections
58
+ ...(withControl.control ? { control: withControl.control } : {}),
59
+ announce(boundPort) {
60
+ unannounce = withControl.announce(boundPort);
61
+ },
62
+ async close() {
63
+ // UNTESTED, deliberately noted: no test observes these timers being cleared. Installing fake
64
+ // timers early enough to count them deadlocks the assembly's own IO. What IS tested is that
65
+ // close() runs and is idempotent; the stop itself rides on scheduler.stop()'s own tests.
66
+ scheduled.stop();
67
+ closed.abort();
68
+ unannounce?.(); // a stale discovery file would point `attach` at a stopped service
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * Mount the AgentCore Runtime adapter (`POST /invocations` + `GET /ping`) over the serving routes —
74
+ * the deployed container's ONLY reachable surface (channels/agentcore.ts). Wired by `start` when
75
+ * `FASTAGENT_AGENTCORE=1` (set by the generated deploy artifacts, never by hand). A channel colliding
76
+ * on either path fails startup, same disposition as the control-plane mount: the adapter's paths are
77
+ * the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
78
+ */
79
+ export function mountAgentcore(routes, options) {
80
+ const { agent, stateRoot, schedules, onStateReady, lazyChannels, signal } = options;
81
+ const mounted = agentcoreRoutes({
82
+ routes: lazyChannels ?? { routes },
83
+ agent,
84
+ stateRoot,
85
+ isBusy: () => activeWork() > 0,
86
+ // Cross-deploy durability: AgentCore wipes the state mount on every runtime version update, so
87
+ // the state root is restored from (and pushed to) an S3 snapshot through presigned URLs the
88
+ // forwarder mints per envelope. Always wired on this path — the platform gives no other way to
89
+ // keep an agent's memory across a deploy.
90
+ stateSync: createStateSync({ stateRoot }),
91
+ // What separates a forwarder envelope from any IAM principal's InvokeAgentRuntime call. Absent =
92
+ // no forwarder in this topology, so only the public `invoke` kind is servable.
93
+ ingressSecret: process.env.FASTAGENT_INGRESS_SECRET,
94
+ onStateReady,
95
+ ...(signal ? { signal } : {}),
96
+ fire: schedules.length === 0
97
+ ? undefined
98
+ : (name, slot) => {
99
+ const schedule = schedules.find((s) => s.name === name);
100
+ if (!schedule)
101
+ throw new UnknownScheduleError(name);
102
+ return fireScheduleOnce({ agent, stateRoot, schedule, slot });
103
+ },
104
+ });
105
+ const collisions = Object.keys(routes).filter((key) => Object.keys(mounted).some((adapterKey) => routeKeysConflict(key, adapterKey)));
106
+ if (collisions.length > 0) {
107
+ throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the AgentCore adapter ` +
108
+ `(/invocations, /ping) — rename the channel route`);
109
+ }
110
+ return { ...routes, ...mounted };
111
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Agent } from "../agent.ts";
2
2
  import type { StateSync, StateUrls } from "./agentcore-state.ts";
3
- import type { Routes } from "../host/node.ts";
3
+ import type { Routes } from "../channel.ts";
4
+ import { type PrefixMount } from "../channels/serve.ts";
4
5
  import type { ScheduleFireOutcome } from "../schedule/scheduler.ts";
5
6
  /**
6
7
  * The HOST's webhook body limit, and the one place it is computed. Lambda Function URLs cap a request
@@ -71,6 +72,12 @@ export interface WebhookReply {
71
72
  headers: Record<string, string>;
72
73
  bodyB64: string;
73
74
  }
75
+ /** What the lazy factory hands back: literal routes plus any prefix-owning mounts (the control
76
+ * plane), so the adapter's INNER dispatch is assembled exactly like a direct host's. */
77
+ export interface RouteSurface {
78
+ routes: Routes;
79
+ mounts?: readonly PrefixMount[];
80
+ }
74
81
  export interface AgentcoreAdapterOptions {
75
82
  /** The serving routes a direct deployment would mount (channels or the builtin invoke + health).
76
83
  * The serving path passes a LAZY factory: channel construction loads channel state and replays
@@ -78,7 +85,7 @@ export interface AgentcoreAdapterOptions {
78
85
  * which happens at the first envelope's `stateSync.ready()` (the restore URLs only an envelope
79
86
  * carries), never at boot, where the mount is pre-restore (empty after every version update).
80
87
  * An eager `Routes` value remains supported for wirings whose state root is already durable. */
81
- routes: Routes | (() => Promise<Routes> | Routes);
88
+ routes: RouteSurface | (() => Promise<RouteSurface> | RouteSurface);
82
89
  agent: Agent;
83
90
  /** Where the forwarder URL from envelopes is persisted for the wake-alarm sink (the state root). */
84
91
  stateRoot: string;
@@ -94,6 +101,9 @@ export interface AgentcoreAdapterOptions {
94
101
  /** FASTAGENT_INGRESS_SECRET: what makes an envelope the FORWARDER's rather than any IAM principal's.
95
102
  * Undefined = nothing can be trusted, so only the public `invoke` kind is served. */
96
103
  ingressSecret?: string;
104
+ /** Cancels the adapter's process-global registrations. Without it a closed adapter keeps saving
105
+ * state on every later idle edge — including work belonging to whatever mounted after it. */
106
+ signal?: AbortSignal;
97
107
  /** Runs ONCE, after the state root is authoritative (post-restore) — the wake-alarm reconcile, which
98
108
  * at boot would see the mount the platform just wiped and conclude there is nothing pending. */
99
109
  onStateReady?: () => void;
@@ -28,7 +28,7 @@
28
28
  import { Buffer } from "node:buffer";
29
29
  import { timingSafeEqual } from "node:crypto";
30
30
  import { beginWork, onIdle } from "./busy.js";
31
- import { router } from "../host/node.js";
31
+ import { router } from "../channels/serve.js";
32
32
  import { log } from "../log.js";
33
33
  import { rememberWakeAlarmUrl } from "../schedule/wake-alarm.js";
34
34
  import { readBodyCapped } from "./body.js";
@@ -72,7 +72,7 @@ export function agentcoreRoutes(options) {
72
72
  // not escape before `dispatchP` is assigned (which would silently re-run the activation).
73
73
  dispatchP = Promise.resolve()
74
74
  .then(() => (typeof routes === "function" ? routes() : routes))
75
- .then(router);
75
+ .then((surface) => router(surface.routes, surface.mounts));
76
76
  dispatchP.catch(() => { }); // observed here so the CACHED rejection is never "unhandled"
77
77
  }
78
78
  return dispatchP;
@@ -80,8 +80,10 @@ export function agentcoreRoutes(options) {
80
80
  const invokeHandler = createInvokeHandler(agent);
81
81
  // Snapshot on the 0-in-flight edge: webhook channels ACK fast and finish the turn in the
82
82
  // background, so "the request returned" is NOT when the state root settles.
83
- if (stateSync)
84
- onIdle(() => stateSync.save());
83
+ if (stateSync) {
84
+ const off = onIdle(() => stateSync.save());
85
+ options.signal?.addEventListener("abort", off, { once: true });
86
+ }
85
87
  let warnedUnsnapshotted = false;
86
88
  let stateReadyFired = false;
87
89
  const handleInvocation = async (req) => {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * SHARED process-wide in-flight work signal. Channels ACK a webhook fast and run the turn
3
- * fire-and-forget on this process's event loop (host/node.ts) — so "is this process busy?" is not
3
+ * fire-and-forget on this process's event loop (serve.ts) — so "is this process busy?" is not
4
4
  * derivable from open HTTP requests. The two shared execution primitives (turn-queue chains,
5
5
  * task-tracker side tasks) report here; a serving surface that must stay alive while background
6
6
  * work runs (the AgentCore adapter's /ping → HealthyBusy) reads it.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * SHARED process-wide in-flight work signal. Channels ACK a webhook fast and run the turn
3
- * fire-and-forget on this process's event loop (host/node.ts) — so "is this process busy?" is not
3
+ * fire-and-forget on this process's event loop (serve.ts) — so "is this process busy?" is not
4
4
  * derivable from open HTTP requests. The two shared execution primitives (turn-queue chains,
5
5
  * task-tracker side tasks) report here; a serving surface that must stay alive while background
6
6
  * work runs (the AgentCore adapter's /ping → HealthyBusy) reads it.
@@ -1,17 +1,18 @@
1
1
  import { type SessionControl, type SessionEvent } from "../session.ts";
2
2
  import type { Agent } from "../agent.ts";
3
- import type { Routes } from "../host/node.ts";
3
+ import type { ChannelHandler } from "../channel.ts";
4
+ import { type PrefixMount } from "./serve.ts";
4
5
  /** The SSE payload: one control-plane event in its transport envelope. */
5
6
  export interface WireEvent {
6
7
  sessionId: string;
7
- /** Serving-process incarnation (per `controlRoutes` call). A change means the server restarted:
8
+ /** Serving-process incarnation (per `createControlPlane` call). A change means the server restarted:
8
9
  * live continuity is gone — run the reconnect steps (entries cursor + state). */
9
10
  epoch: string;
10
11
  /** Per-connection monotonic counter. A gap means events were lost in transit on THIS connection. */
11
12
  seq: number;
12
13
  event: SessionEvent;
13
14
  }
14
- export interface ControlRoutesOptions {
15
+ export interface ControlPlaneOptions {
15
16
  /** Shared bearer secret, required on every route. Never optional: an unauthenticated
16
17
  * remote-control endpoint must not be constructible by omission. */
17
18
  token: string;
@@ -22,7 +23,16 @@ export interface ControlRoutesOptions {
22
23
  agent?: Agent;
23
24
  }
24
25
  /**
25
- * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
26
- * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
26
+ * Create the control plane as a mountable prefix owner: `GET
27
+ * /control/capabilities|commands|state|entries|events` + `POST /control/dispatch`, all
28
+ * bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
29
+ * The plane OWNS {@link CONTROL_PREFIX}: it is mounted as one sub-application, answers its own
30
+ * 404/405/preflight, and puts CORS headers on every reply — see {@link planeApp}.
27
31
  */
28
- export declare function controlRoutes(control: SessionControl, options: ControlRoutesOptions): Routes;
32
+ export declare function createControlPlane(control: SessionControl, options: ControlPlaneOptions): PrefixMount;
33
+ /** Mount a plane route table as a {@link PrefixMount} — the plane owns a PREFIX, while a route
34
+ * table is a set of literal paths. */
35
+ export declare function mountControlPlane(routes: Record<string, ChannelHandler>): PrefixMount;
36
+ /** The plane's route table. Exported so the conformance sweeps derive their route list from what is
37
+ * actually mounted, rather than from a hand-kept copy that cannot notice a new route. */
38
+ export declare function controlPlaneRoutes(control: SessionControl, options: ControlPlaneOptions): Record<string, ChannelHandler>;
@@ -1,9 +1,98 @@
1
1
  import { INVALID_COMMAND_CODE } from "../session.js";
2
2
  import { timingSafeEqual } from "node:crypto";
3
+ import { parseRouteKey, withoutBody } from "./serve.js";
4
+ import { log } from "../log.js";
3
5
  import { readBodyCapped } from "./body.js";
4
6
  import { MAX_BODY_BYTES, createInvokeHandler, sseHeartbeat } from "./http.js";
5
7
  import { text } from "./respond.js";
8
+ /** The prefix this plane OWNS: everything under it is the plane's to answer. */
9
+ const CONTROL_PREFIX = "/control";
6
10
  const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
11
+ /**
12
+ * The plane as one mounted sub-application rather than routes sharing a prefix.
13
+ *
14
+ * CORS belongs to every reply that leaves the plane — including the ones no route produces (an
15
+ * unknown path, an unserved method, a throwing handler). As separate routes those came from the
16
+ * host, outside anything the plane could decorate. Owning the prefix makes them its own answers,
17
+ * headers applied at the single exit they share.
18
+ *
19
+ * `*` is the right origin: authorisation is the bearer token — never the origin, never a cookie —
20
+ * so an origin that cannot present it gets 401 either way, and a deployment cannot know the origins
21
+ * of the GUIs that will manage it (§14's asymmetry).
22
+ *
23
+ * `authorization` is not CORS-safelisted, so EVERY call preflights, including a plain GET.
24
+ * `content-type` is not either (only three values are, and `application/json` is not among them),
25
+ * so a browser POSTing to dispatch/invoke names it — allowing just `authorization` leaves precisely
26
+ * the WRITE routes unreachable while reads work.
27
+ */
28
+ 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]) => {
31
+ 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
+ }
42
+ // Per PATH, stating what it actually serves — omitting a method it does serve has the browser
43
+ // 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) ?? []);
46
+ if (methods.has("GET"))
47
+ methods.add("HEAD");
48
+ // The requested method is always allowed, even where this path does not serve it: preflight is a
49
+ // gate applied BEFORE the request exists, so refusing there means the real request is never sent
50
+ // and the client sees an opaque network error. Allowing it lets the plane's own 404/405 arrive,
51
+ // with these headers and an explanation.
52
+ if (requested)
53
+ methods.add(requested.toUpperCase());
54
+ return [...methods, "OPTIONS"].join(", ");
55
+ };
56
+ return async (req) => {
57
+ const path = new URL(req.url).pathname;
58
+ const known = methodsByPath.has(path);
59
+ const answer = async () => {
60
+ // A preflight carries no token — that is its purpose — so it is answered before auth, and for
61
+ // ANY path under the prefix: gating it would stop the request the 404 below is waiting for.
62
+ if (req.method === "OPTIONS")
63
+ 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
+ }
72
+ // 404 vs 405 as in the host router: a client reads 404 as "this serve predates the route".
73
+ if (known)
74
+ return text("method not allowed\n", 405);
75
+ return text("not found\n", 404);
76
+ };
77
+ let res;
78
+ try {
79
+ // HEAD carries no content, whichever branch answered — including this plane's own 404/405.
80
+ const answered = await answer();
81
+ res = req.method === "HEAD" ? withoutBody(answered) : answered;
82
+ }
83
+ catch (error) {
84
+ // The plane's own totality boundary: a rejecting handler (`commands()` on an unreadable
85
+ // definition) must still answer with the headers; the message stays internal.
86
+ log.error(`[control] ${req.method} ${path} failed: ${String(error)}`);
87
+ res = text("internal error\n", 500);
88
+ }
89
+ // THE single exit. Every reply above — route, preflight, 404, 405, 500 — leaves through here.
90
+ res.headers.set("access-control-allow-origin", "*");
91
+ 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")));
93
+ return res;
94
+ };
95
+ }
7
96
  // ONE constant for every Prompt-bearing wire surface (imported from the invoke channel — the two
8
97
  // caps cannot drift apart): commands carry Prompts, which may ride base64 images.
9
98
  const DISPATCH_BODY_LIMIT = MAX_BODY_BYTES;
@@ -84,13 +173,26 @@ function parseWireCommand(raw) {
84
173
  }
85
174
  }
86
175
  /**
87
- * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
88
- * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
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}.
89
181
  */
90
- export function controlRoutes(control, options) {
182
+ export function createControlPlane(control, options) {
183
+ return mountControlPlane(controlPlaneRoutes(control, options));
184
+ }
185
+ /** 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. */
187
+ export function mountControlPlane(routes) {
188
+ return { prefix: CONTROL_PREFIX, handler: planeApp(routes) };
189
+ }
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. */
192
+ export function controlPlaneRoutes(control, options) {
91
193
  const { token } = options;
92
194
  if (!token)
93
- throw new Error("controlRoutes: a bearer token is required (empty tokens are not a mode)");
195
+ throw new Error("createControlPlane: a bearer token is required (empty tokens are not a mode)");
94
196
  const epoch = crypto.randomUUID();
95
197
  // Timing-safe: the bearer token is this surface's ONLY auth (and the --tunnel warning names it
96
198
  // as the sole protection on a public URL) — a plain === would leak byte-by-byte via timing.