@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
@@ -8,7 +8,7 @@
8
8
  * --force). `--run` drives the target CLI instead of printing.
9
9
  */
10
10
  import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
11
- import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
13
13
  import { basename, dirname, join, resolve } from "node:path";
14
14
  import { registerFeishuWebhook } from "../../channels/feishu/register-webhook.js";
@@ -17,8 +17,9 @@ import { registerSlackWebhook } from "../../channels/slack/register-webhook.js";
17
17
  import { registerTelegramWebhook } from "../../channels/telegram/register-webhook.js";
18
18
  import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../deploy/agentcore/plan.js";
19
19
  import { deployAgentcoreRun } from "../../deploy/agentcore/run.js";
20
+ import { webhookPaths } from "../../deploy/channel-ingress.js";
20
21
  import { isGeneratedDockerfile, isGeneratedDockerignore } from "../../deploy/container.js";
21
- import { composeHasTunnelService, dockerWebhookPaths, isGeneratedCompose, planDockerDeploy, toDockerProjectName, } from "../../deploy/docker/plan.js";
22
+ import { composeHasTunnelService, isGeneratedCompose, planDockerDeploy, toDockerProjectName, } from "../../deploy/docker/plan.js";
22
23
  import { deployDockerRun } from "../../deploy/docker/run.js";
23
24
  import { isGeneratedFlyToml, parseFlyAppName, parseFlyMinMachines, parseFlyRegion, planFlyDeploy, toFlyAppName, } from "../../deploy/fly/plan.js";
24
25
  import { deployFlyRun } from "../../deploy/fly/run.js";
@@ -29,18 +30,120 @@ import { spawnRunner } from "../../deploy/runner.js";
29
30
  import { assembleSecrets } from "../../deploy/secrets.js";
30
31
  import { loadDotEnv } from "../../env.js";
31
32
  import { loadConfig, resolveModelSpec } from "../../engines/pi/config.js";
32
- import { resolveStateRoot } from "../../paths.js";
33
+ import { resolveStateRoot, exists } from "../../paths.js";
33
34
  import { loadSchedules } from "../../schedule/discover.js";
34
35
  import { installProxyFetch } from "../../proxy.js";
35
36
  import { openExternalUrl } from "../../open-url.js";
36
- import { exists } from "../../paths.js";
37
37
  import { announceWebhooks } from "../../tunnel.js";
38
38
  import { failStartup, failUsage, placementOrExit } from "../fail.js";
39
39
  import { resolveFirstRunModel } from "../shared.js";
40
+ /** The deploy targets, as a value: {@link HOST_ONLY_FLAGS} is checked against this list by a test, and
41
+ * a type alone cannot be enumerated at runtime. NOT the only copy — `cli/program.ts` repeats it as the
42
+ * `<host>` argument's `choices`, because that file may not import a command module at load time (its
43
+ * per-command imports are lazy). Adding a host means editing both; the type there rides in on a
44
+ * `import type`, which costs nothing at runtime. */
45
+ export const DEPLOY_HOSTS = ["docker", "fly", "railway", "agentcore"];
46
+ /** The registrars every host driver gets. One wiring: a channel's credentials are the channel's, not
47
+ * the host's, so which of them can run end-to-end never varies by deployment target. */
48
+ function registrarsFor(agentDir) {
49
+ return {
50
+ telegram: (baseUrl) => registerTelegramWebhook(baseUrl),
51
+ slack: (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }),
52
+ feishu: (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind),
53
+ };
54
+ }
40
55
  /** A copy/paste-safe POSIX shell argument for the command hints deploy prints. */
41
56
  function shellArg(value) {
42
57
  return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
43
58
  }
