@fastagent-sh/fastagent 0.14.0 → 0.16.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 (277) hide show
  1. package/README.md +16 -6
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +258 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +35 -0
  13. package/dist/channels/context-buffer.js +103 -0
  14. package/dist/channels/control.d.ts +28 -0
  15. package/dist/channels/control.js +214 -0
  16. package/dist/channels/feishu/card.js +1 -1
  17. package/dist/channels/feishu/context-buffer.d.ts +15 -13
  18. package/dist/channels/feishu/context-buffer.js +62 -69
  19. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  20. package/dist/channels/feishu/feishu-api.js +24 -3
  21. package/dist/channels/feishu/feishu.d.ts +27 -27
  22. package/dist/channels/feishu/feishu.js +318 -207
  23. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  24. package/dist/channels/feishu/invoke-turn.js +44 -79
  25. package/dist/channels/feishu/model.d.ts +2 -2
  26. package/dist/channels/feishu/normalize.d.ts +2 -1
  27. package/dist/channels/feishu/normalize.js +0 -1
  28. package/dist/channels/feishu/parse.d.ts +22 -8
  29. package/dist/channels/feishu/parse.js +24 -7
  30. package/dist/channels/feishu/preview.d.ts +4 -7
  31. package/dist/channels/feishu/preview.js +27 -142
  32. package/dist/channels/feishu/register-app.d.ts +2 -1
  33. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  34. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  35. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  36. package/dist/channels/feishu/setup-mode.d.ts +37 -0
  37. package/dist/channels/feishu/setup-mode.js +28 -0
  38. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  39. package/dist/channels/feishu/ws-ingress.js +136 -0
  40. package/dist/channels/github/github.js +16 -7
  41. package/dist/channels/http.d.ts +14 -0
  42. package/dist/channels/http.js +36 -3
  43. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  44. package/dist/channels/invoke-turn-kit.js +87 -0
  45. package/dist/channels/lark/lark.d.ts +4 -2
  46. package/dist/channels/lark/lark.js +4 -1
  47. package/dist/channels/lark/onboard.d.ts +8 -4
  48. package/dist/channels/lark/onboard.js +8 -0
  49. package/dist/channels/lark/scaffold/channel.ts +9 -8
  50. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  51. package/dist/channels/preview-kit.d.ts +115 -0
  52. package/dist/channels/preview-kit.js +184 -0
  53. package/dist/channels/seen.d.ts +5 -0
  54. package/dist/channels/seen.js +35 -0
  55. package/dist/channels/slack/bot-auth.d.ts +15 -0
  56. package/dist/channels/slack/bot-auth.js +146 -0
  57. package/dist/channels/slack/config-api.d.ts +60 -0
  58. package/dist/channels/slack/config-api.js +149 -0
  59. package/dist/channels/slack/context-buffer.d.ts +24 -0
  60. package/dist/channels/slack/context-buffer.js +37 -0
  61. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  62. package/dist/channels/slack/invoke-turn.js +63 -0
  63. package/dist/channels/slack/manifest.d.ts +49 -0
  64. package/dist/channels/slack/manifest.js +69 -0
  65. package/dist/channels/slack/model.d.ts +67 -0
  66. package/dist/channels/slack/model.js +2 -0
  67. package/dist/channels/slack/onboard.d.ts +41 -0
  68. package/dist/channels/slack/onboard.js +120 -0
  69. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  70. package/dist/channels/slack/onboarding-state.js +69 -0
  71. package/dist/channels/slack/parse.d.ts +38 -0
  72. package/dist/channels/slack/parse.js +124 -0
  73. package/dist/channels/slack/preview.d.ts +23 -0
  74. package/dist/channels/slack/preview.js +403 -0
  75. package/dist/channels/slack/reaction.d.ts +24 -0
  76. package/dist/channels/slack/reaction.js +62 -0
  77. package/dist/channels/slack/register-webhook.d.ts +10 -0
  78. package/dist/channels/slack/register-webhook.js +49 -0
  79. package/dist/channels/slack/scaffold/channel.ts +33 -0
  80. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  81. package/dist/channels/slack/setup-server.d.ts +17 -0
  82. package/dist/channels/slack/setup-server.js +103 -0
  83. package/dist/channels/slack/slack-api.d.ts +57 -0
  84. package/dist/channels/slack/slack-api.js +399 -0
  85. package/dist/channels/slack/slack.d.ts +51 -0
  86. package/dist/channels/slack/slack.js +496 -0
  87. package/dist/channels/slack/welcomed.d.ts +5 -0
  88. package/dist/channels/slack/welcomed.js +32 -0
  89. package/dist/channels/state.d.ts +11 -4
  90. package/dist/channels/state.js +22 -15
  91. package/dist/channels/stop-command.d.ts +6 -0
  92. package/dist/channels/stop-command.js +36 -0
  93. package/dist/channels/tasks.d.ts +7 -0
  94. package/dist/channels/tasks.js +25 -0
  95. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  96. package/dist/channels/telegram/context-buffer.js +6 -85
  97. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  98. package/dist/channels/telegram/invoke-turn.js +11 -58
  99. package/dist/channels/telegram/parse.d.ts +0 -7
  100. package/dist/channels/telegram/parse.js +4 -2
  101. package/dist/channels/telegram/preview.d.ts +4 -7
  102. package/dist/channels/telegram/preview.js +24 -142
  103. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  104. package/dist/channels/telegram/telegram.js +24 -10
  105. package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
  106. package/dist/channels/{feishu/text.js → text.js} +15 -1
  107. package/dist/channels/thread-participants.d.ts +21 -0
  108. package/dist/channels/thread-participants.js +132 -0
  109. package/dist/channels/turn-queue.js +8 -1
  110. package/dist/channels/turn-store.d.ts +1 -1
  111. package/dist/channels/turn-store.js +2 -3
  112. package/dist/cli/add-feishu.d.ts +30 -0
  113. package/dist/cli/add-feishu.js +366 -0
  114. package/dist/cli/add-slack.d.ts +11 -0
  115. package/dist/cli/add-slack.js +199 -0
  116. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  117. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  118. package/dist/cli/commands/add.d.ts +4 -0
  119. package/dist/cli/commands/add.js +191 -60
  120. package/dist/cli/commands/attach.d.ts +82 -0
  121. package/dist/cli/commands/attach.js +563 -0
  122. package/dist/cli/commands/chat.d.ts +1 -0
  123. package/dist/cli/commands/chat.js +12 -7
  124. package/dist/cli/commands/deploy.d.ts +3 -1
  125. package/dist/cli/commands/deploy.js +384 -82
  126. package/dist/cli/commands/dev.js +34 -26
  127. package/dist/cli/commands/fire.js +17 -18
  128. package/dist/cli/commands/info.js +36 -29
  129. package/dist/cli/commands/init.d.ts +1 -1
  130. package/dist/cli/commands/init.js +65 -53
  131. package/dist/cli/commands/invoke.js +11 -9
  132. package/dist/cli/commands/login.js +48 -45
  133. package/dist/cli/commands/models.js +1 -1
  134. package/dist/cli/commands/schedule.js +6 -8
  135. package/dist/cli/commands/start.js +102 -39
  136. package/dist/cli/commands/tool.js +23 -18
  137. package/dist/cli/fail.d.ts +17 -2
  138. package/dist/cli/fail.js +25 -1
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +3 -2
  142. package/dist/cli/models-view.d.ts +21 -0
  143. package/dist/cli/models-view.js +66 -0
  144. package/dist/cli/program.js +135 -56
  145. package/dist/cli/serve.d.ts +60 -20
  146. package/dist/cli/serve.js +252 -47
  147. package/dist/cli/shared.d.ts +40 -7
  148. package/dist/cli/shared.js +180 -38
  149. package/dist/collect.d.ts +14 -3
  150. package/dist/collect.js +24 -0
  151. package/dist/core.d.ts +3 -1
  152. package/dist/core.js +2 -0
  153. package/dist/deploy/agentcore/plan.d.ts +117 -0
  154. package/dist/deploy/agentcore/plan.js +721 -0
  155. package/dist/deploy/agentcore/run.d.ts +73 -0
  156. package/dist/deploy/agentcore/run.js +412 -0
  157. package/dist/deploy/agentcore/zip.d.ts +17 -0
  158. package/dist/deploy/agentcore/zip.js +68 -0
  159. package/dist/deploy/container.d.ts +26 -25
  160. package/dist/deploy/container.js +93 -89
  161. package/dist/deploy/docker/plan.d.ts +3 -3
  162. package/dist/deploy/docker/plan.js +20 -23
  163. package/dist/deploy/fly/plan.d.ts +5 -1
  164. package/dist/deploy/fly/plan.js +39 -26
  165. package/dist/deploy/fly/run.d.ts +17 -5
  166. package/dist/deploy/fly/run.js +52 -7
  167. package/dist/deploy/preflight.d.ts +20 -8
  168. package/dist/deploy/preflight.js +267 -71
  169. package/dist/deploy/railway/plan.d.ts +10 -1
  170. package/dist/deploy/railway/plan.js +53 -20
  171. package/dist/deploy/railway/run.d.ts +13 -5
  172. package/dist/deploy/railway/run.js +22 -7
  173. package/dist/deploy/runner.d.ts +7 -3
  174. package/dist/deploy/runner.js +9 -3
  175. package/dist/deploy/secrets.d.ts +2 -1
  176. package/dist/deploy/secrets.js +23 -3
  177. package/dist/dev-supervisor.d.ts +11 -10
  178. package/dist/dev-supervisor.js +56 -50
  179. package/dist/engines/pi/auth.d.ts +8 -7
  180. package/dist/engines/pi/auth.js +12 -10
  181. package/dist/engines/pi/channel.d.ts +22 -16
  182. package/dist/engines/pi/channel.js +92 -62
  183. package/dist/engines/pi/chat.d.ts +4 -16
  184. package/dist/engines/pi/chat.js +8 -261
  185. package/dist/engines/pi/config.d.ts +20 -55
  186. package/dist/engines/pi/config.js +49 -121
  187. package/dist/engines/pi/create.d.ts +30 -10
  188. package/dist/engines/pi/create.js +33 -26
  189. package/dist/engines/pi/definition.d.ts +7 -26
  190. package/dist/engines/pi/definition.js +8 -54
  191. package/dist/engines/pi/harness.d.ts +49 -0
  192. package/dist/engines/pi/harness.js +90 -2
  193. package/dist/engines/pi/invoke.d.ts +55 -2
  194. package/dist/engines/pi/invoke.js +304 -19
  195. package/dist/engines/pi/login.d.ts +12 -1
  196. package/dist/engines/pi/login.js +16 -4
  197. package/dist/engines/pi/models.d.ts +59 -13
  198. package/dist/engines/pi/models.js +62 -24
  199. package/dist/engines/pi/open.d.ts +114 -0
  200. package/dist/engines/pi/open.js +127 -0
  201. package/dist/engines/pi/session-builder.d.ts +16 -0
  202. package/dist/engines/pi/session-builder.js +308 -0
  203. package/dist/engines/pi/session-control.d.ts +50 -0
  204. package/dist/engines/pi/session-control.js +604 -0
  205. package/dist/engines/pi/sessions.d.ts +17 -2
  206. package/dist/engines/pi/sessions.js +9 -0
  207. package/dist/engines/pi/tool-context.d.ts +18 -11
  208. package/dist/engines/pi/tool-context.js +3 -9
  209. package/dist/engines/pi/tool.d.ts +6 -8
  210. package/dist/engines/pi/tool.js +10 -1
  211. package/dist/engines/pi/wake-tool.d.ts +0 -3
  212. package/dist/engines/pi/wake-tool.js +9 -7
  213. package/dist/env.d.ts +16 -4
  214. package/dist/env.js +43 -5
  215. package/dist/feishu.d.ts +1 -1
  216. package/dist/feishu.js +1 -1
  217. package/dist/host/node.d.ts +25 -8
  218. package/dist/host/node.js +5 -4
  219. package/dist/index.d.ts +1 -0
  220. package/dist/index.js +1 -0
  221. package/dist/lark.d.ts +1 -1
  222. package/dist/lark.js +1 -1
  223. package/dist/loader.d.ts +2 -2
  224. package/dist/loader.js +3 -3
  225. package/dist/log.d.ts +1 -1
  226. package/dist/log.js +1 -1
  227. package/dist/observe.js +3 -0
  228. package/dist/paths.d.ts +138 -0
  229. package/dist/paths.js +326 -0
  230. package/dist/pi.d.ts +6 -5
  231. package/dist/pi.js +4 -3
  232. package/dist/runtime.d.ts +7 -5
  233. package/dist/runtime.js +2 -2
  234. package/dist/scaffold/add-channel.d.ts +26 -9
  235. package/dist/scaffold/add-channel.js +175 -40
  236. package/dist/scaffold/init.d.ts +32 -41
  237. package/dist/scaffold/init.js +161 -185
  238. package/dist/scaffold/templates/env.example +15 -6
  239. package/dist/scaffold/templates/fastagent.config.mjs +5 -4
  240. package/dist/scaffold/templates/gitignore +14 -6
  241. package/dist/scaffold/templates/persona.md +4 -2
  242. package/dist/scaffold/templates/secrets.gitignore +5 -0
  243. package/dist/scaffold/templates.d.ts +1 -7
  244. package/dist/scaffold/templates.js +3 -25
  245. package/dist/scaffold/vendor-skill.d.ts +2 -2
  246. package/dist/scaffold/vendor-skill.js +13 -13
  247. package/dist/schedule/discover.js +4 -4
  248. package/dist/schedule/scheduler.d.ts +40 -1
  249. package/dist/schedule/scheduler.js +89 -56
  250. package/dist/schedule/state.js +1 -1
  251. package/dist/schedule/wake-alarm.d.ts +47 -0
  252. package/dist/schedule/wake-alarm.js +136 -0
  253. package/dist/schedule/wakeups.d.ts +1 -3
  254. package/dist/schedule/wakeups.js +19 -1
  255. package/dist/session-remote.d.ts +53 -0
  256. package/dist/session-remote.js +336 -0
  257. package/dist/session.d.ts +265 -0
  258. package/dist/session.js +37 -0
  259. package/dist/slack.d.ts +2 -0
  260. package/dist/slack.js +2 -0
  261. package/dist/tunnel.d.ts +7 -4
  262. package/dist/tunnel.js +23 -12
  263. package/package.json +22 -7
  264. package/dist/channels/feishu/owned-threads.d.ts +0 -11
  265. package/dist/channels/feishu/owned-threads.js +0 -47
  266. package/dist/channels/feishu/seen.d.ts +0 -5
  267. package/dist/channels/feishu/seen.js +0 -47
  268. package/dist/cli-add-feishu.d.ts +0 -8
  269. package/dist/cli-add-feishu.js +0 -223
  270. package/dist/cli-models.d.ts +0 -11
  271. package/dist/cli-models.js +0 -20
  272. package/dist/engines/pi/workspace.d.ts +0 -56
  273. package/dist/engines/pi/workspace.js +0 -72
  274. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  275. package/dist/scaffold/templates/gitignore.kit +0 -2
  276. package/dist/workspace.d.ts +0 -9
  277. package/dist/workspace.js +0 -45
