@fastagent-sh/fastagent 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/atomic-write.d.ts +11 -7
  2. package/dist/atomic-write.js +16 -11
  3. package/dist/channels/agentcore-limits.d.ts +9 -0
  4. package/dist/channels/agentcore-limits.js +9 -0
  5. package/dist/channels/agentcore-service.d.ts +12 -11
  6. package/dist/channels/agentcore-service.js +14 -18
  7. package/dist/channels/agentcore.d.ts +11 -19
  8. package/dist/channels/agentcore.js +14 -7
  9. package/dist/channels/control.d.ts +50 -12
  10. package/dist/channels/control.js +239 -96
  11. package/dist/channels/discover.d.ts +26 -4
  12. package/dist/channels/discover.js +12 -23
  13. package/dist/channels/feishu/feishu-api.js +5 -8
  14. package/dist/channels/feishu/feishu.js +35 -18
  15. package/dist/channels/kit/attachment-path.d.ts +12 -0
  16. package/dist/channels/kit/attachment-path.js +43 -0
  17. package/dist/channels/kit/context-buffer.d.ts +9 -0
  18. package/dist/channels/kit/context-buffer.js +11 -0
  19. package/dist/channels/kit/signature.d.ts +12 -0
  20. package/dist/channels/kit/signature.js +17 -0
  21. package/dist/channels/kit/stop-command.js +3 -3
  22. package/dist/channels/kit/tasks.d.ts +1 -1
  23. package/dist/channels/kit/tasks.js +10 -4
  24. package/dist/channels/kit/turn-store.d.ts +19 -3
  25. package/dist/channels/kit/turn-store.js +29 -2
  26. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  27. package/dist/channels/slack/invoke-turn.js +5 -1
  28. package/dist/channels/slack/onboard.js +5 -5
  29. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  30. package/dist/channels/slack/onboarding-state.js +13 -20
  31. package/dist/channels/slack/register-webhook.js +1 -1
  32. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  33. package/dist/channels/slack/slack-api.js +22 -54
  34. package/dist/channels/slack/slack.js +13 -13
  35. package/dist/channels/telegram/parse.d.ts +21 -1
  36. package/dist/channels/telegram/parse.js +65 -11
  37. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  38. package/dist/channels/telegram/telegram-api.js +5 -6
  39. package/dist/channels/telegram/telegram.d.ts +2 -2
  40. package/dist/channels/telegram/telegram.js +57 -69
  41. package/dist/cli/add-slack.js +6 -5
  42. package/dist/cli/commands/add.js +1 -5
  43. package/dist/cli/commands/attach.d.ts +2 -4
  44. package/dist/cli/commands/attach.js +17 -15
  45. package/dist/cli/commands/deploy.d.ts +50 -5
  46. package/dist/cli/commands/deploy.js +183 -134
  47. package/dist/cli/commands/dev.js +4 -31
  48. package/dist/cli/commands/info.js +1 -2
  49. package/dist/cli/commands/init.js +1 -2
  50. package/dist/cli/commands/start.js +31 -41
  51. package/dist/cli/serve.d.ts +12 -21
  52. package/dist/cli/serve.js +20 -25
  53. package/dist/cli/shared.d.ts +40 -10
  54. package/dist/cli/shared.js +51 -4
  55. package/dist/deploy/agentcore/logs.d.ts +2 -2
  56. package/dist/deploy/agentcore/logs.js +2 -2
  57. package/dist/deploy/agentcore/plan.d.ts +12 -10
  58. package/dist/deploy/agentcore/plan.js +39 -31
  59. package/dist/deploy/agentcore/run.d.ts +5 -4
  60. package/dist/deploy/agentcore/run.js +13 -36
  61. package/dist/deploy/channel-ingress.d.ts +73 -0
  62. package/dist/deploy/channel-ingress.js +101 -0
  63. package/dist/deploy/docker/plan.d.ts +3 -7
  64. package/dist/deploy/docker/plan.js +4 -15
  65. package/dist/deploy/docker/run.d.ts +18 -1
  66. package/dist/deploy/docker/run.js +10 -0
  67. package/dist/deploy/fly/plan.d.ts +4 -5
  68. package/dist/deploy/fly/plan.js +7 -22
  69. package/dist/deploy/fly/run.d.ts +9 -23
  70. package/dist/deploy/fly/run.js +32 -68
  71. package/dist/deploy/preflight.d.ts +6 -7
  72. package/dist/deploy/preflight.js +26 -22
  73. package/dist/deploy/railway/plan.d.ts +4 -5
  74. package/dist/deploy/railway/plan.js +11 -26
  75. package/dist/deploy/railway/run.d.ts +9 -9
  76. package/dist/deploy/railway/run.js +40 -40
  77. package/dist/deploy/runner.js +22 -2
  78. package/dist/deploy/secrets.d.ts +18 -9
  79. package/dist/deploy/secrets.js +64 -12
  80. package/dist/dev-supervisor.js +3 -4
  81. package/dist/engines/pi/agent-session-factory.js +20 -39
  82. package/dist/engines/pi/auth.js +43 -43
  83. package/dist/engines/pi/config.d.ts +1 -1
  84. package/dist/engines/pi/create.d.ts +14 -2
  85. package/dist/engines/pi/create.js +2 -2
  86. package/dist/engines/pi/definition.d.ts +1 -1
  87. package/dist/engines/pi/definition.js +3 -4
  88. package/dist/engines/pi/login.js +32 -16
  89. package/dist/engines/pi/open.d.ts +1 -2
  90. package/dist/engines/pi/open.js +1 -1
  91. package/dist/engines/pi/search-tools.js +1 -1
  92. package/dist/engines/pi/service.d.ts +1 -1
  93. package/dist/engines/pi/service.js +8 -0
  94. package/dist/engines/pi/session-builder.js +6 -33
  95. package/dist/engines/pi/session-control.d.ts +19 -6
  96. package/dist/engines/pi/session-control.js +565 -470
  97. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  98. package/dist/engines/pi/session-inheritance.js +95 -76
  99. package/dist/engines/pi/session-markers.d.ts +48 -0
  100. package/dist/engines/pi/session-markers.js +59 -0
  101. package/dist/engines/pi/session-settings.d.ts +4 -4
  102. package/dist/engines/pi/session-settings.js +7 -4
  103. package/dist/engines/pi/session-store.d.ts +83 -13
  104. package/dist/engines/pi/session-store.js +408 -55
  105. package/dist/engines/pi/tool-context.d.ts +21 -8
  106. package/dist/engines/pi/tool-context.js +29 -4
  107. package/dist/engines/pi/tool.js +2 -2
  108. package/dist/env.js +1 -2
  109. package/dist/loader.d.ts +49 -7
  110. package/dist/loader.js +79 -18
  111. package/dist/paths.d.ts +19 -0
  112. package/dist/paths.js +27 -2
  113. package/dist/scaffold/add-channel.js +2 -2
  114. package/dist/scaffold/init.js +7 -2
  115. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  116. package/dist/schedule/discover.js +3 -15
  117. package/dist/schedule/wake-alarm.d.ts +13 -7
  118. package/dist/schedule/wake-alarm.js +88 -46
  119. package/dist/schedule/wakeups.d.ts +1 -1
  120. package/dist/schedule/wakeups.js +10 -7
  121. package/dist/service.d.ts +1 -2
  122. package/dist/service.js +27 -5
  123. package/dist/session-remote.d.ts +7 -2
  124. package/dist/session-remote.js +162 -92
  125. package/dist/session.d.ts +226 -92
  126. package/dist/session.js +61 -23
  127. package/dist/telegram.d.ts +1 -1
  128. package/dist/telegram.js +1 -1
  129. package/dist/tunnel.d.ts +18 -3
  130. package/dist/tunnel.js +32 -40
  131. package/package.json +1 -1