59
+ /** Infers `Owner` from the literal's own `owner`, so each row carries its exhaustiveness itself. The
60
+ * alternative — annotating the array as a tuple of `HostOnlyFlag<"fly"> | …` — put the whole guarantee
61
+ * in one annotation that a third rule invites relaxing to `HostOnlyFlag<DeployHost>[]`, where
62
+ * `Exclude<DeployHost, DeployHost>` collapses to `never` and every `instead` type-checks empty. */
63
+ const hostOnlyFlag = (rule) => rule;
64
+ /**
65
+ * ONE table for "this flag belongs to that host", because the fact is symmetric and was not stored
66
+ * that way: each host branch stated the OTHER hosts' flags in its own words, so the same sentence
67
+ * existed three times per flag and had already drifted (`Railway-only` twice, `railway-only` once).
68
+ *
69
+ * Every row is a flag that only WARNS elsewhere. `--tunnel` is host-only too and is deliberately not
70
+ * here: it is a usage GATE (`failUsage`, exit 2) raised in `runDeploy`, not a warning — moving it in
71
+ * would downgrade a refusal to a line of advice.
72
+ *
73
+ * Exported for the exhaustiveness test: the type stops a missing `instead` line, and the test stops it
74
+ * from being typed away.
75
+ */
76
+ export const HOST_ONLY_FLAGS = [
77
+ hostOnlyFlag({
78
+ flag: "--stop/--no-scale-to-zero",
79
+ owner: "fly",
80
+ passed: (opts) => opts.stop === true || opts.scaleToZero === false,
81
+ instead: {
82
+ docker: "local Compose stays running",
83
+ railway: "Railway's App Sleeping is a dashboard toggle (the runbook states the manual step)",
84
+ agentcore: "AgentCore's idle/lifetime policy lives in the template's LifecycleConfiguration",
85
+ },
86
+ }),
87
+ hostOnlyFlag({
88
+ flag: "--into-linked",
89
+ owner: "railway",
90
+ passed: (opts) => opts.intoLinked === true,
91
+ instead: {
92
+ docker: "ignored for local Docker",
93
+ agentcore: "ignored for AgentCore",
94
+ fly: "fly --run is idempotent — it reuses an existing app/volume",
95
+ },
96
+ }),
97
+ ];
98
+ /** Exported for its own test: nothing covered these six sentences, which is how three of them came to
99
+ * disagree about the spelling of a host name. */
100
+ export function warnHostOnlyFlags(host, opts) {
101
+ for (const rule of HOST_ONLY_FLAGS) {
102
+ if (host === rule.owner || !rule.passed(opts))
103
+ continue;
104
+ // The `continue` above is exactly the key set `instead` is typed over, but TS cannot narrow a
105
+ // union member out through a comparison against a per-rule literal, so the read needs a cast —
106
+ // and a cast is how a hole would reach the operator as the word "undefined". Unreachable while
107
+ // the rows stay exhaustive, which is what the table's own test asserts.
108
+ const instead = rule.instead[host];
109
+ // THROWN, not `failStartup`ed, though every other failure in this file exits through that: an
110
+ // incomplete table is a bug, and fail.ts prints a plain Error's message WITHOUT its stack (it
111
+ // reserves that shape for user-fixable problems) while exiting the process — which in the test
112
+ // that covers this table would kill the worker instead of reporting which row is short.
113
+ if (instead === undefined) {
114
+ throw new Error(`deploy: HOST_ONLY_FLAGS has no "instead" line for ${host} on ${rule.flag}`);
115
+ }
116
+ // A colon, not "is": one row names a single flag and the other names a pair, and no verb agrees
117
+ // with both (the hand-written copies this replaced said "is" and "are" respectively).
118
+ console.error(`[fastagent] warn: ${rule.flag}: ${rule.owner}-only — ${instead}`);
119
+ }
120
+ }
121
+ /**
122
+ * The `--run` credential carry, for every host: the local model credential (an env key, or the whole
123
+ * auth.json as a `FASTAGENT_AUTH_SEED`) plus channel secrets, read through the one environment that
124
+ * accounts for Slack's rotated bot token. Four drivers assembled this identically; a fifth would have
125
+ * had to remember the `deployEnvironment` wrapper, which is invisible when forgotten (a rotated token
126
+ * silently deploys stale).
127
+ */
128
+ async function carryCredentials(params) {
129
+ const { agentDir, modelAuth, modelKeyInDefinition, authPath, channels, extraSecrets } = params;
130
+ return assembleSecrets({
131
+ modelAuth,
132
+ modelKeyInDefinition,
133
+ authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
134
+ channels,
135
+ extraSecrets,
136
+ env: deployEnvironment(agentDir, channels),
137
+ });
138
+ }
139
+ /** Gate a `--run` that has no model credential to carry. Its remediation is `fastagent login`, which
140
+ * is why it is separate from `missingSecrets` (a `.env` fix) rather than folded into it. Docker states
141
+ * the same gate inside its driver, where it belongs after the daemon check. */
142
+ function gateOnModelCredential(needsModelCredential) {
143
+ if (!needsModelCredential)
144
+ return;
145
+ failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
146
+ }
44
147
  export async function runDeploy(host, dirArg, opts) {
45
148
  // ONE deploy semantic: bake the WORKSPACE (WYSIWYG). Artifacts land under the agent dir
46
149
  // (`fastagent/`) plus the one workspace-root `.dockerignore` the packers require; host CLIs run
@@ -76,30 +179,26 @@ export async function runDeploy(host, dirArg, opts) {
76
179
  failStartup(new Error(`deploy stopped: ${pre.gate}`));
77
180
  for (const m of pre.messages)
78
181
  console.error(`[fastagent] ${m.level}: ${m.text}`);
79
- const { channels, routeChannels, longConnectionChannels, hasTimeTriggers, modelAuth, modelKeyInDefinition, authPath, container, port, extraSecrets, } = pre;
80
- const hasDeclaredChannels = routeChannels.length + longConnectionChannels.length > 0;
182
+ const { channels, hasTimeTriggers, modelAuth, modelKeyInDefinition, authPath, container, port, extraSecrets } = pre;
183
+ const webhookChannels = channels.filter((channel) => channel.ingress === "webhook");
184
+ const longConnectionChannels = channels.filter((channel) => channel.ingress === "long-connection");
185
+ const hasDeclaredChannels = channels.length > 0;
186
+ warnHostOnlyFlags(host, opts);
81
187
  // Docker: one app service + loopback port + state volume. `--tunnel` shapes the generated topology
82
188
  // with an optional Quick Tunnel service; `--run` alone decides whether Docker receives side effects.
83
189
  if (host === "docker") {
84
- if (opts.stop || opts.scaleToZero === false) {
85
- console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — local Compose stays running`);
86
- }
87
- if (opts.intoLinked) {
88
- console.error(`[fastagent] warn: --into-linked is Railway-only — ignored for local Docker`);
89
- }
90
190
  const projectName = toDockerProjectName(basename(workspace));
91
191
  const dockerPlan = (tunnel) => planDockerDeploy({
92
192
  projectName,
93
193
  port,
94
194
  modelAuth,
95
195
  channels,
96
- longConnectionChannels,
97
196
  tunnel,
98
197
  extraSecrets,
99
198
  ...container,
100
199
  });
101
- const requestedTunnel = !!opts.tunnel && (!hasDeclaredChannels || routeChannels.length > 0);
102
- if (opts.tunnel && hasDeclaredChannels && routeChannels.length === 0) {
200
+ const requestedTunnel = !!opts.tunnel && (!hasDeclaredChannels || webhookChannels.length > 0);
201
+ if (opts.tunnel && hasDeclaredChannels && webhookChannels.length === 0) {
103
202
  console.error(`[fastagent] note: --tunnel skipped — every channel uses a long connection`);
104
203
  }
105
204
  let plan = dockerPlan(requestedTunnel);
@@ -129,7 +228,6 @@ export async function runDeploy(host, dirArg, opts) {
129
228
  modelKeyInDefinition,
130
229
  authPath,
131
230
  channels,
132
- longConnectionChannels,
133
231
  extraSecrets,
134
232
  });
135
233
  }
@@ -143,10 +241,6 @@ export async function runDeploy(host, dirArg, opts) {
143
241
  // Railway: thin config file, scale-to-zero is a manual dashboard step, the URL is minted (see
144
242
  // planRailwayDeploy). --run drives the railway CLI to completion; otherwise print the runbook.
145
243
  if (host === "railway") {
146
- if (opts.stop || opts.scaleToZero === false) {
147
- console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — Railway's App Sleeping is a dashboard toggle ` +
148
- `(the runbook states the manual step).`);
149
- }
150
244
  // Railway service names are project-scoped (not globally unique like a Fly app); slug the dir
151
245
  // basename so a name with spaces/odd chars can't break the `railway add --service <name>` command.
152
246
  const serviceName = basename(workspace)
@@ -156,7 +250,6 @@ export async function runDeploy(host, dirArg, opts) {
156
250
  serviceName,
157
251
  modelAuth,
158
252
  channels,
159
- longConnectionChannels,
160
253
  extraSecrets,
161
254
  hasTimeTriggers,
162
255
  ...container,
@@ -180,7 +273,6 @@ export async function runDeploy(host, dirArg, opts) {
180
273
  modelKeyInDefinition,
181
274
  authPath,
182
275
  channels,
183
- longConnectionChannels,
184
276
  extraSecrets,
185
277
  intoLinked: !!opts.intoLinked,
186
278
  dockerfilePath: dockerfilePathVar(pre.container.agentPrefix),
@@ -192,18 +284,11 @@ export async function runDeploy(host, dirArg, opts) {
192
284
  // AgentCore: one CloudFormation stack (runtime + forwarder Lambda + EventBridge schedules); no
193
285
  // public URL and no resident process — see deploy/agentcore/plan.ts for the topology decisions.
194
286
  if (host === "agentcore") {
195
- if (opts.stop || opts.scaleToZero === false) {
196
- console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — AgentCore's idle/lifetime policy lives in ` +
197
- `the template's LifecycleConfiguration.`);
198
- }
199
- if (opts.intoLinked) {
200
- console.error(`[fastagent] warn: --into-linked is Railway-only — ignored for AgentCore`);
201
- }
202
287
  // Long-connection channels are STRUCTURALLY unsupported: the connection is the ingress, and a
203
288
  // reclaimed session has nothing to wake it — events in the gap are silently lost. `--run` gates
204
289
  // (deploying a channel that can't connect); generate-only warns and prints the runbook.
205
290
  if (longConnectionChannels.length > 0) {
206
- const msg = `long-connection channel (${longConnectionChannels.join(", ")}) cannot run on AgentCore — there is no ` +
291
+ const msg = `long-connection channel (${longConnectionChannels.map((c) => c.name).join(", ")}) cannot run on AgentCore — there is no ` +
207
292
  `resident process to hold the connection, and nothing wakes a reclaimed session. Switch the channel ` +
208
293
  `to webhook mode (its events then ride the forwarder like every other channel).`;
209
294
  if (opts.run)
@@ -230,7 +315,6 @@ export async function runDeploy(host, dirArg, opts) {
230
315
  name: acName,
231
316
  modelAuth,
232
317
  channels,
233
- routeChannels,
234
318
  extraSecrets,
235
319
  schedules: loaded.schedules.map((s) => ({ name: s.name, cron: s.cron, tz: s.tz })),
236
320
  selfSchedule: !!config.selfSchedule,
@@ -247,7 +331,7 @@ export async function runDeploy(host, dirArg, opts) {
247
331
  // Host capability limit, stated at plan time. GitHub's own webhook contract is 25 MiB, but a
248
332
  // Lambda Function URL request caps at 6 MB — so on this host a large payload cannot arrive at
249
333
  // all. Better a sentence here than an opaque 502 the first time someone pushes a big diff.
250
- if (channels.includes("github")) {
334
+ if (channels.some((channel) => channel.name === "github")) {
251
335
  console.error(`[fastagent] note: on AgentCore a webhook body is capped at ~${Math.round(MAX_WEBHOOK_BODY_BYTES / (1 << 20))} MiB ` +
252
336
  `(the forwarder's Function URL limit); the GitHub channel accepts 25 MiB on a resident host, so the largest ` +
253
337
  `payloads are rejected here rather than delivered`);
@@ -287,20 +371,17 @@ export async function runDeploy(host, dirArg, opts) {
287
371
  selfSchedule: !!config.selfSchedule,
288
372
  // Same predicate the template uses for the forwarder resource — kept in one expression so
289
373
  // the run path and the topology cannot disagree about whether a forwarder exists.
290
- needsForwarder: routeChannels.length > 0 || loaded.schedules.length > 0 || !!config.selfSchedule,
374
+ needsForwarder: webhookChannels.length > 0 || loaded.schedules.length > 0 || !!config.selfSchedule,
291
375
  });
292
376
  }
293
377
  console.log(plan.runbook.join("\n"));
294
378
  return;
295
379
  }
296
380
  // host === "fly".
297
- if (opts.intoLinked) {
298
- console.error(`[fastagent] warn: --into-linked is railway-only (fly --run is idempotent — it reuses an existing app/volume)`);
299
- }
300
381
  // The replay floor that makes scale-to-zero safe is Telegram-only (its L1 turn store). GitHub turns
301
382
  // are fire-and-forget (no replay), so the generated fly.toml keeps one machine running for them —
302
383
  // a note, not a warn, since the plan already did the safe thing (definition-aware autostop).
303
- if (channels.includes("github")) {
384
+ if (channels.some((channel) => channel.name === "github")) {
304
385
  console.error(`[fastagent] note: github turns have no replay — the generated fly.toml uses min_machines_running=1 ` +
305
386
  `(no scale-to-zero) so autostop can't drop an in-flight review. Set it to 0 to accept that trade.`);
306
387
  }
@@ -344,7 +425,7 @@ export async function runDeploy(host, dirArg, opts) {
344
425
  // hand-written fly.toml without the line scales to zero exactly like an explicit 0.
345
426
  const reason = hasTimeTriggers
346
427
  ? `schedules/self-scheduling need a running machine (no external wake-up)`
347
- : `long-connection channel (${longConnectionChannels.join(", ")}) needs an always-on outbound connection`;
428
+ : `long-connection channel (${longConnectionChannels.map((c) => c.name).join(", ")}) needs an always-on outbound connection`;
348
429
  const msg = `your kept fly.toml scales to zero (min_machines_running = ${min ?? "absent → platform default 0"}), but ` +
349
430
  `${reason}. Set min_machines_running = 1, or pass --force to regenerate.`;
350
431
  if (opts.run)
@@ -357,7 +438,6 @@ export async function runDeploy(host, dirArg, opts) {
357
438
  port,
358
439
  modelAuth,
359
440
  channels,
360
- longConnectionChannels,
361
441
  extraSecrets,
362
442
  hasTimeTriggers,
363
443
  ...container,
@@ -377,30 +457,13 @@ export async function runDeploy(host, dirArg, opts) {
377
457
  modelKeyInDefinition,
378
458
  authPath,
379
459
  channels,
380
- longConnectionChannels,
381
460
  flyTomlPath,
382
461
  extraSecrets,
383
462
  });
384
463
  }
385
464
  console.log(plan.runbook.join("\n"));
386
465
  }
387
- /**
388
- * Write each generated artifact, under ONE ownership rule: **deploy only ever overwrites its own
389
- * output.** Each generated file opens with a marker, so a file on disk is either OURS (a previous
390
- * `deploy` wrote it) or the author's — and `--force` means "my generated artifact is authoritative",
391
- * which never licenses clobbering a file we did not write. To hand a path back to fastagent, delete it.
392
- *
393
- * That single rule replaced a second mechanism (a per-host list of paths exempt from `--force`), which
394
- * only described one shape: with the agent inside the workspace, the root `.dockerignore` is the
395
- * WORKSPACE's file and was listed; with the agent flat, the list was empty — so `--force` would have
396
- * overwritten a hand-written root `Dockerfile` in a repository `init --flat` had explicitly adopted.
397
- * Ownership is a property of the file, not of where the agent sits.
398
- *
399
- * A KEPT file of OURS that no longer matches what deploy would generate now (config/channel/lockfile/
400
- * version drift — or the author's edits, which we cannot tell apart) is flagged stale; `--force`
401
- * regenerates that one.
402
- */
403
- /** Did fastagent generate the file at `path`? ONE table for all five artifact kinds: a kind missing from
466
+ /** Did fastagent generate the file at `path`? ONE table for every artifact kind: a kind missing from
404
467
  * it is permanently classified as the author's, which silently turns `--force` into a no-op for that
405
468
  * file (`fly.toml` and `railway.json` were, until the marker predicates below existed). */
406
469
  function isOurArtifact(path, content) {
@@ -419,10 +482,24 @@ function isOurArtifact(path, content) {
419
482
  return false;
420
483
  }
421
484
  /**
422
- * Write the plan's artifacts under `target`, honouring the ownership rule: a file we did not generate is
423
- * never touched, ours is kept unless `--force`. Exported for its own test this is a four-branch state
424
- * machine over (exists, ours, force) that used to be proven by spawning the CLI eight times, which is
425
- * command LOGIC re-run through a subprocess (see vitest.config.ts) and the suite's slowest test.
485
+ * Write each generated artifact under `target`, under ONE ownership rule: **deploy only ever overwrites
486
+ * its own output.** Each generated file opens with a marker, so a file on disk is either OURS (a previous
487
+ * `deploy` wrote it) or the author's and `--force` means "my generated artifact is authoritative",
488
+ * which never licenses clobbering a file we did not write. To hand a path back to fastagent, delete it.
489
+ *
490
+ * That single rule replaced a second mechanism (a per-host list of paths exempt from `--force`), which
491
+ * only described one shape: with the agent inside the workspace, the root `.dockerignore` is the
492
+ * WORKSPACE's file and was listed; with the agent flat, the list was empty — so `--force` would have
493
+ * overwritten a hand-written root `Dockerfile` in a repository `init --flat` had explicitly adopted.
494
+ * Ownership is a property of the file, not of where the agent sits.
495
+ *
496
+ * A KEPT file of OURS that no longer matches what deploy would generate now (config/channel/lockfile/
497
+ * version drift — or the author's edits, which we cannot tell apart) is flagged stale; `--force`
498
+ * regenerates that one.
499
+ *
500
+ * Exported for its own test — this is a four-branch state machine over (exists, ours, force) that used
501
+ * to be proven by spawning the CLI eight times, which is command LOGIC re-run through a subprocess
502
+ * (see vitest.config.ts) and the suite's slowest test.
426
503
  */
427
504
  export async function writeArtifacts(target, artifacts, options) {
428
505
  for (const a of artifacts) {
@@ -461,7 +538,7 @@ export async function writeArtifacts(target, artifacts, options) {
461
538
  }
462
539
  }
463
540
  function deployEnvironment(agentDir, channels) {
464
- if (!channels.includes("slack"))
541
+ if (!channels.some((channel) => channel.name === "slack"))
465
542
  return process.env;
466
543
  const latest = readSlackBotAuthEnv(join(resolveStateRoot(agentDir), "channels", "slack", "bot-auth.json"));
467
544
  return { ...process.env, ...latest };
@@ -472,37 +549,43 @@ function deployEnvironment(agentDir, channels) {
472
549
  * when present, yields an ephemeral URL that reuses the same webhook announcer as `dev --tunnel`.
473
550
  */
474
551
  async function runDeployDocker(params) {
475
- const { agentDir, workspace, composeFile, port, requireTunnel, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, extraSecrets, } = params;
476
- const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
477
- modelAuth,
478
- modelKeyInDefinition,
479
- authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
480
- channels,
481
- longConnectionChannels,
482
- extraSecrets,
483
- env: deployEnvironment(agentDir, channels),
484
- });
485
- const outcome = await deployDockerRun({ composeFile, port, secrets, missingSecrets, needsModelCredential, requireTunnel }, spawnRunner("docker", workspace), (message) => console.error(`[fastagent] ${message}`));
486
- if (!outcome.ok)
487
- failStartup(new Error(`deploy stopped: ${outcome.gate}`));
488
- const compose = `docker compose -f ${composeFile}`;
489
- console.error(`[fastagent] running${outcome.url ? ` → ${outcome.url}` : ""}`);
490
- console.error(`[fastagent] logs: ${compose} logs -f agent`);
491
- console.error(`[fastagent] stop: ${compose} down (state volume is kept)`);
492
- if (outcome.tunnelUrl) {
493
- // Docker Desktop commonly injects a host proxy. The Quick Tunnel hostname may be resolvable only
494
- // through it, exactly like provider/channel APIs; use the same Node dispatcher as dev/start/login.
495
- installProxyFetch();
496
- await announceWebhooks(agentDir, outcome.tunnelUrl, {
552
+ const { agentDir, workspace, composeFile, port, requireTunnel, channels } = params;
553
+ const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
554
+ const outcome = await deployDockerRun({
555
+ composeFile,
556
+ port,
557
+ secrets,
558
+ missingSecrets,
559
+ needsModelCredential,
560
+ requireTunnel,
561
+ announce: (tunnelUrl) => announceWebhooks(agentDir, tunnelUrl, channels, {
497
562
  openUrl: openExternalUrl,
498
- routeChannels: channels.filter((kind) => !longConnectionChannels.includes(kind)),
499
563
  stateRoot: resolveStateRoot(agentDir),
500
- });
564
+ }),
565
+ }, spawnRunner("docker", workspace), (message) => console.error(`[fastagent] ${message}`));
566
+ const compose = `docker compose -f ${composeFile}`;
567
+ // Compose reached "up" iff the driver could report where it is. The gates before that point
568
+ // (no Docker CLI, no daemon, missing secrets) must not be preceded by `docker compose logs`
569
+ // instructions for containers that do not exist; the health-check gate names its own logs command.
570
+ const isUp = outcome.ok || outcome.url !== undefined || outcome.tunnelUrl !== undefined;
571
+ if (outcome.url)
572
+ console.error(`[fastagent] running → ${outcome.url}`);
573
+ if (isUp) {
574
+ console.error(`[fastagent] logs: ${compose} logs -f agent`);
575
+ console.error(`[fastagent] stop: ${compose} down (state volume is kept)`);
576
+ }
577
+ // BEFORE failStartup: a registration gate says "re-run this deploy", and a re-run rebuilds the
578
+ // tunnel service — the operator needs to know the URL will be a different one, or they will read
579
+ // the retry as re-registering the same address.
580
+ if (outcome.tunnelUrl) {
501
581
  console.error(`[fastagent] note: Quick Tunnel URLs are ephemeral — after the tunnel container/Docker daemon ` +
502
582
  `restarts, re-run this deploy so webhooks receive the new URL`);
503
- return;
504
583
  }
505
- const paths = dockerWebhookPaths(channels.filter((kind) => !longConnectionChannels.includes(kind)));
584
+ if (!outcome.ok)
585
+ failStartup(new Error(`deploy stopped: ${outcome.gate}`));
586
+ if (outcome.tunnelUrl)
587
+ return;
588
+ const paths = webhookPaths(channels);
506
589
  if (paths.length > 0) {
507
590
  console.error(`[fastagent] note: public ingress is operator-owned — configure your tunnel/proxy, then wire the ` +
508
591
  `default webhook path(s): ${paths.join(", ")} (or your remapped channel routes)`);
@@ -516,37 +599,24 @@ async function runDeployDocker(params) {
516
599
  * behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the whole workspace).
517
600
  */
518
601
  async function runDeployFly(params) {
519
- const { agentDir, workspace, agentPrefix, appName, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, flyTomlPath, extraSecrets, } = params;
602
+ const { agentDir, workspace, agentPrefix, appName, channels, flyTomlPath } = params;
520
603
  const fly = spawnRunner("fly", workspace);
521
604
  // Fail fast if flyctl is absent (spawn ENOENT → 127), with the install link — not a confusing auth gate.
522
605
  if ((await fly(["version"], { capture: true })).code === 127) {
523
606
  failStartup(new Error(`flyctl not found — install it: https://fly.io/docs/flyctl/install, then re-run`));
524
607
  }
525
608
  const region = parseFlyRegion(await readFile(flyTomlPath, "utf8")) ?? "iad";
526
- const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
527
- modelAuth,
528
- modelKeyInDefinition,
529
- authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
530
- channels,
531
- longConnectionChannels,
532
- extraSecrets,
533
- env: deployEnvironment(agentDir, channels),
534
- });
535
- // Model credential has its OWN remediation (login), distinct from a missing secret's (.env) — gate it
536
- // here, not through missingSecrets, so the message isn't a contradictory mash of both.
537
- if (needsModelCredential) {
538
- failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
539
- }
609
+ const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
610
+ gateOnModelCredential(needsModelCredential);
540
611
  const outcome = await deployFlyRun({
541
612
  appName,
542
613
  region,
543
614
  secrets,
544
615
  missingSecrets,
545
616
  channels,
546
- longConnectionChannels,
547
617
  flyConfig: `${agentPrefix}fly.toml`,
548
618
  dockerfile: `${agentPrefix}Dockerfile`,
549
- }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind), (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }));
619
+ }, fly, (m) => console.error(`[fastagent] ${m}`), registrarsFor(agentDir));
550
620
  if (!outcome.ok)
551
621
  failStartup(new Error(`deploy stopped: ${outcome.gate}`));
552
622
  console.error(`[fastagent] deployed → https://${appName}.fly.dev`);
@@ -559,15 +629,8 @@ async function runDeployFly(params) {
559
629
  * file (secret values off argv) is created here — 0600, removed after the run either way.
560
630
  */
561
631
  async function runDeployAgentcore(params) {
562
- const { agentDir, workspace, agentPrefix, name, modelAuth, modelKeyInDefinition, authPath, channels, extraSecrets, selfSchedule, } = params;
563
- const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
564
- modelAuth,
565
- modelKeyInDefinition,
566
- authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
567
- channels,
568
- extraSecrets,
569
- env: deployEnvironment(agentDir, channels),
570
- });
632
+ const { agentDir, workspace, agentPrefix, name, channels, selfSchedule } = params;
633
+ const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
571
634
  // The wake-alarm shared secret (container ↔ forwarder). Minted fresh each run — both sides receive
572
635
  // the SAME parameter, so rotation is atomic; it never needs to be remembered locally.
573
636
  if (selfSchedule)
@@ -576,9 +639,7 @@ async function runDeployAgentcore(params) {
576
639
  // principal's. Minted fresh each run — both sides receive the SAME parameter, so rotation is atomic.
577
640
  if (params.needsForwarder)
578
641
  secrets.FASTAGENT_INGRESS_SECRET = crypto.randomUUID();
579
- if (needsModelCredential) {
580
- failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
581
- }
642
+ gateOnModelCredential(needsModelCredential);
582
643
  // The params temp dir holds the ONE file carrying secret values (file:// parameter-overrides —
583
644
  // never argv); 0700/0600 and removed after the run, success or gate.
584
645
  const paramsDir = await mkdtemp(join(tmpdir(), "fastagent-agentcore-"));
@@ -601,13 +662,12 @@ async function runDeployAgentcore(params) {
601
662
  }, spawnRunner("aws", workspace), spawnRunner("docker", workspace), (m) => console.error(`[fastagent] ${m}`), async (content) => {
602
663
  const path = join(paramsDir, "params.json");
603
664
  await writeFile(path, content, { mode: 0o600 });
604
- await chmod(path, 0o600);
605
665
  return path;
606
666
  }, async (bytes) => {
607
667
  const path = join(paramsDir, "forwarder.zip");
608
668
  await writeFile(path, bytes);
609
669
  return path;
610
- }, (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind), (baseUrl) => registerSlackWebhook(baseUrl, { stateRoot: resolveStateRoot(agentDir) }));
670
+ }, registrarsFor(agentDir));
611
671
  if (!outcome.ok)
612
672
  failStartup(new Error(`deploy stopped: ${outcome.gate}`));
613
673
  console.error(`[fastagent] deployed → ${outcome.runtimeArn}`);
@@ -634,26 +694,15 @@ async function runDeployAgentcore(params) {
634
694
  * webhook) lives in {@link deployRailwayRun}; see there for why Railway differs from Fly.
635
695
  */
636
696
  async function runDeployRailway(params) {
637
- const { agentDir, workspace, name, modelAuth, modelKeyInDefinition, authPath, channels, longConnectionChannels, extraSecrets, intoLinked, dockerfilePath, } = params;
697
+ const { agentDir, workspace, name, channels, intoLinked, dockerfilePath } = params;
638
698
  const railway = spawnRunner("railway", workspace);
639
699
  // Fail fast if the railway CLI is absent (spawn ENOENT → 127), with the install link.
640
700
  if ((await railway(["--version"], { capture: true })).code === 127) {
641
701
  failStartup(new Error(`railway CLI not found — install it: https://docs.railway.com/guides/cli, then re-run`));
642
702
  }
643
- const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
644
- modelAuth,
645
- modelKeyInDefinition,
646
- authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
647
- channels,
648
- longConnectionChannels,
649
- extraSecrets,
650
- env: deployEnvironment(agentDir, channels),
651
- });
652
- // Model credential has its OWN remediation (login), distinct from a missing secret's (.env).
653
- if (needsModelCredential) {
654
- failStartup(new Error(`deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`));
655
- }
656
- 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) }));
703
+ const { secrets, missingSecrets, needsModelCredential } = await carryCredentials(params);
704
+ gateOnModelCredential(needsModelCredential);
705
+ const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked, dockerfilePath }, railway, (m) => console.error(`[fastagent] ${m}`), registrarsFor(agentDir));
657
706
  if (!outcome.ok)