package/dist/cli/serve.js CHANGED
@@ -1,4 +1,14 @@
1
- import { createInvokeHandler } from "../channels/http.js";
1
+ /**
2
+ * The serving spine shared by `dev` (its worker) and `start`: channel assembly, Node HTTP binding,
3
+ * long-connection lifecycle, scheduler lifecycle, and the optional Cloudflare quick tunnel.
4
+ */
5
+ import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { createStateSync } from "../channels/agentcore-state.js";
8
+ import { agentcoreRoutes, UnknownScheduleError } from "../channels/agentcore.js";
9
+ import { activeWork } from "../channels/busy.js";
10
+ import { controlRoutes } from "../channels/control.js";
11
+ import { INVOKE_EXAMPLE_BODY, createInvokeHandler } from "../channels/http.js";
2
12
  import { text } from "../channels/respond.js";
3
13
  import { loadChannels } from "../engines/pi/channel.js";
4
14
  import { reportModuleLoadFailures } from "../engines/pi/report.js";
@@ -6,15 +16,19 @@ import { parseRouteKey, router, serveNode } from "../host/node.js";
6
16
  import { log } from "../log.js";
7
17
  import { openExternalUrl } from "../open-url.js";
8
18
  import { loadSchedules } from "../schedule/discover.js";