@@ -2,29 +2,24 @@
2
2
  * `fastagent start [dir]`: run the agent in production posture — the SAME assembly as dev (your
3
3
  * directory is the agent), just no file-watching. No build step: start reads the definition directly.
4
4
  */
5
- import { mkdir, writeFile } from "node:fs/promises";
6
5
  import { dirname, resolve } from "node:path";
7
- import { authSeedBytes, collectAuthSeed } from "../../deploy/fly/run.js";
6
+ import { writeFileAtomic } from "../../atomic-write.js";
7
+ import { authSeedBytes, collectAuthSeed } from "../../deploy/secrets.js";
8
8
  import { loadDotEnv } from "../../env.js";
9
9
  import { resolveAuthPath, resolveSessionsDirOverride } from "../../engines/pi/config.js";
10
- import { resolveSecretsDir, workspaceHint } from "../../paths.js";
11
- import { isUnderDir } from "../../paths.js";
12
- import { reportFindingsIfChanged, reportToolCollisions } from "../../engines/pi/report.js";
13
- import { reportModuleLoadFailures } from "../../log.js";
14
- import { CODING_TOOL_NAMES } from "../../engines/pi/create.js";
10
+ import { SECRET_FILE_MODE, ensureSecretsDir, resolveSecretsDir, isUnderDir, exists } from "../../paths.js";
11
+ import { log, setLogLevel } from "../../log.js";
15
12
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
16
13
  import { mountAgentService } from "../../service.js";
17
- import { log, setLogLevel } from "../../log.js";
18
14
  import { logAgentLoop } from "../../observe.js";
19
15
  import { installProxyFetch } from "../../proxy.js";
20
- import { exists } from "../../paths.js";
21
16
  import { bindAddress } from "../../bind.js";
22
17
  import { isAgentcoreRuntime, mountAgentcoreService } from "../../channels/agentcore-service.js";
23
- import { createWakeAlarmSink, reconcileWakeAlarms } from "../../schedule/wake-alarm.js";
18
+ import { createWakeAlarmSink } from "../../schedule/wake-alarm.js";
24
19
  import { setWakeupsSink } from "../../schedule/wakeups.js";
25
20
  import { failStartup, placementOrExit } from "../fail.js";
26
21
  import { SHUTDOWN_GRACE_MS, assertTunnelBindable, maybeTunnel, reportServing, serve } from "../serve.js";