658
707
  failStartup(new Error(`deploy stopped: ${outcome.gate}`));
659
708
  console.error(`[fastagent] deployed → ${outcome.url}`);
@@ -6,19 +6,15 @@
6
6
  import { resolve } from "node:path";
7
7
  import { runDevSupervisor } from "../../dev-supervisor.js";
8
8
  import { loadDotEnv } from "../../env.js";
9
- import { reportFindingsIfChanged, reportToolCollisions } from "../../engines/pi/report.js";
10
- import { reportModuleLoadFailures } from "../../log.js";
11
- import { CODING_TOOL_NAMES } from "../../engines/pi/create.js";
9
+ import { setLogLevel } from "../../log.js";
12
10
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
13
11
  import { mountAgentService } from "../../service.js";
14
- import { setLogLevel } from "../../log.js";
15
12
  import { logAgentLoop } from "../../observe.js";
16
13
  import { installProxyFetch } from "../../proxy.js";
17
- import { workspaceHint } from "../../paths.js";
18
14
  import { bindAddress } from "../../bind.js";
19
15
  import { failStartup, placementOrExit } from "../fail.js";
20
16
  import { SHUTDOWN_GRACE_MS, assertTunnelBindable, maybeTunnel, reportServing, serve } from "../serve.js";
21
- import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
17
+ import { parseBind, parsePort, reportAssembly, resolveFirstRunModel } from "../shared.js";
22
18
  export async function runDev(dirArg, opts) {
23
19
  const dir = resolve(dirArg);
24
20
  const placement = placementOrExit(dir);
@@ -54,15 +50,8 @@ async function serveOnce(dir, opts) {
54
50
  authPath: opts.authPath, // flag > FASTAGENT_AUTH_PATH > default — resolved by the opener (one owner)
55
51
  serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
56
52
  }).catch(failStartup);
