@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
@@ -7,55 +7,73 @@
7
7
  * Read-only on the definition; the only writes are generated artifacts (never clobbered without
8
8
  * --force). `--run` drives the target CLI instead of printing.
9
9
  */
10
- import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
11
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
11
13
  import { basename, dirname, join, resolve } from "node:path";
12
14
  import { registerFeishuWebhook } from "../../channels/feishu/register-webhook.js";
15
+ import { readSlackBotAuthEnv } from "../../channels/slack/bot-auth.js";
16
+ import { registerSlackWebhook } from "../../channels/slack/register-webhook.js";
13
17
  import { registerTelegramWebhook } from "../../channels/telegram/register-webhook.js";
14
- import { isGeneratedDockerfile } from "../../deploy/container.js";
18
+ import { FORWARDER_FILE, TEMPLATE_FILE, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../deploy/agentcore/plan.js";
19
+ import { deployAgentcoreRun } from "../../deploy/agentcore/run.js";
20
+ import { isGeneratedDockerfile, isGeneratedDockerignore } from "../../deploy/container.js";
15
21
  import { composeHasTunnelService, dockerWebhookPaths, isGeneratedCompose, planDockerDeploy, toDockerProjectName, } from "../../deploy/docker/plan.js";
16
22
  import { deployDockerRun } from "../../deploy/docker/run.js";
17
- import { parseFlyAppName, parseFlyMinMachines, parseFlyRegion, planFlyDeploy, toFlyAppName, } from "../../deploy/fly/plan.js";
23
+ import { isGeneratedFlyToml, parseFlyAppName, parseFlyMinMachines, parseFlyRegion, planFlyDeploy, toFlyAppName, } from "../../deploy/fly/plan.js";
18
24
  import { deployFlyRun } from "../../deploy/fly/run.js";
19
25
  import { preflightDeploy } from "../../deploy/preflight.js";
20
- import { planRailwayDeploy } from "../../deploy/railway/plan.js";
26
+ import { dockerfilePathVar, isGeneratedRailwayJson, planRailwayDeploy } from "../../deploy/railway/plan.js";
21
27
  import { deployRailwayRun } from "../../deploy/railway/run.js";
22
28
  import { spawnRunner } from "../../deploy/runner.js";
23
29
  import { assembleSecrets } from "../../deploy/secrets.js";
24
30
  import { loadDotEnv } from "../../env.js";
25
- import { loadConfig, resolveAgentDir, resolveAuthPathOverride, resolveModelSpec } from "../../engines/pi/config.js";
31
+ import { loadConfig, resolveModelSpec } from "../../engines/pi/config.js";
32
+ import { resolveStateRoot } from "../../paths.js";
33
+ import { loadSchedules } from "../../schedule/discover.js";
26
34
  import { installProxyFetch } from "../../proxy.js";
27
35
  import { openExternalUrl } from "../../open-url.js";
28
- import { exists } from "../../scaffold/init.js";
36
+ import { exists } from "../../paths.js";
29
37
  import { announceWebhooks } from "../../tunnel.js";
30
- import { failStartup, failUsage } from "../fail.js";
38
+ import { failStartup, failUsage, placementOrExit } from "../fail.js";
39
+ import { resolveFirstRunModel } from "../shared.js";
31
40
  export async function runDeploy(host, dirArg, opts) {
32
- const target = resolve(dirArg);
41
+ // ONE deploy semantic: bake the WORKSPACE (WYSIWYG). Artifacts land under the agent dir
42
+ // (`fastagent/`) plus the one workspace-root `.dockerignore` the packers require; host CLIs run
43
+ // from the workspace, which is the build context.
44
+ const placement = placementOrExit(resolve(dirArg));
45
+ const { agentDir, workspace } = placement;
33
46
  if (opts.tunnel && host !== "docker") {
34
47
  // A flag/host combination the parser cannot see (host is an argument) — usage class, exit 2.
35
48
  failUsage(`deploy stopped: --tunnel is supported only by the local Docker target`);
36
49
  }
37
- loadDotEnv(target); // a custom provider/tool may read a key at config load
50
+ loadDotEnv(agentDir); // a custom provider/tool may read a key at config load
38
51
  installProxyFetch(); // post-deploy channel API calls must honor HTTP(S)_PROXY under Node
39
- const { config } = await loadConfig(target).catch(failStartup);
40
- const agentDir = resolveAgentDir(target, config);
52
+ // First-run funnel, FULL picker: the write-back lands the model in fastagent.config.* — exactly what
53
+ // the model-travel gate below requires (--model/env don't reach the deployed box) — and an inline
54
+ // login stores the credential `--run` then carries. Runs BEFORE loadConfig; the read-back sees the
55
+ // rewritten file because loadConfig cache-busts on mtime (a failed write-back still gates, correctly).
56
+ await resolveFirstRunModel(agentDir, { model: opts.model, authPath: opts.authPath, input: opts.input });
57
+ const { config } = await loadConfig(agentDir).catch(failStartup);
41
58
  const modelSpec = resolveModelSpec(opts.model, config);
42
59
  // The host-neutral pre-flight (model-travel gate, channel discovery, model-auth probe, container facts +
43
60
  // their warnings) lives in deploy/preflight.ts — testable in isolation. The CLI prints its messages and
44
61
  // stops on its gate; the host branch below adds only the host-specific artifacts + runbook + run drive.
45
62
  const pre = await preflightDeploy({
46
- target,
47
- agentDir,
63
+ placement,
48
64
  config,
49
65
  modelSpec,
50
66
  run: !!opts.run,
51
67
  force: !!opts.force,
52
- authPathOverride: resolveAuthPathOverride(opts.authPath),
68
+ externalClock: host === "agentcore", // cron rides EventBridge there — the resident-host notes don't apply
69
+ authPathFlag: opts.authPath, // flag > FASTAGENT_AUTH_PATH > default — resolved by preflight (one owner)
53
70
  }).catch(failStartup);
54
71
  if (!pre.ok)
55
72
  failStartup(new Error(`deploy stopped: ${pre.gate}`));
56
73
  for (const m of pre.messages)
57
74
  console.error(`[fastagent] ${m.level}: ${m.text}`);
58
- const { channels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets } = pre;
75
+ const { channels, routeChannels, longConnectionChannels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets, } = pre;
76
+ const hasDeclaredChannels = routeChannels.length + longConnectionChannels.length > 0;
59
77
  // Docker: one app service + loopback port + state volume. `--tunnel` shapes the generated topology
60
78
  // with an optional Quick Tunnel service; `--run` alone decides whether Docker receives side effects.
61
79
  if (host === "docker") {
@@ -65,33 +83,48 @@ export async function runDeploy(host, dirArg, opts) {
65
83
  if (opts.intoLinked) {
66
84
  console.error(`[fastagent] warn: --into-linked is Railway-only — ignored for local Docker`);
67
85
  }
68
- const projectName = toDockerProjectName(basename(target));
69
- const dockerPlan = (tunnel) => planDockerDeploy({ projectName, port, modelAuth, channels, tunnel, extraSecrets, ...container });
70
- const requestedTunnel = !!opts.tunnel;
86
+ const projectName = toDockerProjectName(basename(workspace));
87
+ const dockerPlan = (tunnel) => planDockerDeploy({
88
+ projectName,
89
+ port,
90
+ modelAuth,
91
+ channels,
92
+ longConnectionChannels,
93
+ tunnel,
94
+ extraSecrets,
95
+ ...container,
96
+ });
97
+ const requestedTunnel = !!opts.tunnel && (!hasDeclaredChannels || routeChannels.length > 0);
98
+ if (opts.tunnel && hasDeclaredChannels && routeChannels.length === 0) {
99
+ console.error(`[fastagent] note: --tunnel skipped — every channel uses a long connection`);
100
+ }
71
101
  let plan = dockerPlan(requestedTunnel);
72
102
  // An existing Compose file is authoritative: shape its comparison/runbook from the topology on disk,
73
103
  // regardless of the current flag. `--force` is the explicit reset to the requested generated shape.
74
- const composeFile = join(target, plan.composePath);
104
+ const composeFile = join(workspace, plan.composePath);
75
105
  let keptWithoutRequestedTunnel = false;
76
- if (!opts.force && (await exists(composeFile))) {
77
- const existingHasTunnel = composeHasTunnelService(await readFile(composeFile, "utf8"));
106
+ // Same ownership question as fly.toml: a hand-owned compose file survives --force, so the plan must
107
+ // describe the topology that will actually be there.
108
+ const composeText = await readFile(composeFile, "utf8").catch(() => undefined);
109
+ if (composeText !== undefined && (!opts.force || !isGeneratedCompose(composeText))) {
110
+ const existingHasTunnel = composeHasTunnelService(composeText);
78
111
  plan = dockerPlan(existingHasTunnel);
79
112
  keptWithoutRequestedTunnel = requestedTunnel && !existingHasTunnel;
80
113
  }
81
- await writeArtifacts(target, plan.artifacts, {
114
+ await writeArtifacts(workspace, plan.artifacts, {
82
115
  force: !!opts.force,
83
- neverForce: container.kitDir ? [".dockerignore"] : [],
84
116
  });
85
117
  if (opts.run) {
86
118
  return runDeployDocker({
87
- target,
88
119
  agentDir,
120
+ workspace,
89
121
  composeFile: plan.composePath,
90
122
  port,
91
123
  requireTunnel: requestedTunnel,
92
124
  modelAuth,
93
125
  authPath,
94
126
  channels,
127
+ longConnectionChannels,
95
128
  extraSecrets,
96
129
  });
97
130
  }
@@ -111,23 +144,146 @@ export async function runDeploy(host, dirArg, opts) {
111
144
  }
112
145
  // Railway service names are project-scoped (not globally unique like a Fly app); slug the dir
113
146
  // basename so a name with spaces/odd chars can't break the `railway add --service <name>` command.
114
- const serviceName = basename(target)
147
+ const serviceName = basename(workspace)
115
148
  .replace(/[^a-zA-Z0-9-]+/g, "-")
116
149
  .replace(/^-+|-+$/g, "") || "agent";
117
- const plan = planRailwayDeploy({ serviceName, modelAuth, channels, extraSecrets, hasTimeTriggers, ...container });
118
- await writeArtifacts(target, plan.artifacts, {
150
+ const plan = planRailwayDeploy({
151
+ serviceName,
152
+ modelAuth,
153
+ channels,
154
+ longConnectionChannels,
155
+ extraSecrets,
156
+ hasTimeTriggers,
157
+ ...container,
158
+ });
159
+ await writeArtifacts(workspace, plan.artifacts, {
119
160
  force: !!opts.force,
120
- neverForce: container.kitDir ? [".dockerignore"] : [],
121
161
  });
122
162
  if (opts.run) {
163
+ // The BUILD entry is guaranteed by the RAILWAY_DOCKERFILE_PATH service variable the runner sets
164
+ // (Railway's documented non-root-Dockerfile route), and Railway's default restart policy already
165
+ // equals the file's ON_FAILURE — the dashboard-only Config-as-code pointer only adds the /health
166
+ // deploy gate (boot-crash visibility), so it is an OPTIONAL note, not a gate.
167
+ console.error(`[fastagent] note: optional — point the service at fastagent/railway.json (Service → Settings → ` +
168
+ `Config-as-code, dashboard-only) so the /health healthcheck marks a boot-crashing deploy as FAILED; ` +
169
+ `the build already uses fastagent/Dockerfile via the RAILWAY_DOCKERFILE_PATH variable`);
123
170
  return runDeployRailway({
124
- target,
171
+ agentDir,
172
+ workspace,
125
173
  name: serviceName,
126
174
  modelAuth,
127
175
  authPath,
128
176
  channels,
177
+ longConnectionChannels,
129
178
  extraSecrets,
130
179
  intoLinked: !!opts.intoLinked,
180
+ dockerfilePath: dockerfilePathVar(pre.container.agentPrefix),
181
+ });
182
+ }
183
+ console.log(plan.runbook.join("\n"));
184
+ return;
185
+ }
186
+ // AgentCore: one CloudFormation stack (runtime + forwarder Lambda + EventBridge schedules); no
187
+ // public URL and no resident process — see deploy/agentcore/plan.ts for the topology decisions.
188
+ if (host === "agentcore") {
189
+ if (opts.stop || opts.scaleToZero === false) {
190
+ console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — AgentCore's idle/lifetime policy lives in ` +
191
+ `the template's LifecycleConfiguration.`);
192
+ }
193
+ if (opts.intoLinked) {
194
+ console.error(`[fastagent] warn: --into-linked is Railway-only — ignored for AgentCore`);
195
+ }
196
+ // Long-connection channels are STRUCTURALLY unsupported: the connection is the ingress, and a
197
+ // reclaimed session has nothing to wake it — events in the gap are silently lost. `--run` gates
198
+ // (deploying a channel that can't connect); generate-only warns and prints the runbook.
199
+ if (longConnectionChannels.length > 0) {
200
+ const msg = `long-connection channel (${longConnectionChannels.join(", ")}) cannot run on AgentCore — there is no ` +
201
+ `resident process to hold the connection, and nothing wakes a reclaimed session. Switch the channel ` +
202
+ `to webhook mode (its events then ride the forwarder like every other channel).`;
203
+ if (opts.run)
204
+ failStartup(new Error(`deploy stopped: ${msg}`));
205
+ console.error(`[fastagent] warn: ${msg}`);
206
+ }
207
+ // selfSchedule is fully supported: pending wake-ups are mirrored into one-shot EventBridge
208
+ // schedules via the forwarder (the wake-alarm mechanism — see deploy/agentcore/plan.ts).
209
+ // Schedules feed EventBridge rules — parsed facts (cron/tz), not just file names, so a bad file
210
+ // must surface here (a schedule silently missing its rule would never fire).
211
+ const loaded = await loadSchedules(agentDir).catch(failStartup);
212
+ if (loaded.failures.length > 0) {
213
+ failStartup(new Error(`deploy stopped: cannot load schedules: ${loaded.failures.map((x) => `${x.label}: ${x.message}`).join("; ")}`));
214
+ }
215
+ const acName = basename(workspace)
216
+ .toLowerCase()
217
+ .replace(/[^a-z0-9-]+/g, "-")
218
+ .replace(/^-+|-+$/g, "") || "agent";
219
+ // Every derived AWS name embeds acName; the tightest ceiling is the Lambda function name
220
+ // (`fastagent-<name>-forwarder` ≤ 64 chars). Gate the base instead of silently truncating —
221
+ // truncation would break the redeploy identity (a renamed stack starts blank state).
222
+ if (acName.length > 40) {
223
+ failStartup(new Error(`deploy stopped: the directory name maps to "${acName}" (${acName.length} chars) — AWS resource ` +
224
+ `names derived from it exceed their limits past 40 chars. Deploy from a shorter directory name.`));
225
+ }
226
+ const plan = planAgentcoreDeploy({
227
+ name: acName,
228
+ modelAuth,
229
+ channels,
230
+ routeChannels,
231
+ extraSecrets,
232
+ schedules: loaded.schedules.map((s) => ({ name: s.name, cron: s.cron, tz: s.tz })),
233
+ selfSchedule: !!config.selfSchedule,
234
+ ...container,
235
+ });
236
+ for (const u of plan.untranslatableSchedules) {
237
+ // Same discipline as Fly's kept-toml time-trigger gate: a deploy whose schedule silently never
238
+ // fires is worse than a stopped deploy — nothing fails visibly when the instant passes.
239
+ const msg = `schedule "${u.name}" cannot be expressed as an EventBridge rule — ${u.reason}`;
240
+ if (opts.run)
241
+ failStartup(new Error(`deploy stopped: ${msg}`));
242
+ console.error(`[fastagent] warn: ${msg} — it will NOT fire on this deployment`);
243
+ }
244
+ // Host capability limit, stated at plan time. GitHub's own webhook contract is 25 MiB, but a
245
+ // Lambda Function URL request caps at 6 MB — so on this host a large payload cannot arrive at
246
+ // all. Better a sentence here than an opaque 502 the first time someone pushes a big diff.
247
+ if (channels.includes("github")) {
248
+ console.error(`[fastagent] note: on AgentCore a webhook body is capped at ~${Math.round(MAX_WEBHOOK_BODY_BYTES / (1 << 20))} MiB ` +
249
+ `(the forwarder's Function URL limit); the GitHub channel accepts 25 MiB on a resident host, so the largest ` +
250
+ `payloads are rejected here rather than delivered`);
251
+ }
252
+ // The template IS the topology (EventBridge rules, wake wiring, secrets) — a kept generated
253
+ // template that no longer matches the definition would deploy a stack silently missing the
254
+ // difference (a new schedule with no rule never fires: the exact miss the gate above stops).
255
+ // A hand-written template (marker removed) is the operator's own — kept, never gated.
256
+ const templateArtifact = plan.artifacts.find((a) => a.path.endsWith(TEMPLATE_FILE));
257
+ const templateHome = join(workspace, templateArtifact?.path ?? TEMPLATE_FILE);
258
+ if (!opts.force && templateArtifact && (await exists(templateHome))) {
259
+ const existing = await readFile(templateHome, "utf8");
260
+ if (isGeneratedAgentcoreTemplate(existing) && existing !== templateArtifact.content) {
261
+ const msg = `${templateArtifact.path} no longer matches this definition (channels/schedules/selfSchedule changed) — ` +
262
+ `the kept template would silently drop the difference. Pass --force to regenerate (hand edits are lost), ` +
263
+ `or delete the file.`;
264
+ if (opts.run)
265
+ failStartup(new Error(`deploy stopped: ${msg}`));
266
+ console.error(`[fastagent] warn: ${msg}`);
267
+ }
268
+ }
269
+ await writeArtifacts(workspace, plan.artifacts, {
270
+ force: !!opts.force,
271
+ alwaysWrite: [`${container.agentPrefix}${FORWARDER_FILE}`],
272
+ });
273
+ if (opts.run) {
274
+ return runDeployAgentcore({
275
+ agentDir,
276
+ workspace,
277
+ agentPrefix: container.agentPrefix,
278
+ name: acName,
279
+ modelAuth,
280
+ authPath,
281
+ channels,
282
+ extraSecrets,
283
+ selfSchedule: !!config.selfSchedule,
284
+ // Same predicate the template uses for the forwarder resource — kept in one expression so
285
+ // the run path and the topology cannot disagree about whether a forwarder exists.
286
+ needsForwarder: routeChannels.length > 0 || loaded.schedules.length > 0 || !!config.selfSchedule,
131
287
  });
132
288
  }
133
289
  console.log(plan.runbook.join("\n"));
@@ -148,20 +304,26 @@ export async function runDeploy(host, dirArg, opts) {
148
304
  // and the runbook reads its `app=` (Fly app names are globally unique, so the basename guess may be
149
305
  // taken and the user renamed it). --force: the template is authoritative — the WHOLE fly.toml resets
150
306
  // (app→basename, region→iad, vm→defaults), so we do NOT round-trip `app` and warn that hand edits go.
151
- // Kit layout: fly.toml lives under the kit (agent/fly.toml) — the host repo's own fly.toml (if any)
152
- // belongs to the host's product deploy and is never read or written here.
153
- const flyTomlPath = container.kitDir ? join(target, container.kitDir, "fly.toml") : join(target, "fly.toml");
154
- const flyTomlExists = await exists(flyTomlPath);
155
- const keptApp = flyTomlExists && !opts.force ? parseFlyAppName(await readFile(flyTomlPath, "utf8")) : undefined;
156
- const appName = keptApp ?? toFlyAppName(basename(target));
307
+ // fly.toml lives in the agent dir (fastagent/fly.toml) — the workspace's own
308
+ // fly.toml (if any) belongs to the host's product deploy and is never read or written here.
309
+ const flyTomlPath = join(agentDir, "fly.toml");
310
+ const flyToml = await readFile(flyTomlPath, "utf8").catch(() => undefined);
311
+ // Every decision below turns on ONE question — will `writeArtifacts` keep this file? and the answer is
312
+ // OWNERSHIP, not the flag: `--force` resets a fly.toml we generated and leaves a hand-written one alone.
313
+ // Keying these on `--force` alone meant a forced deploy of a hand-owned fly.toml took the app name from
314
+ // the directory basename (shipping that file at a DIFFERENT app than it declares) and skipped the
315
+ // scale-to-zero gate below (a schedules deploy that silently sleeps). Read once, decide once.
316
+ const flyTomlKept = flyToml !== undefined && (!opts.force || !isGeneratedFlyToml(flyToml));
317
+ const keptApp = flyTomlKept ? parseFlyAppName(flyToml) : undefined;
318
+ const appName = keptApp ?? toFlyAppName(basename(workspace));
157
319
  if (keptApp)
158
320
  console.error(`[fastagent] app: ${keptApp} (from fly.toml)`);
159
- if (flyTomlExists && opts.force) {
321
+ if (flyToml !== undefined && !flyTomlKept) {
160
322
  console.error(`[fastagent] warn: --force resets fly.toml to defaults (app, region, vm) — re-apply any hand edits`);
161
323
  }
162
324
  // Autostop flags shape the GENERATED fly.toml only. In KEEP mode (fly.toml exists, no --force) it is
163
325
  // not rewritten, so the flags would silently do nothing — surface that instead of a confusing no-op.
164
- if (flyTomlExists && !opts.force && (opts.stop || opts.scaleToZero === false)) {
326
+ if (flyTomlKept && (opts.stop || opts.scaleToZero === false)) {
165
327
  console.error(`[fastagent] warn: --stop/--no-scale-to-zero only shape a freshly generated fly.toml — yours exists and ` +
166
328
  `was kept. Edit auto_stop_machines/min_machines_running in fly.toml, or pass --force to regenerate.`);
167
329
  }
@@ -171,14 +333,16 @@ export async function runDeploy(host, dirArg, opts) {
171
333
  // Under `--run` this is a GATE (same discipline as the model-travel gate): a full deploy whose schedules
172
334
  // silently never fire is worse than a crash-loop — nothing fails visibly when a cron instant passes on a
173
335
  // sleeping machine, and unlike github's min=0 there is no legitimate trade to accept here.
174
- if (flyTomlExists && !opts.force && hasTimeTriggers) {
175
- const min = parseFlyMinMachines(await readFile(flyTomlPath, "utf8"));
336
+ if (flyTomlKept && (hasTimeTriggers || longConnectionChannels.length > 0)) {
337
+ const min = parseFlyMinMachines(flyToml);
176
338
  if ((min ?? 0) === 0) {
177
339
  // undefined = the line is absent — Fly's platform default for min_machines_running is 0, so a
178
340
  // hand-written fly.toml without the line scales to zero exactly like an explicit 0.
341
+ const reason = hasTimeTriggers
342
+ ? `schedules/self-scheduling need a running machine (no external wake-up)`
343
+ : `long-connection channel (${longConnectionChannels.join(", ")}) needs an always-on outbound connection`;
179
344
  const msg = `your kept fly.toml scales to zero (min_machines_running = ${min ?? "absent → platform default 0"}), but ` +
180
- `schedules/self-scheduling need a running machine (no external wake-up). Set min_machines_running = 1, ` +
181
- `or pass --force to regenerate.`;
345
+ `${reason}. Set min_machines_running = 1, or pass --force to regenerate.`;
182
346
  if (opts.run)
183
347
  failStartup(new Error(`deploy stopped: ${msg}`));
184
348
  console.error(`[fastagent] warn: ${msg}`);
@@ -189,70 +353,124 @@ export async function runDeploy(host, dirArg, opts) {
189
353
  port,
190
354
  modelAuth,
191
355
  channels,
356
+ longConnectionChannels,
192
357
  extraSecrets,
193
358
  hasTimeTriggers,
194
359
  ...container,
195
360
  autostop: opts.stop ? "stop" : "suspend",
196
361
  scaleToZero: opts.scaleToZero !== false,
197
362
  });
198
- await writeArtifacts(target, plan.artifacts, {
363
+ await writeArtifacts(workspace, plan.artifacts, {
199
364
  force: !!opts.force,
200
- neverForce: container.kitDir ? [".dockerignore"] : [],
201
365
  });
202
- if (opts.run)
203
- return runDeployFly({ target, appName, modelAuth, authPath, channels, flyTomlPath, extraSecrets });
366
+ if (opts.run) {
367
+ return runDeployFly({
368
+ agentDir,
369
+ workspace,
370
+ agentPrefix: container.agentPrefix,
371
+ appName,
372
+ modelAuth,
373
+ authPath,
374
+ channels,
375
+ longConnectionChannels,
376
+ flyTomlPath,
377
+ extraSecrets,
378
+ });
379
+ }
204
380
  console.log(plan.runbook.join("\n"));
205
381
  }
206
382
  /**
207
- * Write each generated artifact. An existing file is KEPT unless --force deploy NEVER clobbers a file
208
- * without it (no silent data loss). A Dockerfile/Compose file WE generated is definition-derived, so a
209
- * KEPT one that no longer matches what deploy would generate now (config/channel/lockfile/version drift
210
- * OR the user's own edits, which we can't distinguish) is flagged stale; --force regenerates it. A
211
- * hand-written Dockerfile/Compose/ignore or fly.toml's app+region state is simply kept.
383
+ * Write each generated artifact, under ONE ownership rule: **deploy only ever overwrites its own
384
+ * output.** Each generated file opens with a marker, so a file on disk is either OURS (a previous
385
+ * `deploy` wrote it) or the author's and `--force` means "my generated artifact is authoritative",
386
+ * which never licenses clobbering a file we did not write. To hand a path back to fastagent, delete it.
387
+ *
388
+ * That single rule replaced a second mechanism (a per-host list of paths exempt from `--force`), which
389
+ * only described one shape: with the agent inside the workspace, the root `.dockerignore` is the
390
+ * WORKSPACE's file and was listed; with the agent flat, the list was empty — so `--force` would have
391
+ * overwritten a hand-written root `Dockerfile` in a repository `init --flat` had explicitly adopted.
392
+ * Ownership is a property of the file, not of where the agent sits.
393
+ *
394
+ * A KEPT file of OURS that no longer matches what deploy would generate now (config/channel/lockfile/
395
+ * version drift — or the author's edits, which we cannot tell apart) is flagged stale; `--force`
396
+ * regenerates that one.
212
397
  */
398
+ /** Did fastagent generate the file at `path`? ONE table for all five artifact kinds: a kind missing from
399
+ * it is permanently classified as the author's, which silently turns `--force` into a no-op for that
400
+ * file (`fly.toml` and `railway.json` were, until the marker predicates below existed). */
401
+ function isOurArtifact(path, content) {
402
+ if (path.endsWith("Dockerfile"))
403
+ return isGeneratedDockerfile(content);
404
+ if (path.endsWith(".dockerignore"))
405
+ return isGeneratedDockerignore(content);
406
+ if (path.endsWith("fastagent.compose.yml"))
407
+ return isGeneratedCompose(content);
408
+ if (path.endsWith("fly.toml"))
409
+ return isGeneratedFlyToml(content);
410
+ if (path.endsWith("railway.json"))
411
+ return isGeneratedRailwayJson(content);
412
+ if (path.endsWith(TEMPLATE_FILE))
413
+ return isGeneratedAgentcoreTemplate(content);
414
+ return false;
415
+ }
213
416
  async function writeArtifacts(target, artifacts, options) {
214
417
  for (const a of artifacts) {
215
418
  const abs = join(target, a.path);
216
- // Host-owned paths (the root .dockerignore in the agentDir layout): --force means "MY generated
217
- // artifact is authoritative", which never licenses clobbering the HOST's file — keep it always.
218
- if (options.neverForce?.includes(a.path) && (await exists(abs))) {
219
- console.error(`[fastagent] kept ${a.path} — the host repo's own file (never overwritten, even with --force); ` +
220
- `see the preflight warnings for what it must contain`);
419
+ // Pure build output, not operator-owned configuration. It must track the generated template/runbook.
420
+ if (options.alwaysWrite?.includes(a.path)) {
421
+ await mkdir(dirname(abs), { recursive: true });
422
+ await writeFile(abs, a.content);
423
+ console.error(`[fastagent] wrote ${a.path}`);
221
424
  continue;
222
425
  }
223
- if (!options.force && (await exists(abs))) {
224
- const existing = await readFile(abs, "utf8");
225
- const generatedDrift = (a.path.endsWith("Dockerfile") && isGeneratedDockerfile(existing)) ||
226
- (a.path.endsWith("fastagent.compose.yml") && isGeneratedCompose(existing));
227
- if (generatedDrift && existing !== a.content) {
228
- console.error(`[fastagent] kept ${a.path} — it no longer matches what deploy would generate (config changed, or ` +
229
- `you edited it); pass --force to regenerate.`);
230
- }
231
- else {
232
- console.error(`[fastagent] kept ${a.path} (exists — pass --force to overwrite)`);
233
- }
426
+ const existing = (await exists(abs)) ? await readFile(abs, "utf8") : undefined;
427
+ const ours = existing !== undefined && isOurArtifact(a.path, existing);
428
+ if (existing !== undefined && !ours) {
429
+ // Only the `.dockerignore` has content checks in preflight — pointing at "the preflight warnings"
430
+ // for the other kinds would send the reader looking for output that is never printed.
431
+ console.error(`[fastagent] kept ${a.path} — not generated by fastagent, so --force does not touch it ` +
432
+ `(delete it to let deploy own the path)` +
433
+ (a.path.endsWith(".dockerignore")
434
+ ? `; see the preflight warnings for what it must exclude`
435
+ : a.path.endsWith("Dockerfile")
436
+ ? `; deploy still assumes it listens on $PORT and runs \`fastagent start /app\``
437
+ : ``));
438
+ continue;
439
+ }
440
+ if (existing !== undefined && !options.force) {
441
+ console.error(existing !== a.content
442
+ ? `[fastagent] kept ${a.path} — it no longer matches what deploy would generate (config changed, or ` +
443
+ `you edited it); pass --force to regenerate.`
444
+ : `[fastagent] kept ${a.path} (unchanged)`);
234
445
  continue;
235
446
  }
236
- await mkdir(dirname(abs), { recursive: true }); // kit-layout artifacts live under agent/
447
+ await mkdir(dirname(abs), { recursive: true }); // artifacts live under fastagent/
237
448
  await writeFile(abs, a.content);
238
449
  console.error(`[fastagent] wrote ${a.path}`);
239
450
  }
240
451
  }
452
+ function deployEnvironment(agentDir, channels) {
453
+ if (!channels.includes("slack"))
454
+ return process.env;
455
+ const latest = readSlackBotAuthEnv(join(resolveStateRoot(agentDir), "channels", "slack", "bot-auth.json"));
456
+ return { ...process.env, ...latest };
457
+ }
241
458
  /**
242
459
  * `deploy docker --run`: carry local credentials into Compose's child environment, then reconcile the
243
460
  * user-owned local topology. Docker owns container/network/volume lifecycle. A Compose tunnel service,
244
461
  * when present, yields an ephemeral URL that reuses the same webhook announcer as `dev --tunnel`.
245
462
  */
246
463
  async function runDeployDocker(params) {
247
- const { target, agentDir, composeFile, port, requireTunnel, modelAuth, authPath, channels, extraSecrets } = params;
464
+ const { agentDir, workspace, composeFile, port, requireTunnel, modelAuth, authPath, channels, longConnectionChannels, extraSecrets, } = params;
248
465
  const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
249
466
  modelAuth,
250
467
  authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
251
468
  channels,
469
+ longConnectionChannels,
252
470
  extraSecrets,
253
- env: process.env,
471
+ env: deployEnvironment(agentDir, channels),
254
472
  });
255
- const outcome = await deployDockerRun({ composeFile, port, secrets, missingSecrets, needsModelCredential, requireTunnel }, spawnRunner("docker", target), (message) => console.error(`[fastagent] ${message}`));
473
+ const outcome = await deployDockerRun({ composeFile, port, secrets, missingSecrets, needsModelCredential, requireTunnel }, spawnRunner("docker", workspace), (message) => console.error(`[fastagent] ${message}`));
256
474
  if (!outcome.ok)
257
475
  failStartup(new Error(`deploy stopped: ${outcome.gate}`));
258
476
  const compose = `docker compose -f ${composeFile}`;
@@ -263,12 +481,16 @@ async function runDeployDocker(params) {
263
481
  // Docker Desktop commonly injects a host proxy. The Quick Tunnel hostname may be resolvable only
264
482
  // through it, exactly like provider/channel APIs; use the same Node dispatcher as dev/start/login.
265
483
  installProxyFetch();
266
- await announceWebhooks(agentDir, outcome.tunnelUrl, { openUrl: openExternalUrl });
484
+ await announceWebhooks(agentDir, outcome.tunnelUrl, {
485
+ openUrl: openExternalUrl,
486
+ routeChannels: channels.filter((kind) => !longConnectionChannels.includes(kind)),
487
+ stateRoot: resolveStateRoot(agentDir),
488
+ });
267
489
  console.error(`[fastagent] note: Quick Tunnel URLs are ephemeral — after the tunnel container/Docker daemon ` +
268
490
  `restarts, re-run this deploy so webhooks receive the new URL`);
269
491
  return;
270
492
  }
271
- const paths = dockerWebhookPaths(channels);
493
+ const paths = dockerWebhookPaths(channels.filter((kind) => !longConnectionChannels.includes(kind)));
272
494
  if (paths.length > 0) {
273
495
  console.error(`[fastagent] note: public ingress is operator-owned — configure your tunnel/proxy, then wire the ` +
274
496
  `default webhook path(s): ${paths.join(", ")} (or your remapped channel routes)`);
@@ -279,11 +501,11 @@ async function runDeployDocker(params) {
279
501
  * from the local env — the model key (env auth) or the whole auth.json as a `FASTAGENT_AUTH_SEED` seed
280
502
  * (OAuth/stored auth: the deployed box materializes it onto the /data volume on first boot, so a
281
503
  * personal deploy runs on the SAME subscription) plus channel secrets — then runs the flyctl steps
282
- * behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the agent).
504
+ * behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the whole workspace).
283
505
  */
284
506
  async function runDeployFly(params) {
285
- const { target, appName, modelAuth, authPath, channels, flyTomlPath, extraSecrets } = params;
286
- const fly = spawnRunner("fly", target);
507
+ const { agentDir, workspace, agentPrefix, appName, modelAuth, authPath, channels, longConnectionChannels, flyTomlPath, extraSecrets, } = params;
508
+ const fly = spawnRunner("fly", workspace);
287
509
  // Fail fast if flyctl is absent (spawn ENOENT → 127), with the install link — not a confusing auth gate.
288
510
  if ((await fly(["version"], { capture: true })).code === 127) {
289
511
  failStartup(new Error(`flyctl not found — install it: https://fly.io/docs/flyctl/install, then re-run`));
@@ -293,29 +515,108 @@ async function runDeployFly(params) {
293
515
  modelAuth,
294
516
  authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
295
517
  channels,
518
+ longConnectionChannels,
296
519
  extraSecrets,
297
- env: process.env,
520
+ env: deployEnvironment(agentDir, channels),
298
521
  });
299
522
  // Model credential has its OWN remediation (login), distinct from a missing secret's (.env) — gate it
300
523
  // here, not through missingSecrets, so the message isn't a contradictory mash of both.
301
524
  if (needsModelCredential) {
302
525
  failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
303
526
  }
304
- const outcome = await deployFlyRun({ appName, region, secrets, missingSecrets, channels, flyConfig: "fly.toml" }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind));
527
+ const outcome = await deployFlyRun({
528
+ appName,
529
+ region,
530
+ secrets,
531
+ missingSecrets,
532
+ channels,
533
+ longConnectionChannels,
534
+ flyConfig: `${agentPrefix}fly.toml`,
535
+ dockerfile: `${agentPrefix}Dockerfile`,
536
+ }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind), (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }));
305
537
  if (!outcome.ok)
306
538
  failStartup(new Error(`deploy stopped: ${outcome.gate}`));
307
539
  console.error(`[fastagent] deployed → https://${appName}.fly.dev`);
308
540
  }
541
+ /**
542
+ * `deploy agentcore --run`: drive aws + docker to completion. Mirrors {@link runDeployFly} — same
543
+ * credential carry via {@link assembleSecrets}, same runner seam (spawned `aws` + `docker`, cwd = the
544
+ * workspace so the build context is the agent). The AgentCore-specific sequence (identity → buildx →
545
+ * ECR → CloudFormation → outputs → webhooks) lives in {@link deployAgentcoreRun}; the params temp
546
+ * file (secret values off argv) is created here — 0600, removed after the run either way.
547
+ */
548
+ async function runDeployAgentcore(params) {
549
+ const { agentDir, workspace, agentPrefix, name, modelAuth, authPath, channels, extraSecrets, selfSchedule } = params;
550
+ const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
551
+ modelAuth,
552
+ authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
553
+ channels,
554
+ extraSecrets,
555
+ env: deployEnvironment(agentDir, channels),
556
+ });
557
+ // The wake-alarm shared secret (container ↔ forwarder). Minted fresh each run — both sides receive
558
+ // the SAME parameter, so rotation is atomic; it never needs to be remembered locally.
559
+ if (selfSchedule)
560
+ secrets.FASTAGENT_WAKE_SECRET = crypto.randomUUID();
561
+ // The forwarder→runtime ingress secret: what makes an envelope the forwarder's rather than any IAM
562
+ // principal's. Minted fresh each run — both sides receive the SAME parameter, so rotation is atomic.
563
+ if (params.needsForwarder)
564
+ secrets.FASTAGENT_INGRESS_SECRET = crypto.randomUUID();
565
+ if (needsModelCredential) {
566
+ failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
567
+ }
568
+ // The params temp dir holds the ONE file carrying secret values (file:// parameter-overrides —
569
+ // never argv); 0700/0600 and removed after the run, success or gate.
570
+ const paramsDir = await mkdtemp(join(tmpdir(), "fastagent-agentcore-"));
571
+ try {
572
+ const outcome = await deployAgentcoreRun({
573
+ name,
574
+ templatePath: `${agentPrefix}${TEMPLATE_FILE}`,
575
+ dockerfilePath: agentPrefix ? `${agentPrefix}Dockerfile` : undefined,
576
+ tag: new Date()
577
+ .toISOString()
578
+ .replace(/[-:.TZ]/g, "")
579
+ .slice(0, 14),
580
+ region: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION,
581
+ secrets,
582
+ missingSecrets,
583
+ channels,
584
+ // Same predicate the template uses: the forwarder exists for route channels, cron schedules
585
+ // or selfSchedule — and it is what mints the state snapshot's presigned URLs.
586
+ needsForwarder: params.needsForwarder,
587
+ }, spawnRunner("aws", workspace), spawnRunner("docker", workspace), (m) => console.error(`[fastagent] ${m}`), async (content) => {
588
+ const path = join(paramsDir, "params.json");
589
+ await writeFile(path, content, { mode: 0o600 });
590
+ await chmod(path, 0o600);
591
+ return path;
592
+ }, async (bytes) => {
593
+ const path = join(paramsDir, "forwarder.zip");
594
+ await writeFile(path, bytes);
595
+ return path;
596
+ }, (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind), (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }));
597
+ if (!outcome.ok)
598
+ failStartup(new Error(`deploy stopped: ${outcome.gate}`));
599
+ console.error(`[fastagent] deployed → ${outcome.runtimeArn}`);
600
+ if (outcome.url)
601
+ console.error(`[fastagent] webhook ingress → ${outcome.url}`);
602
+ console.error(`[fastagent] invoke: aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn ${outcome.runtimeArn} \\\n` +
603
+ ` --runtime-session-id "my-conversation-000000000000000000" \\\n` +
604
+ ` --payload '{"kind":"invoke","session":"cli","text":"hello"}' --cli-binary-format raw-in-base64-out /dev/stdout`);
605
+ }
606
+ finally {
607
+ await rm(paramsDir, { recursive: true, force: true });
608
+ }
609
+ }
309
610
  /**
310
611
  * `deploy railway --run`: drive the railway CLI to completion. Mirrors {@link runDeployFly} — same
311
612
  * credential carry (env key OR the OAuth auth.json as `FASTAGENT_AUTH_SEED`) via {@link assembleSecrets},
312
- * same runner seam (spawned `railway`, cwd = the workspace so `railway up`'s upload is the agent). The
613
+ * same runner seam (spawned `railway`, cwd = the workspace so `railway up`'s upload is the whole workspace). The
313
614
  * Railway-specific sequence (linked-check → init/add/volume when fresh → variables → up → domain →
314
615
  * webhook) lives in {@link deployRailwayRun}; see there for why Railway differs from Fly.
315
616
  */
316
617
  async function runDeployRailway(params) {
317
- const { target, name, modelAuth, authPath, channels, extraSecrets, intoLinked } = params;
318
- const railway = spawnRunner("railway", target);
618
+ const { agentDir, workspace, name, modelAuth, authPath, channels, longConnectionChannels, extraSecrets, intoLinked, dockerfilePath, } = params;
619
+ const railway = spawnRunner("railway", workspace);
319
620
  // Fail fast if the railway CLI is absent (spawn ENOENT → 127), with the install link.
320
621
  if ((await railway(["--version"], { capture: true })).code === 127) {
321
622
  failStartup(new Error(`railway CLI not found — install it: https://docs.railway.com/guides/cli, then re-run`));
@@ -324,14 +625,15 @@ async function runDeployRailway(params) {
324
625
  modelAuth,
325
626
  authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
326
627
  channels,
628
+ longConnectionChannels,
327
629
  extraSecrets,
328
- env: process.env,
630
+ env: deployEnvironment(agentDir, channels),
329
631
  });
330
632
  // Model credential has its OWN remediation (login), distinct from a missing secret's (.env).
331
633
  if (needsModelCredential) {
332
634
  failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
333
635
  }
334
- const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind));
636
+ const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, longConnectionChannels, intoLinked, dockerfilePath }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind), (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }));
335
637
  if (!outcome.ok)
336
638
  failStartup(new Error(`deploy stopped: ${outcome.gate}`));
337
639
  console.error(`[fastagent] deployed → ${outcome.url}`);