27
- import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
22
+ import { parseBind, parsePort, reportAssembly, resolveFirstRunModel } from "../shared.js";
28
23
  export async function runStart(dirArg, opts) {
29
24
  const dir = resolve(dirArg);
30
25
  // Flag validation first: a bad --port is a USAGE error (exit 2), and reporting it must not depend on
@@ -43,26 +38,15 @@ export async function runStart(dirArg, opts) {
43
38
  // The same opener dev uses (single assembly source), just no watch.
44
39
  const sessionsDirOverride = resolveSessionsDirOverride(opts.sessionsDir);
45
40
  const opened = await openStartDir(dir, opts, sessionsDirOverride);
46
- const { agent, definition, agentDir, workspace, config, modelSpec, stateRoot, sessionsDir, authPath, toolNames, deferredToolNames, toolCollisions, toolFailures, } = opened;
47
- reportLine("agent", agentDir);
48
- reportLine("workspace", workspace);
49
- reportWorkspaceHint(workspaceHint({ agentDir, workspace }));
50
- reportLine("model", `${modelSpec}${config.thinkingLevel ? ` (thinking: ${config.thinkingLevel})` : ""}`);
51
- await reportAuth(agentDir, modelSpec, authPath);
52
- reportLine("context", definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
53
- if (definition.persona)
54
- reportLine("persona", "persona.md");
55
- reportLine("skills", definition.skills.map((s) => s.name).join(", ") || "(none)");
56
- reportLine("codingTools", CODING_TOOL_NAMES.join(", "));
57
- if (toolNames.length > 0)
58
- reportLine("tools", toolNames.join(", "));
59
- if (deferredToolNames.length > 0) {
60
- reportLine("deferred", `${deferredToolNames.join(", ")} (activated via search_tools)`);
61
- }
62
- reportToolCollisions(toolCollisions);
63
- reportModuleLoadFailures(toolFailures);
64
- reportLine("state", stateRoot);
65
- reportLine("sessions", sessionsDir);
41
+ const { agent, agentDir, config, stateRoot, sessionsDir } = opened;
42
+ // The same report `dev` prints; `state:`/`sessions:` are start's own extras, and the persistence
43
+ // notes below are why (see reportAssembly on the asymmetry).
44
+ await reportAssembly(opened, {
45
+ afterTools: [
46
+ ["state", stateRoot],
47
+ ["sessions", sessionsDir],
48
+ ],
49
+ });
66
50
  // State defaults under the agent dir, which a redeploy may replace wholesale. Gate on where the
67
51
  // state root ACTUALLY resolved (inside the agent dir?), not on the raw env var: an empty
68
52
  // `FASTAGENT_STATE_DIR=""` reads as unset (resolveStateRoot) and still lands in-agent, so a raw
@@ -81,7 +65,6 @@ export async function runStart(dirArg, opts) {
81
65
  log.info(`[fastagent] note: secrets (.env, rotated auth.json) live under the definition dir; point ` +
82
66
  `FASTAGENT_SECRETS_DIR at a persistent volume so a redeploy that replaces the dir does not wipe them.`);
83
67
  }
84
- reportFindingsIfChanged(definition.dir, definition);
85
68
  // Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
86
69
  const traced = logAgentLoop(agent);
87
70
  // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
@@ -119,12 +102,6 @@ export async function runStart(dirArg, opts) {
119
102
  // and replays it next start (turn-store.ts, L1 durable execution, at-least-once); HTTP and other
120
103
  // channels have no such layer, so their in-flight turns are still lost (the asker re-invokes).
121
104
  }
122
- /**
123
- * Materialize `FASTAGENT_AUTH_SEED` (base64 of an auth.json, set by `deploy --run`) into the
124
- * writable secrets dir ONCE — only when the seed is set AND the auth file is absent, so a refreshed
125
- * volume copy is never clobbered by the stale seed. Lets a deploy carry the operator's local
126
- * OAuth/API credential so the box runs on the SAME subscription. No-op locally (the seed is unset).
127
- */
128
105
  /** The opener, kept as its own call so `opened` can also feed the shared assembly below. */
129
106
  function openStartDir(dir, opts, sessionsDir) {
130
107
  return createPiAgentFromDir(dir, {
@@ -134,14 +111,25 @@ function openStartDir(dir, opts, sessionsDir) {
134
111
  serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
135
112
  }).catch(failStartup);
136
113
  }
114
+ /**
115
+ * Materialize `FASTAGENT_AUTH_SEED` (base64 of an auth.json, set by `deploy --run`) into the
116
+ * writable secrets dir ONCE — only when the seed is set AND the auth file is absent, so a refreshed
117
+ * volume copy is never clobbered by the stale seed. Lets a deploy carry the operator's local
118
+ * OAuth/API credential so the box runs on the SAME subscription. No-op locally (the seed is unset).
119
+ */
137
120
  async function maybeSeedAuth(authPath) {
138
121
  // collectAuthSeed: the seed may arrive CHUNKED (FASTAGENT_AUTH_SEED + _2…) on hosts with a small
139
122
  // env-value max length (AgentCore); single-var hosts are unchanged.
140
123
  const bytes = authSeedBytes(collectAuthSeed(process.env), await exists(authPath));
141
124
  if (!bytes)
142
125
  return;
143
- await mkdir(dirname(authPath), { recursive: true });
144
- await writeFile(authPath, bytes);
126
+ await ensureSecretsDir(dirname(authPath));
127
+ // Same spelling as the credential store: the mode is applied before the content is reachable, so
128
+ // the seed can never be published at some other file's looser permissions. It does NOT close the
129
+ // gap between the `exists` check and the write — the rename would overwrite a file created in
130
+ // between, against this function's absent-only promise. That window needs two `start` processes on
131
+ // one secrets dir, which no supported deployment runs (one container, one volume).
132
+ writeFileAtomic(authPath, bytes, SECRET_FILE_MODE);
145
133
  log.info(`[fastagent] seeded ${authPath} from FASTAGENT_AUTH_SEED (first boot)`);
146
134
  }
147
135
  /**
@@ -160,5 +148,7 @@ function armWakeAlarms(stateRoot) {
160
148
  const sink = createWakeAlarmSink({ secret });
161
149
  setWakeupsSink(sink);
162
150
  log.info(`[fastagent] wake alarms: EventBridge-backed via the forwarder`);
163
- return () => reconcileWakeAlarms(stateRoot, sink);
151
+ // Boot reconcile: pending wake-ups may exist while their alarms were lost (a deploy replaced the
152
+ // forwarder, a sink call failed). The sink re-reads the store, so a bare notification is enough.
153
+ return () => sink(stateRoot);
164
154
  }
@@ -1,15 +1,5 @@
1
1
  import type { ChannelHandler } from "../channel.ts";
2
2
  import type { AgentService } from "../service.ts";
3
- /**
4
- * Mount the session control plane (`/control/*`) when the agent enabled it
5
- * (`config.sessionControl`): merge the bearer-authenticated routes and return an announcer that
6
- * writes `<stateRoot>/control.json` — `{ url, token }`, 0600 — once the port is known. The file is
7
- * the LOCAL discovery channel (`fastagent attach`, a local desktop app); filesystem permissions are
8
- * its trust boundary, and each boot overwrites it with a fresh per-boot token. A user channel
9
- * colliding on `/control/*` fails startup — the same disposition as a channel-channel collision
10
- * (routesFor): `sessionControl` is an explicit opt-in, so declaring both is a configuration error,
11
- * and silently shadowing either side would serve a surface the author didn't write.
12
- */
13
3
  /**
14
4
  * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
15
5
  * (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
@@ -20,6 +10,12 @@ import type { AgentService } from "../service.ts";
20
10
  * came from config is broken runtime configuration (1).
21
11
  */
22
12
  export declare function assertTunnelBindable(host: string | undefined, tunnel: boolean, source: "flag" | "config"): void;
13
+ /**
14
+ * The "we are serving" report: the supervisor message `dev`'s watcher waits for, the addresses, and
15
+ * what mounted. One function because both commands must say the same thing at the same moment —
16
+ * after readiness, never at socket bind.
17
+ */
18
+ export declare function reportServing(service: AgentService, host: string | undefined, boundPort: number): void;
23
19
  /**
24
20
  * The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
25
21
  * ONE function because they are one message — they were two, and `--bind` updated the first while the
@@ -29,21 +25,16 @@ export declare function assertTunnelBindable(host: string | undefined, tunnel: b
29
25
  * A wildcard bind is every interface, and naming one address there would understate it — but the curl
30
26
  * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
31
27
  */
32
- /**
33
- * The "we are serving" report: the supervisor message `dev`'s watcher waits for, the addresses, and
34
- * what mounted. One function because both commands must say the same thing at the same moment —
35
- * after readiness, never at socket bind.
36
- */
37
- export declare function reportServing(service: AgentService, host: string | undefined, boundPort: number): void;
38
28
  export declare function readyAddressLines(host: string | undefined, boundPort: number, builtinInvoke: boolean): string[];
39
- /**
40
- * Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
41
- * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
42
- * the sole clean-shutdown command. `host` unset binds all interfaces.
43
- */
44
29
  /** What the CLI gives a service to stop in, and the hard exit that follows it. The order matters:
45
30
  * a forced exit before the service answers would report a clean shutdown over a stuck channel. */
46
31
  export declare const SHUTDOWN_GRACE_MS = 800;
32
+ /**
33
+ * Bind HTTP and report ready — but only once the SERVICE is, which is not the same moment: a bound
34
+ * socket is not a serving agent while a declared long-connection channel is still dialling, so this
35
+ * awaits `hooks.ready` (mountAgentService owns the connections themselves) before announcing
36
+ * anything. Signals are the sole clean-shutdown command; `host` unset binds all interfaces.
37
+ */
47
38
  export declare function serve(handler: ChannelHandler, bind: {
48
39
  port: number;
49
40
  host?: string;
package/dist/cli/serve.js CHANGED
@@ -10,18 +10,9 @@ import { answersLocalhost, bindLabel, classifyBind, clientHost } from "../bind.j
10
10
  import { serveNode } from "../channels/serve.js";
11
11
  import { log } from "../log.js";
12
12
  import { openExternalUrl } from "../open-url.js";
13
+ import { declaredChannels } from "../channels/discover.js";
13
14
  import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
14
15
  import { failStartup, failUsage } from "./fail.js";
15
- /**
16
- * Mount the session control plane (`/control/*`) when the agent enabled it
17
- * (`config.sessionControl`): merge the bearer-authenticated routes and return an announcer that
18
- * writes `<stateRoot>/control.json` — `{ url, token }`, 0600 — once the port is known. The file is
19
- * the LOCAL discovery channel (`fastagent attach`, a local desktop app); filesystem permissions are
20
- * its trust boundary, and each boot overwrites it with a fresh per-boot token. A user channel
21
- * colliding on `/control/*` fails startup — the same disposition as a channel-channel collision
22
- * (routesFor): `sessionControl` is an explicit opt-in, so declaring both is a configuration error,
23
- * and silently shadowing either side would serve a surface the author didn't write.
24
- */
25
16
  /**
26
17
  * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
27
18
  * (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
@@ -44,15 +35,6 @@ export function assertTunnelBindable(host, tunnel, source) {
44
35
  failUsage(message);
45
36
  failStartup(new Error(message));
46
37
  }
47
- /**
48
- * The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
49
- * ONE function because they are one message — they were two, and `--bind` updated the first while the
50
- * second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
51
- * neither can be changed without the other in view, and the address has a single derivation.
52
- *
53
- * A wildcard bind is every interface, and naming one address there would understate it — but the curl
54
- * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
55
- */
56
38
  /**
57
39
  * The "we are serving" report: the supervisor message `dev`'s watcher waits for, the addresses, and
58
40
  * what mounted. One function because both commands must say the same thing at the same moment —
@@ -67,6 +49,15 @@ export function reportServing(service, host, boundPort) {
67
49
  log.info(`[fastagent] long connections: ${service.channels.longConnections.join(", ")}`);
68
50
  }
69
51
  }
52
+ /**
53
+ * The startup lines that name WHERE the serve is: the bind report, and the curl the reader copies.
54
+ * ONE function because they are one message — they were two, and `--bind` updated the first while the
55
+ * second went on dialing `localhost`, which is precisely what a non-wildcard bind stops answering. Now
56
+ * neither can be changed without the other in view, and the address has a single derivation.
57
+ *
58
+ * A wildcard bind is every interface, and naming one address there would understate it — but the curl
59
+ * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
60
+ */
70
61
  export function readyAddressLines(host, boundPort, builtinInvoke) {
71
62
  const dial = `${clientHost(host)}:${boundPort}`;
72
63
  const lines = [
@@ -77,15 +68,16 @@ export function readyAddressLines(host, boundPort, builtinInvoke) {
77
68
  }
78
69
  return lines;
79
70
  }
80
- /**
81
- * Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
82
- * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
83
- * the sole clean-shutdown command. `host` unset binds all interfaces.
84
- */
85
71
  /** What the CLI gives a service to stop in, and the hard exit that follows it. The order matters:
86
72
  * a forced exit before the service answers would report a clean shutdown over a stuck channel. */
87
73
  export const SHUTDOWN_GRACE_MS = 800;
88
74
  const FORCED_EXIT_MS = 1_500;
75
+ /**
76
+ * Bind HTTP and report ready — but only once the SERVICE is, which is not the same moment: a bound
77
+ * socket is not a serving agent while a declared long-connection channel is still dialling, so this
78
+ * awaits `hooks.ready` (mountAgentService owns the connections themselves) before announcing
79
+ * anything. Signals are the sole clean-shutdown command; `host` unset binds all interfaces.
80
+ */
89
81
  export function serve(handler, bind, hooks = {}) {
90
82
  const { port, host } = bind;
91
83
  const hosted = serveNode(handler, { port, host });
@@ -155,7 +147,10 @@ export function maybeTunnel(agentDir, routeChannels, boundPort, tunnel, stateRoo
155
147
  void startCloudflareTunnel(boundPort).then((instance) => {
156
148
  if (!instance)
157
149
  return;
158
- void announceWebhooks(agentDir, instance.url, { openUrl: openExternalUrl, routeChannels, stateRoot });
150
+ void announceWebhooks(agentDir, instance.url, declaredChannels(routeChannels), {
151
+ openUrl: openExternalUrl,
152
+ stateRoot,
153
+ });
159
154
  const cleanup = () => instance.close();
160
155
  process.once("SIGINT", cleanup);
161
156
  process.once("SIGTERM", cleanup);
@@ -1,16 +1,46 @@
1
1
  import { type LoginIO, type LoginMethod, type LoginResult } from "../engines/pi/login.ts";
2
+ import type { LoadedDefinition } from "../engines/pi/definition.ts";
3
+ import type { ModuleLoadFailure } from "../loader.ts";
4
+ import type { ToolCollision } from "../engines/pi/tool.ts";
5
+ /** What the startup report reads off an opened directory — a structural subset of the opener's return,
6
+ * spelled out so this module does not depend on that function's whole shape. */
7
+ export interface ReportableAssembly {
8
+ agentDir: string;
9
+ workspace: string;
10
+ modelSpec: string;
11
+ authPath: string;
12
+ config: {
13
+ thinkingLevel?: string;
14
+ };
15
+ definition: LoadedDefinition;
16
+ toolNames: string[];
17
+ deferredToolNames: string[];
18
+ toolCollisions: ToolCollision[];
19
+ toolFailures: ModuleLoadFailure[];
20
+ }
2
21
  /**
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
- * `codingTools:` joined `workspace:`/`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.
22
+ * What `dev` and `start` say about the directory they just opened, in the order they say it.
23
+ *
24
+ * ONE function because it is one report: the two commands wrote it out line by line, and the copies
25
+ * had already diverged over which lines exist at all `dev` naming the config file, `start` naming
26
+ * state and sessions. A line added to one of two hand-written copies is invisible in the other.
27
+ *
28
+ * The divergence itself is PRESERVED, not resolved: `start`'s `state:`/`sessions:` pair introduces the
29
+ * persistence warnings that follow it in production posture, and `dev`'s `config:` has no counterpart
30
+ * there. Both are passed as explicit extras by the caller, so the asymmetry is visible at the call
31
+ * site rather than buried in two copies of a list. Whether it is RIGHT is a separate question from
32
+ * whether it has one owner.
33
+ *
34
+ * Findings (skill collisions, definition diagnostics) CLOSE the report — they are about the assembly
35
+ * just printed. What a command says next is its own posture talk, not report: `start`'s persistence
36
+ * notes now follow them rather than precede them.
8
37
  */
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;
38
+ export declare function reportAssembly(a: ReportableAssembly, extras?: {
39
+ /** Printed between `workspace:`/`hint:` and `model:` (`dev` names the config file here). */
40
+ beforeModel?: [label: string, value: string][];
41
+ /** Printed after the tool lines, before findings (`start` names state + sessions here). */
42
+ afterTools?: [label: string, value: string][];
43
+ }): Promise<void>;
14
44
  /** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
15
45
  export declare function isInteractive(): boolean;
16
46
  /**
@@ -12,7 +12,10 @@ import { isValidPort, listModels, loadConfig, providerOf, resolveAuthPath, resol
12
12
  import { LoginCancelled, loginFlow } from "../engines/pi/login.js";
13
13
  import { createPiModelRuntime, createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses, } from "../engines/pi/models.js";
14
14
  import { formatAuthReport } from "./auth-view.js";
15
- import { log } from "../log.js";
15
+ import { CODING_TOOL_NAMES } from "../engines/pi/create.js";
16
+ import { reportFindingsIfChanged, reportToolCollisions } from "../engines/pi/report.js";
17
+ import { workspaceHint } from "../paths.js";
18
+ import { log, reportModuleLoadFailures } from "../log.js";
16
19
  import { openExternalUrl } from "../open-url.js";
17
20
  import { bindAddress, isBindAddress } from "../bind.js";
18
21
  import { failStartup, failUsage } from "./fail.js";
@@ -22,17 +25,61 @@ import { failStartup, failUsage } from "./fail.js";
22
25
  * `codingTools:` joined `workspace:`/`config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
23
26
  * stdout DATA (pipeable, its own label set, its own width), not a log line — the shared thing is the
24
27
  * policy (pad, never hand-space), not a constant.
28
+ *
29
+ * Private to this module: {@link reportAssembly} is the report, and a command reaching past it for a
30
+ * line of its own is how the two copies of that report came to differ.
25
31
  */
26
- export function reportLine(label, value) {
32
+ function reportLine(label, value) {
27
33
  log.info(`[fastagent] ${`${label}:`.padEnd(13)}${value}`);
28
34
  }
29
35
  /** The workspace hint under the `agent:`/`workspace:` pair, when there is one ({@link workspaceHint}):
30
36
  * you pointed at the agent, and the project around it is probably what you meant. A hint, so it renders
31
- * as one and is silent otherwise — `dev` and `start` both print the pair, so both ask for it. */
32
- export function reportWorkspaceHint(hint) {
37
+ * as one and is silent otherwise. */
38
+ function reportWorkspaceHint(hint) {
33
39
  if (hint)
34
40
  reportLine("hint", hint);
35
41
  }
42
+ /**
43
+ * What `dev` and `start` say about the directory they just opened, in the order they say it.
44
+ *
45
+ * ONE function because it is one report: the two commands wrote it out line by line, and the copies
46
+ * had already diverged over which lines exist at all — `dev` naming the config file, `start` naming
47
+ * state and sessions. A line added to one of two hand-written copies is invisible in the other.
48
+ *
49
+ * The divergence itself is PRESERVED, not resolved: `start`'s `state:`/`sessions:` pair introduces the
50
+ * persistence warnings that follow it in production posture, and `dev`'s `config:` has no counterpart
51
+ * there. Both are passed as explicit extras by the caller, so the asymmetry is visible at the call
52
+ * site rather than buried in two copies of a list. Whether it is RIGHT is a separate question from
53
+ * whether it has one owner.
54
+ *
55
+ * Findings (skill collisions, definition diagnostics) CLOSE the report — they are about the assembly
56
+ * just printed. What a command says next is its own posture talk, not report: `start`'s persistence
57
+ * notes now follow them rather than precede them.
58
+ */
59
+ export async function reportAssembly(a, extras = {}) {
60
+ reportLine("agent", a.agentDir);
61
+ reportLine("workspace", a.workspace);
62
+ reportWorkspaceHint(workspaceHint(a));
63
+ for (const [label, value] of extras.beforeModel ?? [])
64
+ reportLine(label, value);
65
+ reportLine("model", `${a.modelSpec}${a.config.thinkingLevel ? ` (thinking: ${a.config.thinkingLevel})` : ""}`);
66
+ await reportAuth(a.agentDir, a.modelSpec, a.authPath);
67
+ reportLine("context", a.definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
68
+ if (a.definition.persona)
69
+ reportLine("persona", "persona.md");
70
+ reportLine("skills", a.definition.skills.map((s) => s.name).join(", ") || "(none)");
71
+ reportLine("codingTools", CODING_TOOL_NAMES.join(", "));
72
+ if (a.toolNames.length > 0)
73
+ reportLine("tools", a.toolNames.join(", "));
74
+ if (a.deferredToolNames.length > 0) {
75
+ reportLine("deferred", `${a.deferredToolNames.join(", ")} (activated via search_tools)`);
76
+ }
77
+ reportToolCollisions(a.toolCollisions);
78
+ reportModuleLoadFailures(a.toolFailures);
79
+ for (const [label, value] of extras.afterTools ?? [])
80
+ reportLine(label, value);
81
+ reportFindingsIfChanged(a.definition.dir, a.definition);
82
+ }
36
83
  /** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
37
84
  export function isInteractive() {
38
85
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -29,7 +29,7 @@ export type AgentcoreLogsOutcome = {
29
29
  };
30
30
  /**
31
31
  * Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
32
- * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
33
- * filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
32
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. The whole group
33
+ * is tailed — see the file header for why a stream filter cannot narrow it and must not be added.
34
34
  */
35
35
  export declare function tailAgentcoreLogs(plan: AgentcoreLogsPlan, aws: CliRunner, announce?: (message: string) => void): Promise<AgentcoreLogsOutcome>;
@@ -17,8 +17,8 @@ function parseLogGroupNames(stdout) {
17
17
  }
18
18
  /**
19
19
  * Find and tail one AgentCore log source. Discovery is dynamic rather than spelling `-DEFAULT`:
20
- * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. Runtime tailing
21
- * filters the log STREAM prefix so OTEL/spans in the same group never pollute the application log.
20
+ * endpoint naming belongs to AWS, and an edited stack may use a different endpoint. The whole group
21
+ * is tailed — see the file header for why a stream filter cannot narrow it and must not be added.
22
22
  */
23
23
  export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
24
24
  const stack = `fastagent-${plan.name}`;
@@ -1,4 +1,4 @@
1
- import type { ChannelKind } from "../../scaffold/add-channel.ts";
1
+ import type { DeclaredChannel } from "../../channels/discover.ts";
2
2
  import { type Artifact, type ContainerInput } from "../container.ts";
3
3
  /** The one schedule fact the plan needs (from loadSchedules) — name + cron + tz. */
4
4
  export interface ScheduleFact {
@@ -11,10 +11,9 @@ export interface AgentcorePlanInput extends ContainerInput {
11
11
  name: string;
12
12
  /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
13
13
  modelAuth: string | undefined;
14
- /** Known first-party channelseach contributes its secret metadata + webhook step. */
15
- channels: ChannelKind[];
16
- /** ALL route-channel basenames (customs included) — any of them requires the forwarder. */
17
- routeChannels: string[];
14
+ /** Every declared channel and its ingress the source of the secret list, the webhook steps, and
15
+ * whether the forwarder is needed at all (ANY webhook channel requires it, customs included). */
16
+ channels: readonly DeclaredChannel[];
18
17
  /** Extra secret env-var names (fastagent.config deploy.secrets). */
19
18
  extraSecrets?: string[];
20
19
  /** Static schedules — each becomes an EventBridge Scheduler rule targeting the forwarder. */
@@ -118,15 +117,18 @@ export declare function toEventBridgeCron(cron: string): {
118
117
  error: string;
119
118
  };
120
119
  /**
121
- * The forwarder Lambda source — the ONLY string both the template's inline ZipFile and the readable
120
+ * The forwarder Lambda source — the ONLY string both the deployment package and the readable
122
121
  * `lambda/forwarder.js` artifact are generated from (one source, no drift). Zero-dependency: the
123
- * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: CloudFormation inline code always
124
- * lands as `index.js`, where ESM `import` is a syntax error (found by the first real deploy). Two
122
+ * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: the package's entry lands as
123
+ * `index.js` ({@link zipSingleFile}), where ESM `import` is a syntax error. Two
125
124
  * event shapes: a Function URL webhook (reconstructed verbatim into a `webhook` envelope; the
126
125
  * channel's REAL response rides back inside the transport reply and is re-emitted byte-exact —
127
126
  * Feishu's URL-verification challenge depends on it), and an EventBridge Scheduler fire
128
- * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key). MUST stay
129
- * under CloudFormation's 4096-byte inline-code cap.
127
+ * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key).
128
+ *
129
+ * It ships as an S3 object rather than inline `ZipFile`, so there is no 4096-byte ceiling on it — a
130
+ * constraint this comment asserted for long after the template stopped inlining, which is the kind
131
+ * of stale rule that stops the next person making a correct change.
130
132
  */
131
133
  export declare function forwarderSource(): string;
132
134
  /**
@@ -30,6 +30,7 @@
30
30
  import { createHash } from "node:crypto";
31
31
  import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
32
32
  import { SECRETS_DIRNAME } from "../../paths.js";
33
+ import { webhookKinds, webhookRunbook } from "../channel-ingress.js";
33
34
  import { containerArtifacts } from "../container.js";
34
35
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
35
36
  /** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
@@ -215,15 +216,18 @@ function yamlSingleQuote(value) {
215
216
  return `'${value.replace(/'/g, "''")}'`;
216
217
  }
217
218
  /**
218
- * The forwarder Lambda source — the ONLY string both the template's inline ZipFile and the readable
219
+ * The forwarder Lambda source — the ONLY string both the deployment package and the readable
219
220
  * `lambda/forwarder.js` artifact are generated from (one source, no drift). Zero-dependency: the
220
- * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: CloudFormation inline code always
221
- * lands as `index.js`, where ESM `import` is a syntax error (found by the first real deploy). Two
221
+ * Lambda Node runtime bundles AWS SDK v3. CommonJS ON PURPOSE: the package's entry lands as
222
+ * `index.js` ({@link zipSingleFile}), where ESM `import` is a syntax error. Two
222
223
  * event shapes: a Function URL webhook (reconstructed verbatim into a `webhook` envelope; the
223
224
  * channel's REAL response rides back inside the transport reply and is re-emitted byte-exact —
224
225
  * Feishu's URL-verification challenge depends on it), and an EventBridge Scheduler fire
225
- * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key). MUST stay
226
- * under CloudFormation's 4096-byte inline-code cap.
226
+ * (`{ scheduleFire }`, slot = the scheduled instant — the container's idempotency key).
227
+ *
228
+ * It ships as an S3 object rather than inline `ZipFile`, so there is no 4096-byte ceiling on it — a
229
+ * constraint this comment asserted for long after the template stopped inlining, which is the kind
230
+ * of stale rule that stops the next person making a correct change.
227
231
  */
228
232
  export function forwarderSource() {
229
233
  return `// Generated by \`fastagent deploy agentcore\` — the deployment's only ingress.
@@ -250,6 +254,15 @@ let ownUrl; // self-resolved once per cold start; rides on every envelope for th
250
254
  const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => \`%\${c.charCodeAt(0).toString(16).toUpperCase()}\`);
251
255
  const hmac = (key, data) => crypto.createHmac("sha256", key).update(data).digest();
252
256
 
257
+ // Every shared-secret gate on this public URL goes through here. Constant-time, and an unset
258
+ // expected secret NEVER matches — otherwise a topology that did not configure one would accept a
259
+ // request that sent none. Non-string input is coerced to "" first: Buffer.from(8) allocates eight
260
+ // bytes rather than throwing, which would compare a number against a secret by length alone.
261
+ const secretEq = (given, expected) => {
262
+ const a = Buffer.from(typeof given === "string" ? given : ""), b = Buffer.from(expected || "");
263
+ return b.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
264
+ };
265
+
253
266
  function presign(method, seconds) {
254
267
  const bucket = process.env.STATE_BUCKET, key = process.env.STATE_KEY, region = process.env.AWS_REGION;
255
268
  const host = \`\${bucket}.s3.\${region}.amazonaws.com\`;
@@ -375,11 +388,7 @@ exports.handler = async (event, ctx) => {
375
388
  // The container may settle long after the webhook Lambda (and its credentials) expired.
376
389
  if (event.rawPath === "/__fastagent/state-urls") {
377
390
  const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
378
- const actual = Buffer.from(typeof req.auth === "string" ? req.auth : "");
379
- const expected = Buffer.from(process.env.STATE_REFRESH_SECRET || "");
380
- if (!expected.length || actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
381
- return { statusCode: 403, body: "forbidden\\n" };
382
- }
391
+ if (!secretEq(req.auth, process.env.STATE_REFRESH_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
383
392
  return {
384
393
  statusCode: 200,
385
394
  headers: { "content-type": "application/json" },
@@ -389,7 +398,7 @@ exports.handler = async (event, ctx) => {
389
398
  // The container's wake-alarm callback (reserved path, shared secret) — handled HERE, never forwarded.
390
399
  if (event.rawPath === "/__fastagent/wake-alarm") {
391
400
  const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
392
- if (!process.env.WAKE_SECRET || req.secret !== process.env.WAKE_SECRET) return { statusCode: 403, body: "forbidden\\n" };
401
+ if (!secretEq(req.secret, process.env.WAKE_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
393
402
  const failed = await syncAlarms(req.alarms || [], ctx);
394
403
  // Partial failure IS failure: the container retries the whole (idempotent) set until every
395
404
  // pending wake really has its alarm.
@@ -405,7 +414,7 @@ exports.handler = async (event, ctx) => {
405
414
  // public traffic) are probeable too.
406
415
  if (event.rawPath === "/__fastagent/probe") {
407
416
  const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
408
- if (!process.env.INGRESS_SECRET || req.auth !== process.env.INGRESS_SECRET) return { statusCode: 403, body: "forbidden\\n" };
417
+ if (!secretEq(req.auth, process.env.INGRESS_SECRET)) return { statusCode: 403, body: "forbidden\\n" };
409
418
  const r = await invoke({ kind: "probe" });
410
419
  if (r.status !== 200) {
411
420
  console.log(\`probe transport error \${r.status}: \${r.body}\`);
@@ -465,7 +474,7 @@ function template(input, translated) {
465
474
  // a schedule turn can outlive both its original presigned URL and the Lambda credentials that signed
466
475
  // it. Schedule-only URLs reject every non-reserved HTTP path before invoking AgentCore, so they do
467
476
  // not expose a webhook/data plane (and start never mounts the builtin /invoke under AgentCore).
468
- const needsForwarder = input.routeChannels.length > 0 || translated.length > 0 || input.selfSchedule;
477
+ const needsForwarder = input.channels.some((channel) => channel.ingress === "webhook") || translated.length > 0 || input.selfSchedule;
469
478
  const needsFunctionUrl = needsForwarder;
470
479
  const secrets = deploymentSecrets(input.modelAuth, input.channels, input.extraSecrets);
471
480
  const forwarderFnArn = `!Sub arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:fastagent-${input.name}-forwarder`;
@@ -604,7 +613,7 @@ function template(input, translated) {
604
613
  ` Action: lambda:GetFunctionUrlConfig`,
605
614
  ` Resource: ${forwarderFnArn}`,
606
615
  ]
607
- : []), ``, ` Forwarder:`, ` Type: AWS::Lambda::Function`, ` Properties:`, ` FunctionName: fastagent-${input.name}-forwarder`, ` Runtime: nodejs22.x`, ` Handler: index.handler`, ` # Webhook ACKs are fast, but schedule-fire holds the connection for the WHOLE agent turn`, ` # (claim-before-run means a timeout never double-fires; the turn also continues and is`, ` # audited container-side). EventBridge→Lambda is async, so the long timeout costs nothing.`, ` Timeout: 900`, ` MemorySize: 256`, ` Role: !GetAtt ForwarderRole.Arn`, ` Environment:`, ` Variables:`, ` RUNTIME_ARN: !GetAtt Runtime.AgentRuntimeArn`, ` INGRESS_SESSION_ID: ${ingressSessionId(input.name)}`, ...(needsFunctionUrl ? [` STATE_REFRESH_SECRET: !Ref FastagentIngressSecret`] : []), ...(input.routeChannels.length > 0 ? [` WEBHOOKS_ENABLED: "1"`] : []), ...(input.selfSchedule
616
+ : []), ``, ` Forwarder:`, ` Type: AWS::Lambda::Function`, ` Properties:`, ` FunctionName: fastagent-${input.name}-forwarder`, ` Runtime: nodejs22.x`, ` Handler: index.handler`, ` # Webhook ACKs are fast, but schedule-fire holds the connection for the WHOLE agent turn`, ` # (claim-before-run means a timeout never double-fires; the turn also continues and is`, ` # audited container-side). EventBridge→Lambda is async, so the long timeout costs nothing.`, ` Timeout: 900`, ` MemorySize: 256`, ` Role: !GetAtt ForwarderRole.Arn`, ` Environment:`, ` Variables:`, ` RUNTIME_ARN: !GetAtt Runtime.AgentRuntimeArn`, ` INGRESS_SESSION_ID: ${ingressSessionId(input.name)}`, ...(needsFunctionUrl ? [` STATE_REFRESH_SECRET: !Ref FastagentIngressSecret`] : []), ...(input.channels.some((channel) => channel.ingress === "webhook") ? [` WEBHOOKS_ENABLED: "1"`] : []), ...(input.selfSchedule
608
617
  ? [
609
618
  ` WAKE_SECRET: !Ref FastagentWakeSecret`,
610
619
  ` WAKE_ROLE_ARN: !GetAtt WakeSchedulerRole.Arn`,
@@ -671,7 +680,7 @@ export function planAgentcoreDeploy(input) {
671
680
  }
672
681
  // Every forwarder needs its authenticated Function URL to refresh S3 snapshot capabilities during
673
682
  // a long turn. Without route channels, ordinary HTTP paths are rejected before AgentCore is invoked.
674
- const needsForwarder = input.routeChannels.length > 0 || translated.length > 0 || input.selfSchedule;
683
+ const needsForwarder = input.channels.some((channel) => channel.ingress === "webhook") || translated.length > 0 || input.selfSchedule;
675
684
  const needsFunctionUrl = needsForwarder;
676
685
  const artifacts = [
677
686
  { path: `${prefix}${TEMPLATE_FILE}`, content: template(input, translated) },
@@ -743,22 +752,21 @@ export function planAgentcoreDeploy(input) {
743
752
  ? `# Model auth: none found at the local auth path — pass --auth-path <file>, or \`--run\` carries it`
744
753
  : `# Model auth: your local auth is "${input.modelAuth}" — the plan can't read its value; \`--run\` carries it`, `# as the FastagentAuthSeed parameter (base64 of auth.json), materialized on first boot.`);
745
754
  }
746
- // Post-deploy webhook registration — same per-channel steps as every host, pointed at the
747
- // forwarder's Function URL (read from the stack outputs).
748
- const post = [];
749
- if (channels.includes("telegram")) {
750
- post.push(`# Register the Telegram webhook (default route POST /telegram; secret_token MUST equal TELEGRAM_SECRET_TOKEN):`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=<ForwarderUrl>/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
751
- }
752
- if (channels.includes("github")) {
753
- post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy + time_of_last_update holds the session while turns run, but the 8 h compute ceiling is hard).`);
754
- }
755
- if (channels.includes("slack")) {
756
- post.push(`# Set Slack Event Subscriptions Request URL = <ForwarderUrl>/slack (scopes per channels/slack.ts).`);
757
- }
758
- for (const kind of ["feishu", "lark"]) {
759
- if (!channels.includes(kind))
760
- continue;
761
- post.push(`# Set the ${kind === "feishu" ? "Feishu" : "Lark"} event Request URL (developer console → Events & Callbacks):`, `# Request URL = <ForwarderUrl>/${kind} (the stack must be deployed when you save — the console`, `# sends a challenge, which rides through the forwarder to the channel and back verbatim).`);
755
+ // Post-deploy webhook registration — the shared channel-ingress steps, pointed at the forwarder's
756
+ // Function URL (read from the stack outputs). `--run` REFUSES a long-connection channel here, but
757
+ // generate-only only warns and still prints this runbook, so the steps are filtered on ingress like
758
+ // every other host's rather than on the CLI having gated.
759
+ const post = webhookRunbook(`<ForwarderUrl>`, channels);
760
+ // AgentCore asides the shared steps cannot carry: nothing else routes through a forwarder, and
761
+ // nothing else has a compute ceiling.
762
+ // Named rather than positional: the asides land after ALL the steps, so "the console" has to say
763
+ // WHICH console. One line for both kinds — an agent that declares feishu AND lark reads the same
764
+ // fact twice otherwise and looks for a second step that does not exist.
765
+ if (webhookKinds(channels).some((kind) => kind === "feishu" || kind === "lark")) {
766
+ post.push(`# NOTE: the Feishu/Lark console's challenge rides through the forwarder to the channel and back`, `# verbatim, so the stack must be deployed when you save the Request URL.`);
767
+ }
768
+ if (channels.some((channel) => channel.name === "github")) {
769
+ post.push(`# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy + time_of_last_update holds the session while turns run, but the 8 h compute ceiling is hard).`);
762
770
  }
763
771
  if (post.length > 0)
764
772
  runbook.push(``, ...post);