57
- reportLine("agent", a.agentDir);
58
- reportLine("workspace", a.workspace);
59
- reportWorkspaceHint(workspaceHint(a));
60
- reportLine("config", a.configPath ?? "(none)");
61
- reportLine("model", `${a.modelSpec}${a.config.thinkingLevel ? ` (thinking: ${a.config.thinkingLevel})` : ""}`);
62
- await reportAuth(a.agentDir, a.modelSpec, a.authPath);
63
- reportAgentsSkillsTools(a);
64
- // Trace each turn's agent loop (tool calls + reply) to the log at debug level — shown in dev, gated
65
- // out in start (level info), keeping end-user content out of production logs. Wired in both postures.
53
+ // The same report `start` prints; `config:` is dev's own extra (see reportAssembly on the asymmetry).
54
+ await reportAssembly(a, { beforeModel: [["config", a.configPath ?? "(none)"]] });
66
55
  // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
67
56
  // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
68
57
  const configured = a.config.http?.host;
@@ -89,19 +78,3 @@ async function serveOnce(dir, opts) {
89
78
  onShutdown: () => service.close(),
90
79
  });
91
80
  }
92
- /** The agents/skills/tools/collisions report lines. */
93
- function reportAgentsSkillsTools(a) {
94
- reportLine("context", a.definition.contextFiles.map((f) => f.path).join(", ") || "(none)");
95
- if (a.definition.persona)
96
- reportLine("persona", "persona.md");
97
- reportLine("skills", a.definition.skills.map((s) => s.name).join(", ") || "(none)");
98
- reportLine("codingTools", CODING_TOOL_NAMES.join(", "));
99
- if (a.toolNames.length > 0)
100
- reportLine("tools", a.toolNames.join(", "));
101
- if (a.deferredToolNames.length > 0) {
102
- reportLine("deferred", `${a.deferredToolNames.join(", ")} (activated via search_tools)`);
103
- }
104
- reportToolCollisions(a.toolCollisions);
105
- reportModuleLoadFailures(a.toolFailures);
106
- reportFindingsIfChanged(a.definition.dir, a.definition);
107
- }
@@ -8,8 +8,7 @@ import { resolveStateRoot, workspaceHint } from "../../paths.js";
8
8
  import { CODING_TOOL_NAMES, resolveAgentTools } from "../../engines/pi/create.js";