9
- import { createScheduler } from "../schedule/scheduler.js";
19
+ import { createScheduler, fireScheduleOnce } from "../schedule/scheduler.js";
10
20
  import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
11
21
  import { failStartup } from "./fail.js";
12
22
  /**
13
- * The routes this deployment serves: a default `GET /health` plus the workspace's discovered
14
- * `channels/` or the default invoke channel at POST /invoke when none are declared.
23
+ * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
24
+ * POST `/invoke` only when neither a route nor a long-connection channel was declared.
15
25
  */
16
- export async function routesFor(workspaceDir, agent, stateRoot) {
17
- const { routes, collisions, failures } = await loadChannels(workspaceDir, { agent, stateRoot });
26
+ export async function routesFor(agentDir, agent, stateRoot, control, options = {}) {
27
+ const { routes, longConnections, routeChannels, collisions, failures } = await loadChannels(agentDir, {
28
+ agent,
29
+ stateRoot,
30
+ control,
31
+ });
18
32
  for (const c of collisions) {
19
33
  console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
20
34
  }
@@ -23,68 +37,259 @@ export async function routesFor(workspaceDir, agent, stateRoot) {
23
37
  throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
24
38
  `fix it, or rename an intentionally disabled file to *.disabled`);
25
39
  }
26
- const channels = Object.keys(routes).length > 0 ? routes : { "POST /invoke": createInvokeHandler(agent) };
27
- // Add a default GET /health unless a channel already covers it (overlap, not exact-key: an
28
- // any-method `/health` also handles GET, so the built-in steps aside).
29
- const healthCovered = Object.keys(channels).some((k) => {
30
- const e = parseRouteKey(k);
31
- return e.path === "/health" && (e.method === undefined || e.method === "GET");
40
+ const builtinInvoke = options.builtinInvoke !== false && Object.keys(routes).length === 0 && longConnections.length === 0;
41
+ const channels = builtinInvoke ? { "POST /invoke": createInvokeHandler(agent) } : routes;
42
+ const healthCovered = Object.keys(channels).some((key) => {
43
+ const entry = parseRouteKey(key);
44
+ return entry.path === "/health" && (entry.method === undefined || entry.method === "GET");
32
45
  });
33
- return healthCovered ? channels : { "GET /health": () => text("ok\n", 200), ...channels };
46
+ let ready = longConnections.length === 0;
47
+ const health = () => (ready ? text("ok\n", 200) : text("starting\n", 503));
48
+ return {
49
+ routes: healthCovered ? channels : { "GET /health": health, ...channels },
50
+ longConnections,
51
+ routeChannels,
52
+ builtinInvoke,
53
+ markReady() {
54
+ ready = true;
55
+ },
56
+ };
57
+ }
58
+ /**
59
+ * Mount the session control plane (`/control/*`) when the agent enabled it
60
+ * (`config.sessionControl`): merge the bearer-authenticated routes and return an announcer that
61
+ * writes `<stateRoot>/control.json` — `{ url, token }`, 0600 — once the port is known. The file is
62
+ * the LOCAL discovery channel (`fastagent attach`, a local desktop app); filesystem permissions are
63
+ * its trust boundary, and each boot overwrites it with a fresh per-boot token. A user channel
64
+ * colliding on `/control/*` fails startup — the same disposition as a channel-channel collision
65
+ * (routesFor): `sessionControl` is an explicit opt-in, so declaring both is a configuration error,
66
+ * and silently shadowing either side would serve a surface the author didn't write.
67
+ */
68
+ export function mountSessionControl(routes, control, stateRoot, options = {}) {
69
+ if (!control)
70
+ return { routes, announce: () => { } };
71
+ const token = crypto.randomUUID();
72
+ const mounted = controlRoutes(control, { token, agent: options.agent });
73
+ // PATH-level collision, matching the router's semantics (an any-method "/control/dispatch"
74
+ // channel key would dodge an exact-key check yet still shadow the method-qualified control
75
+ // route at match time — the router matches by path first).
76
+ const mountedPaths = new Set(Object.keys(mounted).map((key) => parseRouteKey(key).path));
77
+ const collisions = Object.keys(routes).filter((key) => mountedPaths.has(parseRouteKey(key).path));
78
+ if (collisions.length > 0) {
79
+ throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control plane — ` +
80
+ `rename the channel route or disable sessionControl in fastagent.config`);
81
+ }
82
+ return {
83
+ routes: { ...routes, ...mounted },
84
+ announce: (boundPort) => {
85
+ // The state root normally exists (the opener mkdirs the sessions dir under it), but an
86
+ // external --sessions-dir leaves it uncreated — and announce runs inside serve's listening
87
+ // callback, where a throw is an unhandled rejection, not a one-line startup diagnostic.
88
+ mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
89
+ const path = join(stateRoot, "control.json");
90
+ // Atomic (tmp+rename, the state.ts pattern): attach re-reads this file exactly during the
91
+ // restart window — a torn read would be misdiagnosed as "serve gone".
92
+ const tmp = `${path}.tmp`;
93
+ writeFileSync(tmp, `${JSON.stringify({ url: `http://127.0.0.1:${boundPort}`, token })}\n`, { mode: 0o600 });
94
+ chmodSync(tmp, 0o600); // an existing file keeps its old mode on rewrite — pin it
95
+ renameSync(tmp, path);
96
+ log.info(`[fastagent] session control on /control/* (token in ${path})`);
97
+ // The serve binds ALL interfaces (containers require it), so /control/* is LAN-reachable
98
+ // with the bearer token as the only protection — the tunnel and deploy paths warn loudly,
99
+ // and the LAN path must not be the silent third way past the local trust story.
100
+ log.warn("[fastagent] the port binds all interfaces: /control/* is reachable on your LAN, protected only by " +
101
+ "the bearer token — firewall the port or wrap it for real exposure (docs: design §14)");
102
+ if (options.tunnel) {
103
+ // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC
104
+ // (beyond even the LAN reach the mount already warned about). The operator asked for the tunnel (webhooks), but must not DISCOVER the control
105
+ // plane went public with it — say it loudly.
106
+ log.warn("[fastagent] --tunnel exposes /control/* (steer/abort/set_model) at the public tunnel URL, " +
107
+ "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
108
+ }
109
+ // Best-effort lifecycle end: a clean exit removes the discovery file so a later `attach`
110
+ // fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
111
+ const unlink = () => {
112
+ try {
113
+ rmSync(path, { force: true });
114
+ }
115
+ catch {
116
+ /* the file is advisory — exit must not fail on it */
117
+ }
118
+ };
119
+ // Signal handlers MUST NOT absorb termination: registering any listener disables Node's
120
+ // default kill, so clean up and RE-RAISE. The mechanism: `process.kill` delivery is ASYNC —
121
+ // it lands after the current emit completes, so every listener of this same emit (scheduler
122
+ // stop, tunnel close — regardless of registration order) runs first, and the re-raised
123
+ // signal then hits the default action because each `once` handler is already consumed. When
124
+ // some listener exits the process itself (the tunnel path calls process.exit(0)), the
125
+ // re-raise is harmless redundancy. Without this, the first Ctrl+C would leave the serve
126
+ // alive minus its control.json, and dev's watch restart (SIGTERM → wait for exit → respawn)
127
+ // would hang on a worker that never exits.
128
+ const unlinkAndReraise = (signal) => () => {
129
+ unlink();
130
+ process.kill(process.pid, signal);
131
+ };
132
+ process.once("SIGINT", unlinkAndReraise("SIGINT"));
133
+ process.once("SIGTERM", unlinkAndReraise("SIGTERM"));
134
+ process.once("exit", unlink);
135
+ },
136
+ };
34
137
  }
