@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,157 @@
1
+ import type { Agent } from "./agent.ts";
2
+ import { type PrefixMount } from "./channels/serve.ts";
3
+ import { type LoadedLongConnectionChannel } from "./channels/discover.ts";
4
+ import type { SessionControl } from "./session.ts";
5
+ import type { ChannelHandler, Routes } from "./channel.ts";
6
+ import type { LoadedSchedule } from "./schedule/schedule.ts";
7
+ export interface ServingSurface {
8
+ routes: Routes;
9
+ /** Prefix-owning handlers mounted beside the routes (the session control plane). */
10
+ mounts?: readonly PrefixMount[];
11
+ longConnections: LoadedLongConnectionChannel[];
12
+ /** Route-channel basenames; the tunnel registers only this subset. */
13
+ routeChannels: string[];
14
+ builtinInvoke: boolean;
15
+ /** Flip health between 200 and 503. Two-way on purpose: a long connection that dies after coming
16
+ * up leaves the surface serving something it no longer has, and a load balancer should hear it. */
17
+ setReady(value: boolean): void;
18
+ }
19
+ /**
20
+ * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
21
+ * POST `/invoke` only when neither a route nor a long-connection channel was declared.
22
+ */
23
+ export declare function routesFor(agentDir: string, agent: Agent, stateRoot: string, control: SessionControl | undefined, options?: {
24
+ builtinInvoke?: boolean;
25
+ }): Promise<ServingSurface>;
26
+ /**
27
+ * Refuse channel routes the control plane would swallow.
28
+ *
29
+ * `router` refuses this too, and would catch it a moment later; this exists for the sentence, not
30
+ * the check — an author who lands a route under `/control` needs to hear about `sessionControl`,
31
+ * which the host has no way to mention. Both ask {@link pathUnderPrefix}, so there is one rule with
32
+ * two wordings, not two rules.
33
+ *
34
+ * Called from BOTH mount points: here at boot, and again on agentcore's lazy path, whose channels
35
+ * load after this ran against an empty base.
36
+ */
37
+ export declare function assertNoControlPlaneCollision(channelRoutes: Routes, plane: PrefixMount): void;
38
+ export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, stateRoot: string, options?: {
39
+ tunnel?: boolean;
40
+ agent?: Agent;
41
+ host?: string;
42
+ }): {
43
+ routes: Routes;
44
+ mounts: PrefixMount[];
45
+ /** The plane's bearer token and prefix — how an embedder distributes access without a discovery file. */
46
+ control?: {
47
+ token: string;
48
+ prefix: string;
49
+ };
50
+ /** Write the local discovery file; returns its removal. Installs no signal handlers. */
51
+ announce: (boundPort: number) => () => void;
52
+ };
53
+ /**
54
+ * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
55
+ * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
56
+ * loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
57
+ * ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
58
+ */
59
+ export declare function startSchedules(agentDir: string, agent: Agent, stateRoot: string, selfSchedule: boolean, options?: {
60
+ externalClock?: boolean;
61
+ }): Promise<{
62
+ schedules: LoadedSchedule[];
63
+ stop: () => void;
64
+ }>;
65
+ export interface AgentService {
66
+ /** The assembled Fetch handler: channel routes, the control plane, and health. Mount it wherever
67
+ * your host speaks `(Request) => Response`; `nodeListener` bridges it to Node's `(req, res)`. */
68
+ handler: ChannelHandler;
69
+ /** The agent behind it — invoke it directly when you also want a programmatic path. */
70
+ agent: Agent;
71
+ /** The literal routes `handler` was composed from — for a startup line naming what is served.
72
+ * Mounted prefixes are not here: nothing outside the assembly needed them, and a field kept for a
73
+ * hypothetical caller is a field nobody maintains. */
74
+ routes: Routes;
75
+ agentDir: string;
76
+ workspace: string;
77
+ /** What actually mounted, for a startup line: channel files serving routes, long connections, and
78
+ * whether the built-in `POST /invoke` fallback is one of the routes. That last one is a FACT of
79
+ * the assembly, not something to re-infer from a path — a channel may legally author
80
+ * `POST /invoke` with a protocol of its own. */
81
+ channels: {
82
+ routes: string[];
83
+ longConnections: string[];
84
+ builtinInvoke: boolean;
85
+ };
86
+ schedules: readonly LoadedSchedule[];
87
+ /** Settles when every long connection is up — immediately when there are none. REJECTS if one
88
+ * fails to come up, after closing the service: a host must not report itself serving while a
89
+ * declared channel is dead, and health answers 503 until this resolves. */
90
+ ready: Promise<void>;
91
+ /** The control plane's bearer token and prefix, when `sessionControl` is on — how an embedder
92
+ * hands access to a client without a discovery file. */
93
+ control?: {
94
+ token: string;
95
+ prefix: string;
96
+ };
97
+ /** Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the plane,
98
+ * once the port is known. Optional: an embedder mounted inside a larger app has no port of its
99
+ * own to describe and uses {@link AgentService.control} instead.
100
+ *
101
+ * Removed by `close()`. Not by an `exit` handler: installing one is a decision about the whole
102
+ * process, which a mounted library does not get to make. A hard exit therefore leaves the file
103
+ * behind — advisory, overwritten by the next boot, and the client's own error stays honest. */
104
+ announce(boundPort: number): void;
105
+ /** Stop long connections and schedules. Idempotent; also runs when `options.signal` aborts. */
106
+ close(): Promise<void>;
107
+ }
108
+ /** What {@link mountAgentService} needs beyond an opened directory. */
109
+ export interface MountAgentServiceOptions {
110
+ /** Wrap the agent before anything consumes it — every consumer (routes, control plane, schedules)
111
+ * must get the SAME one, which is why this is a hook rather than the caller's own call. `dev`
112
+ * passes `logAgentLoop`. */
113
+ wrapAgent?: (agent: Agent) => Agent;
114
+ /** Passed through to the control plane mount: `--tunnel` widens its warning, `host` names the
115
+ * bind address in the discovery file. */
116
+ control?: {
117
+ tunnel?: boolean;
118
+ host?: string;
119
+ };
120
+ /** Aborting this closes the service, exactly like calling {@link AgentService.close}. */
121
+ signal?: AbortSignal;
122
+ /** Called when a long connection ends on its own — a dropped socket-mode channel, say. The CLI
123
+ * exits; an embedded host may prefer to log. Default: log an error. */
124
+ onChannelClosed?: (name: string, error?: unknown) => void;
125
+ /** How long `close()` waits for a channel to stop before reporting it stuck (default 5s). The CLI
126
+ * shortens it so its own forced exit lands after this answer, not before it. */
127
+ closeTimeoutMs?: number;
128
+ }
129
+ /**
130
+ * What the assembly needs from an opened agent directory — the whole of it. Spelled as its own type
131
+ * rather than an engine's return shape: every field here is either the SPEC contract or a path, so
132
+ * an engine that is not pi can satisfy it without either side knowing about the other.
133
+ */
134
+ export interface MountableAgent {
135
+ agent: Agent;
136
+ /** The definition dir: where channels/, tools/ and schedules/ are read from. */
137
+ agentDir: string;
138
+ /** The agent's cwd. */
139
+ workspace: string;
140
+ /** Where durable state lives (channel state, sessions, schedule fires). */
141
+ stateRoot: string;
142
+ /** Present iff this agent published a control plane. */
143
+ sessionControl?: SessionControl;
144
+ /** Whether the agent schedules its own follow-up turns. REQUIRED, not optional-with-a-default:
145
+ * an engine that forgot it would turn self-scheduling off silently, which is exactly the bug
146
+ * this type was introduced with. */
147
+ selfSchedule: boolean;
148
+ }
149
+ /**
150
+ * The assembly itself, over an already-opened directory: channels, the control plane, schedules and
151
+ * long connections, composed into one handler.
152
+ *
153
+ * {@link createAgentService} is this plus opening the directory. `dev`/`start` open separately — their
154
+ * startup report needs the opened values before anything mounts — and then arrive here, so there is
155
+ * one assembly rather than one per caller.
156
+ */
157
+ export declare function mountAgentService(opened: MountableAgent, options?: MountAgentServiceOptions): Promise<AgentService>;
@@ -0,0 +1,376 @@
1
+ /**
2
+ * The product, as one call: an agent directory becomes a live service.
3
+ *
4
+ * That phrase is the promise on the README, and until this existed only the CLI could keep it. The
5
+ * assembly parts live here too — `routesFor`, `mountSessionControl`, `startSchedules` — because a
6
+ * public entry may not reach into `cli/`: that directory decides process-level things (`fail.ts`
7
+ * calls `process.exit`) which a library mounted inside someone's app does not get to decide.
8
+ * Everything else was parts: assemble the agent, discover channels, mount the control plane, start
9
+ * schedules, open long connections, compose a router. An embedder had to know that list and get its
10
+ * order right, and getting it wrong is silent: a plane that 404s while advertising itself, a
11
+ * schedule that never fires.
12
+ *
13
+ * So the assembly lives here, and `dev`/`start` are callers. AgentCore is the one exception, and a
14
+ * substantive one: its channels load lazily after a state-snapshot restore, so it cannot use an
15
+ * assembly that discovers them eagerly (cli/commands/start.ts says so at the branch).
16
+ */
17
+ import { mkdirSync, rmSync } from "node:fs";
18
+ import { writeFileAtomic } from "./atomic-write.js";
19
+ import { join } from "node:path";
20
+ import { classifyBind, clientHost } from "./bind.js";
21
+ import { CONTROL_TOKEN_ENV, createControlPlane } from "./channels/control.js";
22
+ import { createInvokeHandler } from "./channels/http.js";
23
+ import { text } from "./channels/respond.js";
24
+ import { parseRouteKey, pathUnderPrefix, router } from "./channels/serve.js";
25
+ import { loadChannels } from "./channels/discover.js";
26
+ import { loadSchedules } from "./schedule/discover.js";
27
+ import { createScheduler } from "./schedule/scheduler.js";
28
+ import { log, reportModuleLoadFailures } from "./log.js";
29
+ /** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
30
+ * abort signal must not hang a caller's teardown — or, during a failed start, keep the original
31
+ * error from arriving. The CLI passes a shorter one: its own forced exit must come AFTER this, or
32
+ * the process leaves at 0 before the failure is known. */
33
+ const CLOSE_DEADLINE_MS = 5_000;
34
+ /** Settle when every connection has closed, or when the deadline passes. Reports the ones that did
35
+ * NOT settle — named individually, so a single stuck channel is not reported as all of them. */
36
+ async function closeWithin(runs, names, deadlineMs) {
37
+ const pending = new Set(runs.map((_, i) => i));
38
+ const failures = [];
39
+ const tracked = runs.map((run, i) => run.closed.then(() => {
40
+ pending.delete(i);
41
+ }, (error) => {
42
+ pending.delete(i);
43
+ failures.push(error);
44
+ }));
45
+ let timer;
46
+ try {
47
+ await Promise.race([
48
+ Promise.all(tracked),
49
+ // NOT unref'd: this timer is the thing being awaited, and an unref'd one lets the loop go
50
+ // idle with nothing left to advance it. Cleared below so a prompt close does not hold the
51
+ // process for the rest of the deadline.
52
+ new Promise((resolve) => {
53
+ timer = setTimeout(resolve, deadlineMs);
54
+ }),
55
+ ]);
56
+ }
57
+ finally {
58
+ clearTimeout(timer);
59
+ }
60
+ return { stuck: [...pending].map((i) => names[i] ?? "channel"), failures };
61
+ }
62
+ /**
63
+ * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
64
+ * POST `/invoke` only when neither a route nor a long-connection channel was declared.
65
+ */
66
+ export async function routesFor(agentDir, agent, stateRoot, control, options = {}) {
67
+ const { routes, longConnections, routeChannels, collisions, failures } = await loadChannels(agentDir, {
68
+ agent,
69
+ stateRoot,
70
+ control,
71
+ });
72
+ for (const c of collisions) {
73
+ console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
74
+ }
75
+ reportModuleLoadFailures(failures);
76
+ if (failures.length > 0 || collisions.length > 0) {
77
+ throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
78
+ `fix it, or rename an intentionally disabled file to *.disabled`);
79
+ }
80
+ const builtinInvoke = options.builtinInvoke !== false && Object.keys(routes).length === 0 && longConnections.length === 0;
81
+ const channels = builtinInvoke ? { "POST /invoke": createInvokeHandler(agent) } : routes;
82
+ const healthCovered = Object.keys(channels).some((key) => {
83
+ const entry = parseRouteKey(key);
84
+ return entry.path === "/health" && (entry.method === undefined || entry.method === "GET");
85
+ });
86
+ let ready = longConnections.length === 0;
87
+ const health = () => (ready ? text("ok\n", 200) : text("starting\n", 503));
88
+ return {
89
+ routes: healthCovered ? channels : { "GET /health": health, ...channels },
90
+ longConnections,
91
+ routeChannels,
92
+ builtinInvoke,
93
+ setReady(value) {
94
+ ready = value;
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Refuse channel routes the control plane would swallow.
100
+ *
101
+ * `router` refuses this too, and would catch it a moment later; this exists for the sentence, not
102
+ * the check — an author who lands a route under `/control` needs to hear about `sessionControl`,
103
+ * which the host has no way to mention. Both ask {@link pathUnderPrefix}, so there is one rule with
104
+ * two wordings, not two rules.
105
+ *
106
+ * Called from BOTH mount points: here at boot, and again on agentcore's lazy path, whose channels
107
+ * load after this ran against an empty base.
108
+ */
109
+ export function assertNoControlPlaneCollision(channelRoutes, plane) {
110
+ const collisions = Object.keys(channelRoutes).filter((key) => pathUnderPrefix(parseRouteKey(key).path, plane.prefix));
111
+ if (collisions.length > 0) {
112
+ throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control plane — ` +
113
+ `rename the channel route or disable sessionControl in fastagent.config`);
114
+ }
115
+ }
116
+ export function mountSessionControl(routes, control, stateRoot, options = {}) {
117
+ if (!control)
118
+ return { routes, mounts: [], announce: () => () => { } };
119
+ // WHO OWNS the secret. Per-boot mint is right locally: discovery is `control.json` and its file
120
+ // permissions, which works because both holders share a filesystem. A deployment removes that
121
+ // premise — a token minted in the container is replaced every restart and reachable only by shelling
122
+ // in — so there the deployer mints it and injects it here, like the wake/ingress secrets.
123
+ // Trimmed on read, like `.env` values already are: a token pasted from a dashboard with a trailing
124
+ // newline would otherwise become the box's token verbatim, and every caller holding the clean value
125
+ // gets a bare 401 — the undiagnosable symptom, one character wide.
126
+ const injected = process.env[CONTROL_TOKEN_ENV]?.trim();
127
+ // SET BUT EMPTY is the deployed default, not an edge case: the generated Compose topology writes
128
+ // every secret as `NAME: "${NAME:-}"`, so an operator who skipped this one lands here. Falling back
129
+ // silently would leave exactly the symptom the injection exists to remove — a token the caller does
130
+ // not have — with nothing in the log to tell it apart from a deployment that never asked.
131
+ if (injected === "") {
132
+ log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is set but empty — minting a per-boot token instead; callers holding ` +
133
+ "the deploy-time value will get 401 (set it, or read the minted one from control.json on the box)");
134
+ }
135
+ else if (injected !== undefined && injected.length < 16) {
136
+ // Length is a crude proxy for entropy — sixteen `a`s pass. It is aimed at `changeme`, which this
137
+ // change makes newly dangerous: the plane went from unusable-in-a-deployment to usable by whoever
138
+ // holds this string, and the empty case is the only other thing that says anything.
139
+ log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is ${injected.length} characters — it is the ONLY thing between ` +
140
+ "/control/* (steer, stop, rewrite a session) and anyone who can reach the port; use a random value (uuidgen)");
141
+ }
142
+ const token = injected || crypto.randomUUID();
143
+ const plane = createControlPlane(control, { token, agent: options.agent });
144
+ assertNoControlPlaneCollision(routes, plane);
145
+ return {
146
+ routes,
147
+ mounts: [plane],
148
+ control: { token, prefix: plane.prefix },
149
+ // Writes the discovery file and hands back its removal. It installs NO signal handlers: a
150
+ // library mounted inside someone's app must not change how that app exits — the CLI wires the
151
+ // returned cleanup into its own shutdown, an embedder into `close()`.
152
+ announce: (boundPort) => {
153
+ mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
154
+ const path = join(stateRoot, "control.json");
155
+ const url = `http://${clientHost(options.host)}:${boundPort}`;
156
+ writeFileAtomic(path, `${JSON.stringify({ url, token })}\n`, 0o600);
157
+ log.info(`[fastagent] session control on /control/* (token in ${path})`);
158
+ // LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths
159
+ // warn loudly, and the LAN path must not be the silent third way past the local trust story.
160
+ // A loopback bind closes exactly that reach, so it earns silence.
161
+ const bind = classifyBind(options.host);
162
+ if (bind !== "loopback") {
163
+ log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
164
+ "/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
165
+ "(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
166
+ }
167
+ if (options.tunnel) {
168
+ // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
169
+ log.warn("[fastagent] --tunnel exposes /control/* (steer, stop, rewrite or delete a session) at the public tunnel URL, " +
170
+ "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
171
+ }
172
+ // Removed on shutdown so a stale file cannot point a client at a dead port: `attach` then
173
+ // fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
174
+ return () => {
175
+ try {
176
+ rmSync(path, { force: true });
177
+ }
178
+ catch {
179
+ /* the file is advisory — shutdown must not fail on it */
180
+ }
181
+ };
182
+ },
183
+ };
184
+ }
185
+ /**
186
+ * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
187
+ * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
188
+ * loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
189
+ * ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
190
+ */
191
+ export async function startSchedules(agentDir, agent, stateRoot, selfSchedule, options = {}) {
192
+ // Thrown, not exited on: this runs inside an embedder's app as well as the CLI, and a library
193
+ // that calls process.exit takes a decision (degrade? retry? stop?) that belongs to its host. The
194
+ // CLI catches at its own boundary.
195
+ const { schedules, failures } = await loadSchedules(agentDir);
196
+ reportModuleLoadFailures(failures);
197
+ if (schedules.length === 0 && !selfSchedule)
198
+ return { schedules, stop: () => { } };
199
+ const scheduler = createScheduler({ agent, stateRoot, schedules, externalClock: options.externalClock });
200
+ scheduler.start();
201
+ if (schedules.length > 0) {
202
+ log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}${options.externalClock ? " (external clock — no resident cron timers)" : ""}`);
203
+ }
204
+ // Returned rather than bound to process signals here: this runs inside an embedder's app as well
205
+ // as the CLI, and a library that installs SIGINT handlers is deciding something that is not its
206
+ // to decide. `runStart`/`runDev` wire it to their own shutdown.
207
+ return { schedules, stop: () => scheduler.stop() };
208
+ }
209
+ /**
210
+ * The assembly itself, over an already-opened directory: channels, the control plane, schedules and
211
+ * long connections, composed into one handler.
212
+ *
213
+ * {@link createAgentService} is this plus opening the directory. `dev`/`start` open separately — their
214
+ * startup report needs the opened values before anything mounts — and then arrive here, so there is
215
+ * one assembly rather than one per caller.
216
+ */
217
+ export async function mountAgentService(opened, options = {}) {
218
+ const { agentDir, workspace, stateRoot, sessionControl } = opened;
219
+ // Wrapped BEFORE anything consumes it: routes, the control plane and schedules must all drive the
220
+ // same agent, so this is a hook rather than something a caller applies afterwards.
221
+ const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
222
+ const closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_DEADLINE_MS;
223
+ const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: true });
224
+ const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
225
+ agent,
226
+ ...(options.control?.tunnel !== undefined ? { tunnel: options.control.tunnel } : {}),
227
+ ...(options.control?.host !== undefined ? { host: options.control.host } : {}),
228
+ });
229
+ // Composed BEFORE anything starts. `router` re-validates what `loadChannels` and the control
230
+ // mount already checked, so on THIS path it should not fail — but "should not" is not an ordering
231
+ // guarantee, and a throw after the scheduler ticks and channels dial would leave both running
232
+ // with no service for the caller to close. Free to order correctly; expensive to discover later.
233
+ const handler = router(withControl.routes, withControl.mounts);
234
+ const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule);
235
+ const abort = new AbortController();
236
+ let unannounce;
237
+ // A connection that drops while others are still dialling must not be undone by their later
238
+ // readiness: the service is missing a declared channel from that moment on, whatever else arrives.
239
+ let dropped = false;
240
+ const onClosed = options.onChannelClosed ??
241
+ ((name, error) => log.error(`[fastagent] long connection ${name} ${error === undefined ? "closed" : `failed: ${String(error)}`}`));
242
+ const runs = [];
243
+ // A FUNCTION declaration, not a const: `close()` detaches this listener, and a rollback can call
244
+ // `close()` before this point is reached — a `const` would be in its temporal dead zone there, so
245
+ // the cleanup would throw a ReferenceError and silently skip everything after it.
246
+ //
247
+ // Detached because a caller that closes services itself while holding one long-lived signal would
248
+ // otherwise accumulate listeners, each pinning a whole service through its closure. The signal
249
+ // path has no caller awaiting the promise, so a failure to stop is reported rather than left as an
250
+ // unhandled rejection — in an embedded library, potentially the host's exit.
251
+ function onAbort() {
252
+ void close().catch((error) => log.error(`[fastagent] service close failed: ${String(error)}`));
253
+ }
254
+ let closing;
255
+ const close = () => {
256
+ // Awaits the connections rather than only signalling them: `close()` promises they are stopped,
257
+ // and a caller tearing down a test or a request-scoped service needs that to be true on return.
258
+ closing ??= (async () => {
259
+ abort.abort();
260
+ scheduled.stop();
261
+ options.signal?.removeEventListener("abort", onAbort);
262
+ unannounce?.(); // a stale discovery file would point a client at a dead port
263
+ // A failure to stop is the caller's to know about — swallowing it would let `close()` report
264
+ // success over a channel still holding on. Bounded, because a channel that ignores its abort
265
+ // signal must not hang the teardown either.
266
+ const { stuck, failures } = await closeWithin(runs, routed.longConnections.map((c) => c.name), closeTimeoutMs);
267
+ if (stuck.length > 0) {
268
+ throw new Error(`long connection(s) did not stop within ${closeTimeoutMs}ms: ${stuck.join(", ")}`);
269
+ }
270
+ if (failures.length > 0) {
271
+ throw failures.length === 1 ? failures[0] : new AggregateError(failures, "long connections failed to close");
272
+ }
273
+ })();
274
+ return closing;
275
+ };
276
+ // Rollback IS close(), plus keeping the original error: a failure to clean up is the aftermath,
277
+ // and replacing the reason the caller needs with it hides the actual cause.
278
+ const rollback = async (error) => {
279
+ await close().catch((closeError) => log.error(`[fastagent] cleanup after a failed start also failed: ${String(closeError)}`));
280
+ throw error;
281
+ };
282
+ // Rolled back on failure: a connection that throws while the ones before it are open, and the
283
+ // scheduler already ticking, would otherwise leave both running behind a rejected open().
284
+ for (const connection of routed.longConnections) {
285
+ let run;
286
+ try {
287
+ run = connection.connect(abort.signal);
288
+ }
289
+ catch (error) {
290
+ return rollback(error);
291
+ }
292
+ if (typeof run?.ready?.then !== "function" || typeof run?.closed?.then !== "function") {
293
+ return rollback(new Error(`${connection.name} connect(signal) must return { ready: Promise, closed: Promise }`));
294
+ }
295
+ void run.closed.then(() => {
296
+ if (abort.signal.aborted)
297
+ return;
298
+ // A channel that dies leaves the service serving something it no longer has.
299
+ dropped = true;
300
+ routed.setReady(false);
301
+ onClosed(connection.name);
302
+ }, (error) => {
303
+ if (abort.signal.aborted)
304
+ return;
305
+ dropped = true;
306
+ routed.setReady(false);
307
+ onClosed(connection.name, error);
308
+ });
309
+ runs.push(run);
310
+ }
311
+ if (options.signal?.aborted)
312
+ await close();
313
+ else
314
+ options.signal?.addEventListener("abort", onAbort, { once: true });
315
+ // Health answers 503 until EVERY long connection is up, so a load balancer does not route into a
316
+ // service whose socket-mode channels are still dialling. An abort before that settles `ready` as
317
+ // cancellation, not readiness — a service being torn down must not report itself healthy.
318
+ const ready = (async () => {
319
+ try {
320
+ await Promise.all(runs.map(async (run, i) => {
321
+ const name = routed.longConnections[i]?.name ?? "channel";
322
+ // Raced against `closed`, because the contract puts a terminal failure THERE: a channel
323
+ // that dies dialling may leave `ready` pending forever, and waiting on it alone hangs
324
+ // startup with no diagnosis.
325
+ await Promise.race([
326
+ run.ready,
327
+ run.closed.then(() => Promise.reject(new Error(`${name} closed before it was ready`)), (error) => Promise.reject(new Error(`${name} failed before it was ready: ${String(error)}`))),
328
+ ]);
329
+ if (!abort.signal.aborted)
330
+ log.info(`[fastagent] long connection ready: ${name}`);
331
+ }));
332
+ }
333
+ catch (error) {
334
+ // A connection that cannot come up is a startup failure, not a degraded service: tear the rest
335
+ // down before rejecting, so nothing is left running behind a caller that saw an error. A
336
+ // cleanup that ALSO fails is logged, never rethrown — it would replace the reason the caller
337
+ // actually needs with the aftermath of it.
338
+ await close().catch((closeError) => log.error(`[fastagent] cleanup after a failed start also failed: ${String(closeError)}`));
339
+ throw error;
340
+ }
341
+ // A `ready` that settles because the service was CLOSED is cancellation, not readiness — the
342
+ // contract lets a connection resolve it on abort. Returning normally would tell a caller its
343
+ // channels are up while the service is shut and health says 503.
344
+ if (abort.signal.aborted)
345
+ throw new Error("service closed before it became ready");
346
+ // A drop DURING startup fails it. `dropped` is only reachable here from the startup window —
347
+ // after this line `ready` has settled — and resolving while health is permanently 503 would
348
+ // hand the caller two contradictory answers about the same surface.
349
+ if (dropped) {
350
+ await close();
351
+ throw new Error("a long connection closed before startup completed");
352
+ }
353
+ routed.setReady(true);
354
+ })();
355
+ // Observed here so a rejection is never unhandled; every caller still sees it through `ready`.
356
+ ready.catch(() => { });
357
+ return {
358
+ handler,
359
+ agent,
360
+ routes: withControl.routes,
361
+ agentDir,
362
+ workspace,
363
+ channels: {
364
+ routes: routed.routeChannels,
365
+ longConnections: routed.longConnections.map((c) => c.name),
366
+ builtinInvoke: routed.builtinInvoke,
367
+ },
368
+ schedules: scheduled.schedules,
369
+ ready,
370
+ ...(withControl.control ? { control: withControl.control } : {}),
371
+ announce: (boundPort) => {
372
+ unannounce = withControl.announce(boundPort);
373
+ },
374
+ close,
375
+ };
376
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The remote `SessionControl` — the client half of the Phase 3 transport (design §13). Engine- and
3
- * server-neutral: speaks only the wire protocol `controlRoutes` serves (HTTP JSON + SSE with the
3
+ * server-neutral: speaks only the wire protocol `createControlPlane` serves (HTTP JSON + SSE with the
4
4
  * {sessionId, epoch, seq, event} envelope) and re-exposes the SAME `SessionControl` interface, so
5
5
  * local and remote consumers are isomorphic — client code does not change when the agent moves out
6
6
  * of process.
@@ -15,13 +15,18 @@
15
15
  * visible as a thrown iteration error, a failed request as a rejected promise.
16
16
  */
17
17
  import type { Agent } from "./agent.ts";
18
- import type { SessionControl } from "./session.ts";
18
+ import { type SessionControl } from "./session.ts";
19
19
  /** A control request the server answered with a non-2xx status. Carries the STRUCTURED status so a
20
20
  * consumer distinguishing auth failure (401 — stale token, unrecoverable) from transient transport
21
21
  * trouble branches on `status`, never on message prose. */
22
22
  export declare class ControlRequestError extends Error {
23
23
  readonly status: number;
24
- constructor(status: number, body: string);
24
+ /** The plane's own error code, when the reply carried one (`sessions()` is the only read that
25
+ * does today — design §13). Absent for a plain-text rejection (401) or a proxy's page: a caller
26
+ * distinguishing "this deployment cannot list sessions" from "the endpoint is unreachable" reads
27
+ * THIS, not the status. */
28
+ readonly code?: string;
29
+ constructor(status: number, body: string, code?: string);
25
30
  }
26
31
  /** Connection parameters shared by BOTH remote planes (`connectSessionControl` and
27
32
  * `connectAgent`) — plane-neutral on purpose: one endpoint, one token, two contracts. */