9
9
  import { loadAgentDefinition } from "../../engines/pi/definition.js";
10
10
  import { reportFindingsIfChanged, reportToolCollisions } from "../../engines/pi/report.js";
11
- import { reportModuleLoadFailures } from "../../log.js";
12
- import { log } from "../../log.js";
11
+ import { reportModuleLoadFailures, log } from "../../log.js";
13
12
  import { nextRun } from "../../schedule/cron.js";
14
13
  import { loadSchedules } from "../../schedule/discover.js";
15
14
  import { failStartup, placementOrExit } from "../fail.js";
@@ -7,10 +7,9 @@
7
7
  */
8
8
  import { spawn } from "node:child_process";
9
9
  import { basename, join, resolve } from "node:path";
10
- import { DEFAULT_AGENT_DIRNAME, SECRETS_DIRNAME, agentsAt } from "../../paths.js";
10
+ import { DEFAULT_AGENT_DIRNAME, SECRETS_DIRNAME, agentsAt, displayPath } from "../../paths.js";
11
11
  import { detectRuntime, readPackageJson } from "../../runtime.js";
12
12
  import { agentDirName, agentDirNameError, scaffoldAgent } from "../../scaffold/init.js";
13
- import { displayPath } from "../../paths.js";
14
13
  import { failStartup, failUsage } from "../fail.js";
15
14
  export async function runInit(dirArg, opts) {
16
15
  const dir = resolve(dirArg);