35
- /** Serve `routes` via the Node host. serveNode owns binding; the CLI owns policy (errors, ready signal, log). */
36
- export function serve(routes, port, onListening) {
37
- serveNode(router(routes), { port }).listening.then((boundPort) => {
38
- process.send?.({ type: "ready", port: boundPort }); // tell the dev supervisor we bound + on which port
39
- log.info(`[fastagent] http channel on :${boundPort}`);
40
- log.info(`[fastagent] routes: ${Object.keys(routes).join(", ") || "(none)"}`);
41
- onListening?.(boundPort);
138
+ /**
139
+ * Mount the AgentCore Runtime adapter (`POST /invocations` + `GET /ping`) over the serving routes —
140
+ * the deployed container's ONLY reachable surface (channels/agentcore.ts). Wired by `start` when
141
+ * `FASTAGENT_AGENTCORE=1` (set by the generated deploy artifacts, never by hand). A channel colliding
142
+ * on either path fails startup, same disposition as the control-plane mount: the adapter's paths are
143
+ * the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
144
+ */
145
+ export function mountAgentcore(routes, options) {
146
+ const { agent, stateRoot, schedules, onStateReady } = options;
147
+ const mounted = agentcoreRoutes({
148
+ routes,
149
+ agent,
150
+ stateRoot,
151
+ isBusy: () => activeWork() > 0,
152
+ // Cross-deploy durability: AgentCore wipes the state mount on every runtime version update, so
153
+ // the state root is restored from (and pushed to) an S3 snapshot through presigned URLs the
154
+ // forwarder mints per envelope. Always wired on this path — the platform gives no other way to
155
+ // keep an agent's memory across a deploy.
156
+ stateSync: createStateSync({ stateRoot }),
157
+ // What separates a forwarder envelope from any IAM principal's InvokeAgentRuntime call. Absent =
158
+ // no forwarder in this topology, so only the public `invoke` kind is servable.
159
+ ingressSecret: process.env.FASTAGENT_INGRESS_SECRET,
160
+ onStateReady,
161
+ fire: schedules.length === 0
162
+ ? undefined
163
+ : (name, slot) => {
164
+ const schedule = schedules.find((s) => s.name === name);
165
+ if (!schedule)
166
+ throw new UnknownScheduleError(name);
167
+ return fireScheduleOnce({ agent, stateRoot, schedule, slot });
168
+ },
169
+ });
170
+ const mountedPaths = new Set(Object.keys(mounted).map((key) => parseRouteKey(key).path));
171
+ const collisions = Object.keys(routes).filter((key) => mountedPaths.has(parseRouteKey(key).path));
172
+ if (collisions.length > 0) {
173
+ throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the AgentCore adapter ` +
174
+ `(/invocations, /ping) — rename the channel route`);
175
+ }
176
+ return { ...routes, ...mounted };
177
+ }
178
+ /**
179
+ * Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
180
+ * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
181
+ * the sole clean-shutdown command.
182
+ */
183
+ export function serve(surface, port, onListening) {
184
+ const hosted = serveNode(router(surface.routes), { port });
185
+ const abort = new AbortController();
186
+ let stopping = false;
187
+ const stop = (exitCode) => {
188
+ if (stopping)
189
+ return;
190
+ stopping = true;
191
+ abort.abort();
192
+ const deadline = setTimeout(() => process.exit(exitCode), 1_000);
193
+ void hosted
194
+ .close()
195
+ .catch(() => { })
196
+ .finally(() => {
197
+ clearTimeout(deadline);
198
+ process.exit(exitCode);
199
+ });
200
+ // Preserve the existing no-drain shutdown contract: stop accepting first, then cut active streams.
201
+ hosted.closeAllConnections();
202
+ };
203
+ process.once("SIGINT", () => stop(0));
204
+ process.once("SIGTERM", () => stop(0));
205
+ hosted.listening.then(async (boundPort) => {
206
+ try {
207
+ const runs = surface.longConnections.map((connection) => {
208
+ const run = connection.connect(abort.signal);
209
+ if (run === null ||
210
+ typeof run !== "object" ||
211
+ typeof run.ready?.then !== "function" ||
212
+ typeof run.closed?.then !== "function") {
213
+ throw new Error(`${connection.name} connect(signal) must return { ready: Promise, closed: Promise }`);
214
+ }
215
+ void run.closed.then(() => {
216
+ if (!abort.signal.aborted)
217
+ failStartup(new Error(`${connection.name} closed unexpectedly`));
218
+ }, (error) => {
219
+ if (!abort.signal.aborted)
220
+ failStartup(new Error(`${connection.name} failed: ${String(error)}`));
221
+ });
222
+ return { connection, run };
223
+ });
224
+ await Promise.all(runs.map(async ({ connection, run }) => {
225
+ await run.ready;
226
+ if (!abort.signal.aborted)
227
+ log.info(`[fastagent] long connection ready: ${connection.name}`);
228
+ }));
229
+ // Shutdown raced startup: a pre-ready abort settles `ready` as cancellation, not readiness —
230
+ // stop() already owns the exit; don't mark ready or report a surface being torn down.
231
+ if (abort.signal.aborted)
232
+ return;
233
+ surface.markReady();
234
+ process.send?.({
235
+ type: "ready",
236
+ port: boundPort,
237
+ routeChannels: surface.routeChannels,
238
+ });
239
+ log.info(`[fastagent] http host on :${boundPort}`);
240
+ log.info(`[fastagent] routes: ${Object.keys(surface.routes).join(", ") || "(none)"}`);
241
+ if (surface.longConnections.length > 0) {
242
+ log.info(`[fastagent] long connections: ${surface.longConnections.map((connection) => connection.name).join(", ")}`);
243
+ }
244
+ if (surface.builtinInvoke) {
245
+ log.info(`[fastagent] try it: curl -s localhost:${boundPort}/invoke -X POST -H 'content-type: application/json' -d '${INVOKE_EXAMPLE_BODY}'`);
246
+ }
247
+ onListening?.(boundPort);
248
+ }
249
+ catch (error) {
250
+ abort.abort();
251
+ const closing = hosted.close().catch(() => { });
252
+ hosted.closeAllConnections();
253
+ await closing;
254
+ failStartup(error);
255
+ }
42
256
  }, (error) => {
43
257
  if (error.code === "EADDRINUSE")
44
258
  failStartup(new Error(`port ${port} is already in use; choose another with --port`));
45
259
  failStartup(new Error(`cannot bind http channel on :${port}: ${error.message}`));
46
260
  });
47
261
  }
48
- /**
49
- * Start a Cloudflare tunnel + announce/register webhooks once the server is bound — unless this is a
50
- * watch-supervisor worker, where the supervisor owns the long-lived tunnel so the public URL survives
51
- * reloads.
52
- */
53
- export function maybeTunnel(workspaceDir, boundPort, tunnel) {
262
+ /** Start a Cloudflare tunnel for route channels only. */
263
+ export function maybeTunnel(agentDir, routeChannels, boundPort, tunnel, stateRoot) {
54
264
  if (!tunnel || process.env.FASTAGENT_DEV_WORKER === "1")
55
265
  return;
56
- void startCloudflareTunnel(boundPort).then((t) => {
57
- if (!t)
266
+ void startCloudflareTunnel(boundPort).then((instance) => {
267
+ if (!instance)
58
268
  return;
59
- void announceWebhooks(workspaceDir, t.url, { openUrl: openExternalUrl });
60
- // Single-process (start / --no-watch): close the tunnel on exit (watch mode's supervisor owns its own).
61
- const cleanup = () => {
62
- t.close();
63
- process.exit(0);
64
- };
269
+ void announceWebhooks(agentDir, instance.url, { openUrl: openExternalUrl, routeChannels, stateRoot });
270
+ const cleanup = () => instance.close();
65
271
  process.once("SIGINT", cleanup);
66
272
  process.once("SIGTERM", cleanup);
67
273
  });
68
274
  }
69
275
  /**
70
- * Load and start the workspace's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
71
- * there are static schedules OR `selfSchedule` is on (the scheduler also polls the agent's self-scheduled
72
- * wake-ups, which the built-in `wake` tool creates only when opted in). Shares the SAME (trace-wrapped)
73
- * agent the routes serve, so a scheduled turn is observed like any other. Best-effort stop on exit; dev's
74
- * watch restart re-reads schedules with the worker (schedules are a code input). Single-process.
276
+ * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
277
+ * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
278
+ * loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
279
+ * ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
75
280
  */
76
- export async function startSchedules(workspaceDir, agent, stateRoot, selfSchedule) {
77
- const { schedules, failures } = await loadSchedules(workspaceDir).catch(failStartup);
281
+ export async function startSchedules(agentDir, agent, stateRoot, selfSchedule, options = {}) {
282
+ const { schedules, failures } = await loadSchedules(agentDir).catch(failStartup);
78
283
  reportModuleLoadFailures(failures);
79
- // Nothing to run when there are neither static `schedules/` nor self-scheduling (the `wake` tool, and
80
- // thus any wake-up to poll, is mounted only when config.selfSchedule is on) — skip the poller entirely.
81
284
  if (schedules.length === 0 && !selfSchedule)
82
- return;
83
- const scheduler = createScheduler({ agent, stateRoot, schedules });
285
+ return schedules;
286
+ const scheduler = createScheduler({ agent, stateRoot, schedules, externalClock: options.externalClock });
84
287
  scheduler.start();
85
- if (schedules.length > 0)
86
- log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}`);
288
+ if (schedules.length > 0) {
289
+ log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}${options.externalClock ? " (external clock — no resident cron timers)" : ""}`);
290
+ }
87
291
  const stop = () => scheduler.stop();
88
292
  process.once("SIGINT", stop);
89
293
  process.once("SIGTERM", stop);
294
+ return schedules;
90
295
  }
@@ -1,3 +1,16 @@
1
+ import { type LoginIO, type LoginMethod, type LoginResult } from "../engines/pi/login.ts";
2
+ /**
3
+ * The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). Hand-spaced
4
+ * labels drift out of alignment the moment a longer one appears — which is exactly what happened when
5
+ * `workspace:` joined `config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
6
+ * stdout DATA (pipeable, its own label set, its own width), not a log line — the shared thing is the
7
+ * policy (pad, never hand-space), not a constant.
8
+ */
9
+ export declare function reportLine(label: string, value: string): void;
10
+ /** The workspace hint under the `agent:`/`workspace:` pair, when there is one ({@link workspaceHint}):
11
+ * you pointed at the agent, and the project around it is probably what you meant. A hint, so it renders
12
+ * as one and is silent otherwise — `dev` and `start` both print the pair, so both ask for it. */
13
+ export declare function reportWorkspaceHint(hint: string | undefined): void;
1
14
  /** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
2
15
  export declare function isInteractive(): boolean;
3
16
  /**
@@ -10,15 +23,35 @@ export declare function parsePort(value: string | undefined, source: string, fro
10
23
  /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
11
24
  export declare function reportAuth(modelSpec: string, authPath: string): Promise<void>;
12
25
  /**
13
- * First-run model resolution for the serving commands. When no model is set (flag/env/config), and
14
- * we're on a TTY, pick one from the providers the user is logged into and persist the choice. A no-op
15
- * when a model is already set; on a non-TTY (CI/deploy), or with `--no-input`, or with nothing
16
- * configured it stays silent and lets the opener raise its clear "missing model" error. The pick is
17
- * exported to FASTAGENT_MODEL so a spawned `dev` worker inherits it, and best-effort written back to
18
- * the config so the next run is quiet.
26
+ * First-run model resolution for every assembly command (dev/start/invoke/fire/chat/deploy): ONE
27
+ * funnel, no dead ends. When no model is set (flag/env/config) and we're on a TTY, show the FULL
28
+ * catalog annotated per provider ready (with the credential source, so which account pays is
29
+ * visible at the decision point) or login-required and, when the choice needs auth, run the login
30
+ * flow INLINE instead of exiting with "run `fastagent login` and come back". A no-op when a model is
31
+ * already set; on a non-TTY (CI, a piped stdin), with `--no-input`, on cancel, or on a failed login
32
+ * it stays quiet and lets the caller raise its own clear error (`missing model`, or deploy's
33
+ * model-travel gate). The pick is exported to FASTAGENT_MODEL so a spawned `dev` worker inherits it,
34
+ * and best-effort written back to the config so the next run is quiet. `agentDir` is the resolved
35
+ * AGENT DIR (resolvePlacement().agentDir) — config and auth both live there.
19
36
  */
20
- export declare function resolveFirstRunModel(workspaceDir: string, options?: {
37
+ export declare function resolveFirstRunModel(agentDir: string, options?: {
21
38
  model?: string;
22
39
  authPath?: string;
23
40
  input?: boolean;
24
41
  }): Promise<void>;
42
+ /**
43
+ * Interactive login with the api_key quick-fail probe closed into a LOOP: a definitively rejected key
44
+ * (HTTP 401) deletes the bad credential and RE-PROMPTS immediately — the user's hands are on the
45
+ * keyboard NOW; parking the failure for a later `fastagent login` would waste that. The loop exits on
46
+ * a verified/unverifiable key (kept), an OAuth login (completing the flow already proved the
47
+ * credential), or cancel (LoginCancelled propagates to the caller's cancel policy). Used by both the
48
+ * `login` command and the first-run picker's inline login.
49
+ */
50
+ export declare function loginWithKeyCheck(provider: string | undefined, authPath: string, spec?: string, seams?: {
51
+ flow?: (io: LoginIO, options: {
52
+ provider?: string;
53
+ authPath?: string;
54
+ method?: LoginMethod;
55
+ }) => Promise<LoginResult>;
56
+ verify?: (provider: string, authPath: string, spec?: string) => Promise<"ok" | "rejected" | "unknown">;
57
+ }): Promise<LoginResult>;