@fastagent-sh/fastagent 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +13 -3
  2. package/dist/channels/agentcore-limits.d.ts +7 -0
  3. package/dist/channels/agentcore-limits.js +9 -0
  4. package/dist/channels/agentcore-state.d.ts +83 -0
  5. package/dist/channels/agentcore-state.js +258 -0
  6. package/dist/channels/agentcore.d.ts +98 -0
  7. package/dist/channels/agentcore.js +260 -0
  8. package/dist/channels/busy.d.ts +23 -0
  9. package/dist/channels/busy.js +53 -0
  10. package/dist/channels/context-buffer.d.ts +5 -0
  11. package/dist/channels/context-buffer.js +5 -0
  12. package/dist/channels/feishu/context-buffer.d.ts +13 -8
  13. package/dist/channels/feishu/context-buffer.js +60 -6
  14. package/dist/channels/feishu/feishu-api.js +4 -1
  15. package/dist/channels/feishu/feishu.d.ts +0 -12
  16. package/dist/channels/feishu/feishu.js +143 -54
  17. package/dist/channels/feishu/invoke-turn.js +35 -18
  18. package/dist/channels/feishu/model.d.ts +0 -1
  19. package/dist/channels/feishu/normalize.js +0 -1
  20. package/dist/channels/feishu/parse.d.ts +21 -7
  21. package/dist/channels/feishu/parse.js +24 -7
  22. package/dist/channels/feishu/preview.js +3 -2
  23. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -1
  26. package/dist/channels/feishu/setup-mode.js +27 -1
  27. package/dist/channels/github/github.js +8 -1
  28. package/dist/channels/http.js +1 -1
  29. package/dist/channels/lark/scaffold/channel.ts +9 -8
  30. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  31. package/dist/channels/preview-kit.d.ts +7 -1
  32. package/dist/channels/preview-kit.js +3 -2
  33. package/dist/channels/slack/parse.d.ts +16 -1
  34. package/dist/channels/slack/parse.js +46 -3
  35. package/dist/channels/slack/preview.d.ts +1 -2
  36. package/dist/channels/slack/preview.js +68 -24
  37. package/dist/channels/slack/scaffold/channel.ts +5 -5
  38. package/dist/channels/slack/slack-api.d.ts +3 -23
  39. package/dist/channels/slack/slack-api.js +6 -22
  40. package/dist/channels/slack/slack.d.ts +13 -20
  41. package/dist/channels/slack/slack.js +95 -50
  42. package/dist/channels/state.d.ts +11 -4
  43. package/dist/channels/state.js +19 -12
  44. package/dist/channels/tasks.d.ts +0 -6
  45. package/dist/channels/tasks.js +16 -1
  46. package/dist/channels/telegram/parse.d.ts +0 -7
  47. package/dist/channels/telegram/parse.js +4 -2
  48. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  49. package/dist/channels/telegram/telegram.js +1 -1
  50. package/dist/channels/text.d.ts +14 -0
  51. package/dist/channels/text.js +14 -0
  52. package/dist/channels/thread-participants.d.ts +21 -0
  53. package/dist/channels/thread-participants.js +132 -0
  54. package/dist/channels/turn-queue.js +7 -0
  55. package/dist/cli/add-feishu.d.ts +7 -4
  56. package/dist/cli/add-feishu.js +57 -37
  57. package/dist/cli/add-slack.d.ts +2 -1
  58. package/dist/cli/add-slack.js +6 -11
  59. package/dist/cli/commands/add.js +50 -51
  60. package/dist/cli/commands/attach.js +8 -4
  61. package/dist/cli/commands/chat.js +8 -8
  62. package/dist/cli/commands/deploy.d.ts +1 -1
  63. package/dist/cli/commands/deploy.js +323 -77
  64. package/dist/cli/commands/dev.js +24 -22
  65. package/dist/cli/commands/fire.js +15 -16
  66. package/dist/cli/commands/info.js +36 -29
  67. package/dist/cli/commands/init.d.ts +1 -1
  68. package/dist/cli/commands/init.js +65 -53
  69. package/dist/cli/commands/invoke.js +9 -6
  70. package/dist/cli/commands/login.js +35 -21
  71. package/dist/cli/commands/schedule.js +6 -8
  72. package/dist/cli/commands/start.js +93 -37
  73. package/dist/cli/commands/tool.js +22 -18
  74. package/dist/cli/fail.d.ts +17 -0
  75. package/dist/cli/fail.js +24 -0
  76. package/dist/cli/program.js +57 -36
  77. package/dist/cli/serve.d.ts +26 -6
  78. package/dist/cli/serve.js +62 -15
  79. package/dist/cli/shared.d.ts +15 -2
  80. package/dist/cli/shared.js +30 -18
  81. package/dist/deploy/agentcore/plan.d.ts +117 -0
  82. package/dist/deploy/agentcore/plan.js +721 -0
  83. package/dist/deploy/agentcore/run.d.ts +73 -0
  84. package/dist/deploy/agentcore/run.js +412 -0
  85. package/dist/deploy/agentcore/zip.d.ts +17 -0
  86. package/dist/deploy/agentcore/zip.js +68 -0
  87. package/dist/deploy/container.d.ts +26 -25
  88. package/dist/deploy/container.js +93 -89
  89. package/dist/deploy/docker/plan.d.ts +1 -1
  90. package/dist/deploy/docker/plan.js +12 -17
  91. package/dist/deploy/fly/plan.d.ts +2 -0
  92. package/dist/deploy/fly/plan.js +27 -19
  93. package/dist/deploy/fly/run.d.ts +12 -1
  94. package/dist/deploy/fly/run.js +36 -2
  95. package/dist/deploy/preflight.d.ts +11 -5
  96. package/dist/deploy/preflight.js +217 -65
  97. package/dist/deploy/railway/plan.d.ts +7 -0
  98. package/dist/deploy/railway/plan.js +41 -16
  99. package/dist/deploy/railway/run.d.ts +8 -1
  100. package/dist/deploy/railway/run.js +7 -2
  101. package/dist/deploy/runner.d.ts +5 -2
  102. package/dist/deploy/runner.js +9 -3
  103. package/dist/dev-supervisor.d.ts +11 -8
  104. package/dist/dev-supervisor.js +53 -51
  105. package/dist/engines/pi/auth.d.ts +8 -7
  106. package/dist/engines/pi/auth.js +12 -10
  107. package/dist/engines/pi/channel.d.ts +1 -1
  108. package/dist/engines/pi/channel.js +5 -5
  109. package/dist/engines/pi/chat.js +2 -2
  110. package/dist/engines/pi/config.d.ts +6 -46
  111. package/dist/engines/pi/config.js +21 -108
  112. package/dist/engines/pi/create.d.ts +8 -8
  113. package/dist/engines/pi/create.js +13 -12
  114. package/dist/engines/pi/definition.d.ts +7 -26
  115. package/dist/engines/pi/definition.js +8 -54
  116. package/dist/engines/pi/login.d.ts +1 -1
  117. package/dist/engines/pi/models.d.ts +3 -3
  118. package/dist/engines/pi/models.js +1 -1
  119. package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
  120. package/dist/engines/pi/{workspace.js → open.js} +27 -29
  121. package/dist/engines/pi/session-builder.d.ts +2 -2
  122. package/dist/engines/pi/session-builder.js +11 -11
  123. package/dist/engines/pi/tool.js +4 -0
  124. package/dist/env.d.ts +16 -4
  125. package/dist/env.js +43 -5
  126. package/dist/host/node.d.ts +2 -2
  127. package/dist/loader.d.ts +2 -2
  128. package/dist/loader.js +3 -3
  129. package/dist/log.d.ts +1 -1
  130. package/dist/log.js +1 -1
  131. package/dist/paths.d.ts +138 -0
  132. package/dist/paths.js +326 -0
  133. package/dist/pi.d.ts +1 -1
  134. package/dist/pi.js +2 -2
  135. package/dist/runtime.d.ts +7 -5
  136. package/dist/runtime.js +2 -2
  137. package/dist/scaffold/add-channel.d.ts +7 -3
  138. package/dist/scaffold/add-channel.js +55 -29
  139. package/dist/scaffold/init.d.ts +32 -41
  140. package/dist/scaffold/init.js +161 -185
  141. package/dist/scaffold/templates/env.example +15 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +1 -1
  143. package/dist/scaffold/templates/gitignore +14 -6
  144. package/dist/scaffold/templates/persona.md +4 -2
  145. package/dist/scaffold/templates/secrets.gitignore +5 -0
  146. package/dist/scaffold/templates.d.ts +1 -7
  147. package/dist/scaffold/templates.js +3 -25
  148. package/dist/scaffold/vendor-skill.d.ts +2 -2
  149. package/dist/scaffold/vendor-skill.js +13 -13
  150. package/dist/schedule/discover.js +4 -4
  151. package/dist/schedule/scheduler.d.ts +40 -1
  152. package/dist/schedule/scheduler.js +89 -56
  153. package/dist/schedule/state.js +1 -1
  154. package/dist/schedule/wake-alarm.d.ts +47 -0
  155. package/dist/schedule/wake-alarm.js +136 -0
  156. package/dist/schedule/wakeups.d.ts +1 -0
  157. package/dist/schedule/wakeups.js +18 -0
  158. package/dist/tunnel.d.ts +3 -3
  159. package/dist/tunnel.js +7 -7
  160. package/package.json +4 -1
  161. package/dist/channels/feishu/owned-threads.d.ts +0 -7
  162. package/dist/channels/feishu/owned-threads.js +0 -47
  163. package/dist/channels/slack/owned-threads.d.ts +0 -6
  164. package/dist/channels/slack/owned-threads.js +0 -43
  165. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  166. package/dist/scaffold/templates/gitignore.kit +0 -2
  167. package/dist/workspace.d.ts +0 -9
  168. package/dist/workspace.js +0 -45
@@ -9,23 +9,59 @@
9
9
  * the run modules' {@link import("./fly/run.ts").FlyRunOutcome}: a model that won't travel is a GATE the
10
10
  * CLI stops on, distinct from the advisory warnings/notes it prints and proceeds past.
11
11
  */
12
- import { readFile } from "node:fs/promises";
13
- import { join, relative, sep } from "node:path";
12
+ import { readdir, readFile } from "node:fs/promises";
13
+ import { basename, isAbsolute, join, relative, sep } from "node:path";
14
+ import ignore from "ignore";
14
15
  import { resolveAuthPath } from "../engines/pi/config.js";
16
+ import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
15
17
  import { inspectChannels } from "../engines/pi/channel.js";
16
18
  import { discoverScheduleFiles } from "../schedule/discover.js";
17
19
  import { createPiModels, probeAuthSource } from "../engines/pi/models.js";
18
20
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
19
- import { exists } from "../scaffold/init.js";
21
+ import { exists } from "../paths.js";
20
22
  import { detectRuntime, readPackageJson } from "../runtime.js";
21
23
  import { fastagentVersion } from "../version.js";
22
- import { isGeneratedDockerfile } from "./container.js";
24
+ import { isGeneratedDockerfile, isGeneratedDockerignore } from "./container.js";
25
+ /**
26
+ * "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore`
27
+ * matcher (the same library the workspace ignore files use), so `!` negation and last-match-wins are
28
+ * the library's problem, not ours. Anchoring is normalized: dockerignore patterns are root-anchored
29
+ * while .gitignore's match at any depth, so a bare `foo` becomes `/foo` — without that, a root-only
30
+ * `.secrets` line would read as covering `fastagent/.secrets` and hand back a false all-clear on the
31
+ * exact check that guards credentials.
32
+ *
33
+ * Known dialect gap: `ignore` keeps git's rule that a path under an EXCLUDED directory cannot be
34
+ * re-included, which docker does not have — so an allowlist file (`*` + `!fastagent` + `!fastagent/**`)
35
+ * can read as excluding a path docker would ship. The callers below absorb that: the drop-the-agent
36
+ * gate requires the agent DIRECTORY itself to read as excluded too, which an allowlist re-includes.
37
+ */
38
+ function dockerignoreMatcher(text) {
39
+ const anchored = text
40
+ .split("\n")
41
+ .map((raw) => {
42
+ const line = raw.trim();
43
+ if (line === "" || line.startsWith("#"))
44
+ return line;
45
+ const negated = line.startsWith("!");
46
+ const pattern = negated ? line.slice(1) : line;
47
+ if (pattern.startsWith("/") || pattern.startsWith("**/"))
48
+ return line;
49
+ return `${negated ? "!" : ""}/${pattern}`;
50
+ })
51
+ .join("\n");
52
+ const matcher = ignore({ ignorecase: false }).add(anchored);
53
+ return (path) => matcher.ignores(path);
54
+ }
23
55
  /**
24
56
  * Run the host-neutral pre-flight. Throws on a real fault (an unreadable channels/ dir, a throwing
25
57
  * provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
26
58
  */
27
59
  export async function preflightDeploy(input) {
28
- const { target, agentDir, config, modelSpec, run, force, authPathFlag } = input;
60
+ const { placement: { agentDir, workspace }, config, modelSpec, run, force, externalClock, authPathFlag, } = input;
61
+ // The ONE derived placement fact every host plan needs: where the agent's files sit relative to the
62
+ // build context (the workspace). Nested → "fastagent/"; flat → "" (the agent IS the workspace root).
63
+ const nested = agentDir !== workspace;
64
+ const agentPrefix = nested ? `${basename(agentDir)}/` : "";
29
65
  const messages = [];
30
66
  // The deployed box resolves the model from fastagent.config.ts ONLY (in the image); a model set via
31
67
  // env/flag/.env doesn't travel. `--run` would ship a known crash-loop — hard gate; generate-only warns.
@@ -74,14 +110,14 @@ export async function preflightDeploy(input) {
74
110
  // ("the generated plan…"): in KEEP mode an existing fly.toml is not rewritten — the CLI warns separately
75
111
  // when a kept fly.toml still scales to zero.
76
112
  const hasTimeTriggers = (await discoverScheduleFiles(agentDir)).length > 0 || !!config.selfSchedule;
77
- if (longConnectionChannels.length > 0) {
113
+ if (longConnectionChannels.length > 0 && !externalClock) {
78
114
  messages.push({
79
115
  level: "note",
80
116
  text: `long-connection channel present (${longConnectionChannels.join(", ")}) — a GENERATED plan keeps one machine running ` +
81
117
  `(an outbound connection cannot wake a scaled-to-zero service).`,
82
118
  });
83
119
  }
84
- if (hasTimeTriggers) {
120
+ if (hasTimeTriggers && !externalClock) {
85
121
  messages.push({
86
122
  level: "note",
87
123
  text: `schedules/self-scheduling present — a GENERATED plan keeps one machine running (cron/wake has ` +
@@ -90,46 +126,37 @@ export async function preflightDeploy(input) {
90
126
  }
91
127
  // Probe auth from the SAME project-level file the opener/login use — not the global default, which would
92
128
  // miss a `fastagent login` credential and falsely report "none configured".
93
- const authPath = resolveAuthPath(target, authPathFlag);
129
+ const authPath = resolveAuthPath(agentDir, authPathFlag);
94
130
  const modelAuth = modelSpec ? await probeAuthSource(createPiModels({ authPath }), modelSpec) : undefined;
95
- // Container facts (shared by every host) + the warnings that follow. Repo-as-workspace layout
96
- // (agentDir target): the facts describe the KIT its package.json/runtime/lockfile drive the
97
- // image's install step never the host repo's (whose manifest belongs to the host's own deploy).
98
- // POSIX-normalized: kitDir lands verbatim in Dockerfile COPY/CMD lines and fly/railway commands,
99
- // which all require forward slashes (a Windows `relative()` would emit backslashes).
100
- const kitDir = agentDir === target ? undefined : relative(target, agentDir).split(sep).join("/");
101
- const factsDir = kitDir ? agentDir : target;
102
- if (kitDir && run) {
103
- // The repo-as-workspace deployment shape remains experimental for every target. Generation +
104
- // runbook are supported; automated runners stay gated until an explicit end-to-end smoke validates
105
- // context packing, ignore rules, installed deps, state, and write-back for this layout.
106
- return {
107
- ok: false,
108
- gate: `--run is not yet supported for the agentDir layout — run the same deploy without --run and follow the printed runbook`,
109
- };
110
- }
111
- const hasPackageJson = await exists(join(factsDir, "package.json"));
112
- const pkg = await readPackageJson(factsDir);
113
- const { runtime, bunVersion, hasLockfile } = detectRuntime(factsDir, pkg);
131
+ // Container facts (shared by every host) + the warnings that follow. The facts describe the AGENT —
132
+ // its package.json/runtime/lockfile drive the image's install stepnever the workspace's (the bake
133
+ // ships the whole tree, but the workspace's own manifest belongs to its own deploy).
134
+ const hasPackageJson = await exists(join(agentDir, "package.json"));
135
+ const pkg = await readPackageJson(agentDir);
136
+ const { runtime, bunVersion, hasLockfile } = detectRuntime(agentDir, pkg);
114
137
  const install = runtime === "bun" ? "bun install" : "npm install";
115
138
  const runner = runtime === "bun" ? "bun run fastagent" : "./node_modules/.bin/fastagent";
116
139
  const hasOtherLock = runtime === "node" &&
117
- ((await exists(join(factsDir, "pnpm-lock.yaml"))) || (await exists(join(factsDir, "yarn.lock"))));
118
- if (kitDir) {
119
- // After the facts: the deps sentence must match the kit's actual shape (a markdown-only kit has no
120
- // package.json and installs nothing — the note must not point at a file that doesn't exist).
121
- const deps = hasPackageJson
122
- ? `only the kit's deps (${kitDir}/package.json) are installed — the host repo's own deps are the agent's runtime concern`
123
- : `the kit has no package.json, so no deps are installed (the pinned global CLI serves the repo)`;
124
- messages.push({
125
- level: "note",
126
- text: `repo-as-workspace image (EXPERIMENTAL — not yet verified end-to-end on a real host): the whole ` +
127
- `repo is baked as the agent's cwd; ${deps}. Un-pushed changes on the box do not survive a redeploy ` +
128
- `(the image is a snapshot); write-back goes through git (persona owns the policy; GH_TOKEN etc. go ` +
129
- `in config.deploy.secrets see the runbook's caveat on .git surviving the host's upload).`,
130
- });
131
- }
132
- // A code workspace with no lockfile builds via a non-frozen install (ranges resolve at build time) — not
140
+ ((await exists(join(agentDir, "pnpm-lock.yaml"))) || (await exists(join(agentDir, "yarn.lock"))));
141
+ // Does the baked workspace ship a `.git`? ONE fact driving both the image's git install (below)
142
+ // and the plans' runbook wording the write-back loop needs the history AND the binary together.
143
+ const shipsGit = await exists(join(workspace, ".git"));
144
+ // After the facts: the deps sentence must match the agent's actual shape (a markdown-only agent has
145
+ // no package.json and installs nothing — the note must not point at a file that doesn't exist).
146
+ const deps = hasPackageJson
147
+ ? `only the agent's deps (${agentPrefix}package.json) are installed${nested ? " — the workspace's own deps are the agent's runtime concern" : ""}`
148
+ : `the agent has no package.json, so no deps are installed (the pinned global CLI serves the directory)`;
149
+ const durability = shipsGit
150
+ ? `Un-pushed changes on the box do not survive a redeploy; freshness and write-back run through git, ` +
151
+ `driven by the agent itself (persona owns the policy; GH_TOKEN etc. go in config.deploy.secrets)`
152
+ : `no .git here, so no history ships and the image does not install git changes on the box are ` +
153
+ `ephemeral and do not survive a redeploy`;
154
+ messages.push({
155
+ level: "note",
156
+ text: `the whole directory is baked as the agent's workspace (WYSIWYG — what you see is what ships, ` +
157
+ `git or not, clean or not); ${deps}. ${durability}.`,
158
+ });
159
+ // A code agent with no lockfile builds via a non-frozen install (ranges resolve at build time) — not
133
160
  // reproducible. A pnpm/yarn user gets an accurate message (their lockfile is ignored by the npm Dockerfile).
134
161
  if (hasPackageJson && !hasLockfile) {
135
162
  const lock = runtime === "bun" ? "bun.lock" : "package-lock.json";
@@ -142,7 +169,7 @@ export async function preflightDeploy(input) {
142
169
  `Run \`${install}\` and commit the lockfile for pinned redeploys.`,
143
170
  });
144
171
  }
145
- // The code-path Dockerfile runs `${runner}` — the workspace's OWN local dependency, never the
172
+ // The code-path Dockerfile runs `${runner}` — the agent's OWN local dependency, never the
146
173
  // registry — so a package.json missing it means the container fails at start (no bin to run).
147
174
  if (hasPackageJson && !("@fastagent-sh/fastagent" in { ...pkg.dependencies, ...pkg.devDependencies })) {
148
175
  messages.push({
@@ -151,42 +178,167 @@ export async function preflightDeploy(input) {
151
178
  `so the container fails at start. Add it to dependencies and re-run \`${install}\`.`,
152
179
  });
153
180
  }
154
- // A kept host root .dockerignore silently replaces KIT_DOCKERIGNORE's two protections — read it and
155
- // warn SPECIFICALLY (the generic "kept" line suggests --force, which would clobber the host's file):
156
- // (a) a .git exclude kills the baked write-back (the runtime-clone fallback applies); (b) without a
157
- // recursive **/node_modules the build machine's kit deps (native binaries) clobber the image's.
158
- // Not force-gated: the host's root .dockerignore is kept even under --force (never ours to clobber),
159
- // so these warnings apply regardless.
160
- if (kitDir && (await exists(join(target, ".dockerignore")))) {
161
- const lines = (await readFile(join(target, ".dockerignore"), "utf8")).split("\n").map((l) => l.trim());
162
- if (lines.some((l) => l === ".git" || l === "/.git" || l === ".git/" || l === "**/.git")) {
181
+ // A KEPT workspace-root .dockerignore silently replaces the generated one's protections — so ASK IT
182
+ // about the exact paths that matter (the generic "kept" line suggests --force, which never clobbers
183
+ // the workspace's own file). Two are GATES under --run, same discipline as the model-travel gate:
184
+ // dropping the agent dir ships a context with no persona/config (the box crash-loops), and an
185
+ // unexcluded secrets path BAKES CREDENTIALS INTO THE IMAGE. The other two are advisory: the build
186
+ // machine's node_modules (native binaries for YOUR OS) clobbering the image's, and an excluded .git
187
+ // killing the agent's pull/push loop (a legitimate slimming choice). Not force-gated — the file is
188
+ // kept even under --force.
189
+ // Which paths INSIDE the build context hold secrets resolved, then made workspace-relative (the
190
+ // context root). An external secrets dir (a mounted volume) is outside the context: nothing to check
191
+ // and nothing to exclude. Also fed to the generated .dockerignore, so a custom in-tree dir is
192
+ // excluded by PATH even though its name is not `.secrets`.
193
+ const inContext = (p) => {
194
+ const rel = relative(workspace, p);
195
+ return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split(sep).join("/");
196
+ };
197
+ // The secrets DIR is the unit, not the two filenames we happen to know: an atomic-write temp beside
198
+ // auth.json, a second key file, an editor backup of `.env` — all of it must stay out of the image, and
199
+ // `resolveSecretsDir` says as much ("everything fastagent manages that must never leave the machine").
200
+ // The auth path adds an entry only when an override puts it OUTSIDE that dir. An external secrets dir
201
+ // (the deployed posture: a mounted volume) is outside the context — nothing to check, nothing to
202
+ // exclude.
203
+ const secretsRel = inContext(resolveSecretsDir(agentDir));
204
+ const authRel = inContext(authPath);
205
+ const authElsewhere = authRel !== undefined && (secretsRel === undefined || !authRel.startsWith(`${secretsRel}/`));
206
+ const secretPaths = [...(secretsRel ? [secretsRel] : []), ...(authElsewhere ? [authRel] : [])];
207
+ // ONE rule for every checked path: a file that is not there cannot be baked, so gating on it would
208
+ // be a refusal about a spelling rather than about what would ship (an agent that has never run
209
+ // `login` has no auth.json). The generated .dockerignore still excludes them unconditionally —
210
+ // cheap, and correct the moment they appear.
211
+ const present = async (rels) => {
212
+ const found = [];
213
+ for (const rel of rels)
214
+ if (await exists(join(workspace, rel)))
215
+ found.push(rel);
216
+ return found;
217
+ };
218
+ // State gets the same treatment (a custom in-tree FASTAGENT_STATE_DIR is invisible to the
219
+ // name-based `**/.state`), at warn level: shipping stale sessions is waste, not a credential leak.
220
+ const stateRel = inContext(resolveStateRoot(agentDir));
221
+ // Existence gates the WARNING, never the generated exclude (same split as secretPaths vs
222
+ // leakCandidates): an agent that has never run has no `.state/`, so telling its author a kept ignore
223
+ // file fails to exclude one is a remark about a spelling — while the file we generate must still carry
224
+ // the line, since it is written once and correct the moment the directory appears.
225
+ const stateShips = stateRel !== undefined && (await exists(join(workspace, stateRel))) ? stateRel : undefined;
226
+ // The `.env` family at the two levels fastagent is RESPONSIBLE for: the agent dir and the workspace
227
+ // root. Asking only about a root-level `.env` missed both halves that matter — an `<agent>/.env`, the
228
+ // file habit puts there (env.ts warns about it by name), and the `.env.local` / `.env.production`
229
+ // spellings. DISCOVERED rather than spelled, so the existing rule still holds: only a file that is
230
+ // there can be baked, so only it is gated.
231
+ //
232
+ // Deliberately NOT the recursive `**/.env` the generated file carries: walking a whole monorepo for
233
+ // credential files is a secret scanner, not a placement pre-flight, and a bounded walk would be a
234
+ // heuristic pretending to be a guarantee. The division of responsibility is the honest one — the file
235
+ // WE generate covers every level; an author who keeps their own owns its coverage of their own tree,
236
+ // and this gate speaks only for the paths fastagent itself puts credentials in.
237
+ const dotEnvFiles = async (relDir) => {
238
+ const names = await readdir(join(workspace, relDir || ".")).catch(() => []);
239
+ // POSIX separators, like every other context-relative path here (`inContext`): these strings are
240
+ // matched against dockerignore patterns, and a Windows `fastagent\.env` would match none of them —
241
+ // silently turning the one check whose failure mode is "credentials in a published image" into a
242
+ // no-op.
243
+ return names
244
+ .filter((n) => (n === ".env" || n.startsWith(".env.")) && n !== ".env.example")
245
+ .map((n) => join(relDir, n).split(sep).join("/"));
246
+ };
247
+ const envFiles = (await Promise.all([...new Set(["", agentPrefix])].map(dotEnvFiles))).flat();
248
+ const leakCandidates = [...(await present(secretPaths)), ...envFiles];
249
+ // Same existence rule: a node_modules that is not there cannot be uploaded.
250
+ const depDirs = await present([...new Set([`${agentPrefix}node_modules`, "node_modules"])]);
251
+ const machineryPaths = [...secretPaths, ...(stateRel ? [stateRel] : [])];
252
+ // BOTH ignore files deploy emits get the same interrogation. The workspace-root one is what
253
+ // flyctl/railway's packers read; the per-Dockerfile one is what BuildKit PREFERS for a plain
254
+ // `docker build` (so it is the file that actually decides `deploy docker`). Checking only the root one
255
+ // left the credential gate not covering the path it was written for.
256
+ for (const rel of [".dockerignore", `${agentPrefix}Dockerfile.dockerignore`]) {
257
+ const kept = await readFile(join(workspace, rel), "utf8").catch(() => undefined);
258
+ if (kept === undefined)
259
+ continue;
260
+ // One WE generated is regenerated by this very run under --force, so checking the stale content on
261
+ // disk would gate a deploy on a file about to be replaced. Ours + --force: skip. Ours WITHOUT --force
262
+ // is still checked (it is what would ship), but the remedy differs: hand-adding lines to fastagent's
263
+ // own output is not the fix — regenerating it is.
264
+ const keptIsOurs = isGeneratedDockerignore(kept);
265
+ if (force && keptIsOurs)
266
+ continue;
267
+ const remedy = (lines) => keptIsOurs
268
+ ? `Re-run with --force to regenerate it.`
269
+ : `Add ${lines.map((p) => `\`${p}\``).join(" and ")} before deploying (the same lines the generated ${rel} writes).`;
270
+ const excluded = dockerignoreMatcher(kept);
271
+ // Asked as a DIRECTORY (trailing slash), which is what it is. A bare-name test answers `false` for
272
+ // the `fastagent/` spelling — a directory-only pattern, and the one a hand-written ignore file is
273
+ // most likely to carry — so the agent would be dropped from the context with no warning at all. The
274
+ // pairing this replaced (the directory AND a file inside it) was dead weight rather than a
275
+ // safeguard: git's rule that a file under an excluded directory cannot be re-included is
276
+ // implemented by the matcher, so `excluded(dir)` already implies `excluded(dir/persona.md)` and the
277
+ // second test could never change the answer. What it was aimed at — an allowlist (`*` +
278
+ // `!fastagent` + `!fastagent/**`) that re-includes the agent — is handled by the first test alone,
279
+ // which reads `false` there, as it should.
280
+ if (nested && excluded(`${basename(agentDir)}/`)) {
281
+ const text = `your ${rel} (kept) excludes \`${basename(agentDir)}\` — the build context would ship WITHOUT the ` +
282
+ `agent entirely (the deployed box has no persona/config and crash-loops). Remove that rule ` +
283
+ `before deploying.`;
284
+ if (run)
285
+ return { ok: false, gate: text };
286
+ messages.push({ level: "warn", text });
287
+ }
288
+ // Resolved paths, not spellings: dockerignore patterns are root-anchored (unlike .gitignore), so a
289
+ // bare `.secrets` line does not cover `fastagent/.secrets` — and FASTAGENT_SECRETS_DIR /
290
+ // FASTAGENT_AUTH_PATH can put credentials anywhere in the baked tree, where the name-based excludes
291
+ // never reach. This is the one check whose failure mode is "credentials in a published image".
292
+ const leaks = leakCandidates.filter((p) => !excluded(p));
293
+ if (leaks.length > 0) {
294
+ const text = `your ${rel} (kept) does not exclude ${leaks.map((p) => `\`${p}\``).join(", ")} — the build ` +
295
+ `context would BAKE SECRETS INTO THE IMAGE. ${remedy(leaks.map((p) => `/${p}`))}`;
296
+ if (run)
297
+ return { ok: false, gate: text };
298
+ messages.push({ level: "warn", text });
299
+ }
300
+ if (stateShips && !excluded(`${stateShips}/sessions`)) {
163
301
  messages.push({
164
302
  level: "warn",
165
- text: `your .dockerignore excludes .git — the baked repo ships WITHOUT history/remote, so the agent ` +
166
- `cannot commit/push the baked copy; it must \`git clone\` its repo in the workspace instead ` +
167
- `(or remove the .git line).`,
303
+ text: `your ${rel} (kept) does not exclude \`${stateRel}\` — the build machine's sessions/channel state would ship in the image. ${remedy([`/${stateRel}`])}`,
168
304
  });
169
305
  }
170
- if (!lines.some((l) => l === "**/node_modules" || l === "**/node_modules/")) {
306
+ // Both the agent's own node_modules and the workspace's: either would upload the build machine's
307
+ // deps (native binaries for YOUR OS) and clobber the image's freshly-installed ones. Named by the
308
+ // PATHS actually found unexcluded, like every other check here — not by a rule's spelling.
309
+ const unexcludedDeps = depDirs.filter((p) => !excluded(`${p}/.package-lock.json`));
310
+ if (unexcludedDeps.length > 0) {
171
311
  messages.push({
172
312
  level: "warn",
173
- text: `your .dockerignore lacks \`**/node_modules\` the build machine's ${kitDir}/node_modules ` +
174
- `(native binaries for YOUR OS) would be uploaded and clobber the image's freshly-installed ones. ` +
175
- `Add \`**/node_modules\` to it.`,
313
+ text: `your ${rel} does not exclude ${unexcludedDeps.map((p) => `\`${p}\``).join(" or ")} — the ` +
314
+ `build machine's deps (native binaries for YOUR OS) would be uploaded and clobber the image's ` +
315
+ `freshly-installed ones. ${remedy(unexcludedDeps.map((p) => `/${p}`))}`,
316
+ });
317
+ }
318
+ if (excluded(".git/HEAD")) {
319
+ messages.push({
320
+ level: "note",
321
+ text: `your ${rel} excludes .git — the baked copy ships WITHOUT history/remote, so the agent ` +
322
+ `cannot pull/commit/push it; it must \`git clone\` its repo in the workspace instead (or remove the .git line).`,
176
323
  });
177
324
  }
178
325
  }
179
- // Write-back mechanics are fastagent's (the policy is the persona's): a kit-layout image always
180
- // carries git, so commit/push can work at all. Merged with (never duplicating) config.deploy.apt.
181
- const apt = kitDir ? [...new Set(["git", ...(config.deploy?.apt ?? [])])] : config.deploy?.apt;
326
+ // Write-back mechanics are fastagent's (the policy is the persona's): the image carries the git
327
+ // BINARY iff the baked workspace ships a `.git` (history without the binary is a
328
+ // dead loop; the binary without history is dead weight). A non-git workspace that still needs git
329
+ // (the agent clones repos as its job) declares config.deploy.apt: ["git"] explicitly. Merged with
330
+ // (never duplicating) config.deploy.apt.
331
+ const apt = shipsGit ? [...new Set(["git", ...(config.deploy?.apt ?? [])])] : config.deploy?.apt;
182
332
  const container = {
333
+ agentPrefix,
334
+ machineryPaths,
183
335
  hasPackageJson,
184
336
  runtime,
185
337
  bunVersion,
186
338
  hasLockfile,
187
339
  version: await fastagentVersion(),
188
340
  apt,
189
- kitDir,
341
+ shipsGit,
190
342
  };
191
343
  const port = config.http?.port ?? 8787;
192
344
  // What the agent declared it needs on the box (fastagent.config deploy.secrets) — carried like channel
@@ -195,7 +347,7 @@ export async function preflightDeploy(input) {
195
347
  // deploy.apt only shapes the GENERATED Dockerfile. Warn ONLY when the kept Dockerfile is HAND-WRITTEN
196
348
  // (its apt won't include these) — a fastagent-generated one is handled by writeArtifacts. Don't suggest
197
349
  // --force here: it would overwrite the user's hand-written file.
198
- const dockerfileHome = kitDir ? join(agentDir, "Dockerfile") : join(target, "Dockerfile");
350
+ const dockerfileHome = join(agentDir, "Dockerfile");
199
351
  if (config.deploy?.apt?.length && !force && (await exists(dockerfileHome))) {
200
352
  if (!isGeneratedDockerfile(await readFile(dockerfileHome, "utf8"))) {
201
353
  messages.push({
@@ -46,5 +46,12 @@ export interface RailwayPlan {
46
46
  /** The ordered, values-resolved deploy runbook — printed to stdout for the coding agent to execute. */
47
47
  runbook: string[];
48
48
  }
49
+ /** The `RAILWAY_DOCKERFILE_PATH` value for an agent under `prefix` — repo-root-anchored with a leading
50
+ * slash, the form Railway's builds/dockerfiles docs use for a Dockerfile in another directory. The
51
+ * config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
52
+ * convention); two mechanisms, two documented spellings, one fact each. */
53
+ export declare const dockerfilePathVar: (prefix: string) => string;
54
+ /** Did fastagent generate this `railway.json`? Unparseable or unmarked reads as the author's. */
55
+ export declare function isGeneratedRailwayJson(content: string): boolean;
49
56
  /** Compute the Railway deploy plan from the resolved definition. */
50
57
  export declare function planRailwayDeploy(input: RailwayPlanInput): RailwayPlan;
@@ -2,25 +2,47 @@ import { containerArtifacts } from "../container.js";
2
2
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
3
3
  /** State root = the volume mount path, kept in lockstep. `/data` matches the Fly recipe. */
4
4
  const MOUNT = "/data";
5
+ /** The `RAILWAY_DOCKERFILE_PATH` value for an agent under `prefix` — repo-root-anchored with a leading
6
+ * slash, the form Railway's builds/dockerfiles docs use for a Dockerfile in another directory. The
7
+ * config file's `dockerfilePath` spells it WITHOUT the slash (the config-as-code schema's own
8
+ * convention); two mechanisms, two documented spellings, one fact each. */
9
+ export const dockerfilePathVar = (prefix) => `/${prefix}Dockerfile`;
5
10
  /** railway.json — build/deploy only (Railway's config-as-code scope). No env/volume/sleeping here: those
6
11
  * are service settings the runbook applies via CLI. healthcheckPath gates routing on a live server. */
7
- function railwayJson(kitDir) {
12
+ /** railway.json is JSON, so its ownership marker is a KEY rather than a comment line. Railway ignores
13
+ * unknown keys; the predicate below is what lets `--force` reset OUR file and keep a hand-written one. */
14
+ const GENERATED_RAILWAY_KEY = "x-generated-by";
15
+ const GENERATED_RAILWAY_VALUE = "fastagent deploy railway";
16
+ /** Did fastagent generate this `railway.json`? Unparseable or unmarked reads as the author's. */
17
+ export function isGeneratedRailwayJson(content) {
18
+ try {
19
+ return JSON.parse(content)[GENERATED_RAILWAY_KEY] === GENERATED_RAILWAY_VALUE;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ function railwayJson(prefix) {
8
26
  return `${JSON.stringify({
9
27
  $schema: "https://railway.com/railway.schema.json",
10
- // dockerfilePath is relative to the repo root (`railway up`'s upload context) in BOTH layouts.
11
- build: { builder: "DOCKERFILE", dockerfilePath: kitDir ? `${kitDir}/Dockerfile` : "Dockerfile" },
28
+ [GENERATED_RAILWAY_KEY]: GENERATED_RAILWAY_VALUE,
29
+ // dockerfilePath is relative to the workspace root (`railway up`'s upload context).
30
+ build: { builder: "DOCKERFILE", dockerfilePath: `${prefix}Dockerfile` },
12
31
  deploy: { healthcheckPath: "/health", restartPolicyType: "ON_FAILURE" },
13
32
  }, null, 2)}\n`;
14
33
  }
15
34
  /** Compute the Railway deploy plan from the resolved definition. */
16
35
  export function planRailwayDeploy(input) {
17
36
  const { serviceName, modelAuth, channels } = input;
18
- // Kit layout: railway.json is namespaced under the kit too (the host repo may carry its own
19
- // railway.toml/json for the product). Railway reads config-as-code from the repo root by default,
20
- // so the runbook adds the dashboard step that points the service at the kit's file (no CLI flag exists).
21
- const configPath = input.kitDir ? `${input.kitDir}/railway.json` : "railway.json";
37
+ // railway.json is namespaced under the agent dir too (the workspace may carry its own
38
+ // railway.toml/json for the product). Railway reads config-as-code from the repo root by default and
39
+ // pointing it at a custom path is DASHBOARD-ONLY so the BUILD entry travels as the scriptable
40
+ // RAILWAY_DOCKERFILE_PATH service variable instead (Railway's documented non-root-Dockerfile route),
41
+ // and the config-as-code pointer degrades to an OPTIONAL enhancement: the /health gate (Railway's
42
+ // default restart policy already matches the file's ON_FAILURE).
43
+ const configPath = `${input.agentPrefix}railway.json`;
22
44
  const artifacts = [
23
- { path: configPath, content: railwayJson(input.kitDir) },
45
+ { path: configPath, content: railwayJson(input.agentPrefix) },
24
46
  ...containerArtifacts(input),
25
47
  ];
26
48
  const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets, input.longConnectionChannels);
@@ -47,11 +69,13 @@ export function planRailwayDeploy(input) {
47
69
  `# the later commands resolve it without --service (--run passes --service to stay non-interactive).`,
48
70
  `railway add --service ${serviceName}`,
49
71
  ``,
50
- `# Persistent volume at ${MOUNT} — sessions, auth, channel state. FASTAGENT_STATE_DIR is set to match.`,
72
+ `# Persistent volume at ${MOUNT} — .state (sessions, channel state) + .secrets (seeded auth).`,
51
73
  `railway volume add --mount-path ${MOUNT}`,
52
74
  ``,
53
75
  `# Variables — set BEFORE the first deploy so the box boots with them. Railway injects PORT itself.`,
54
- `railway variables set FASTAGENT_STATE_DIR=${MOUNT}`,
76
+ `# RAILWAY_DOCKERFILE_PATH points the build at the agent's Dockerfile — a service variable,`,
77
+ `# Railway's documented route to a non-root Dockerfile (no dashboard step needed for the build).`,
78
+ `railway variables set FASTAGENT_STATE_DIR=${MOUNT}/.state FASTAGENT_SECRETS_DIR=${MOUNT}/.secrets RAILWAY_DOCKERFILE_PATH=${dockerfilePathVar(input.agentPrefix)}`,
55
79
  ];
56
80
  if (requiredSecrets.length > 0) {
57
81
  runbook.push(`# Required secrets:`, `# ${requiredSecrets.map((s) => `${s.name}: ${s.hint}`).join("\n# ")}`, `railway variables set ${requiredSecrets.map((s) => `${s.name}=<value>`).join(" ")}`);
@@ -63,15 +87,16 @@ export function planRailwayDeploy(input) {
63
87
  // credential's value (OAuth or a stored key) to set it — same wording discipline as the Fly plan.
64
88
  if (!isEnvKey(modelAuth)) {
65
89
  runbook.push(modelAuth === undefined
66
- ? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; pass --auth-path <file> (e.g. ~/.fastagent/auth.json), or \`--run\` carries it automatically.`
90
+ ? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; pass --auth-path <file> (e.g. ~/.fastagent/.secrets/auth.json), or \`--run\` carries it automatically.`
67
91
  : `# Model auth: your local auth is "${modelAuth}" — the plan can't read its value to set as a variable.`, `# Set your provider API key as a variable (railway variables set KEY=...), OR place auth.json on the ${MOUNT} volume.`);
68
92
  }
69
- if (input.kitDir) {
70
- runbook.push(``, `# Repo-as-workspace: point the service at the kit's config file BEFORE the first deploy —`, `# dashboard-only, like App Sleeping (no CLI flag): Service → Settings → Config-as-code →`, `# set the file path to ${configPath}. Without it Railway would read the repo root's own config.`);
71
- }
93
+ runbook.push(``, `# OPTIONAL — the build already uses the agent's Dockerfile via RAILWAY_DOCKERFILE_PATH (set above),`, `# and Railway's default restart policy equals what ${configPath} declares (ON_FAILURE).`, `# Pointing the service at ${configPath} (Service → Settings → Config-as-code — dashboard-only) adds`, `# the /health healthcheck gate: a boot-crashing deploy is marked FAILED instead of going live dead.`, `# (Zero-downtime switching doesn't apply either way — the ${MOUNT} volume allows one active deployment.)`);
72
94
  runbook.push(``, `# Deploy — uploads this dir and builds the Dockerfile on Railway (no local Docker needed). This is`, `# also the ENTIRE redeploy: re-run \`railway up\` alone (the one-time setup above is not repeated).`, `railway up`);
73
- if (input.kitDir) {
74
- runbook.push(``, `# Write-back mechanics: git ships in the image and GH_TOKEN-style creds ride config.deploy.secrets;`, `# the POLICY (push vs PR, identity, remote) lives in persona.md. CAVEAT — \`railway up\` is known to`, `# strip .git from its upload, so expect NO baked history on the box: the agent should \`git clone\``, `# its repo in the workspace (same token) before making changes. Un-pushed changes never survive a`, `# redeploy — the image is a snapshot; durability lives in git.`);
95
+ if (input.shipsGit) {
96
+ runbook.push(``, `# The image is a WYSIWYG snapshot of this directory. Freshness/durability run through git, driven`, `# by the agent itself (pull to freshen, commit/push to write back; creds ride config.deploy.secrets;`, `# git is baked into the image). CAVEAT — \`railway up\` is known to strip .git from its upload:`, `# expect NO baked history on the box; the agent should \`git clone\` its repo in the workspace`, `# (same token) before making changes.`, `# Un-pushed changes on the box never survive a redeploy; durability lives in git.`);
97
+ }
98
+ else {
99
+ runbook.push(``, `# The image is a WYSIWYG snapshot of this directory. No .git here, so no history ships and the`, `# generated image does not install git — changes on the box are ephemeral and never survive a`, `# redeploy. If the agent should clone/push repos as part of its work, add deploy: { apt: ["git"] }.`);
75
100
  }
76
101
  // The public URL is minted, not deterministic (unlike Fly's <app>.fly.dev) — ONE mint step, then each
77
102
  // channel's webhook uses that domain (mint once even when both channels are present).
@@ -30,7 +30,8 @@ export interface RailwayRunPlan {
30
30
  /** Names both the project (`railway init --name`) and the service (`railway add --service`). Railway
31
31
  * names are project-scoped, not globally unique — the CLI derives it from the dir basename. */
32
32
  name: string;
33
- /** The volume mount path AND `FASTAGENT_STATE_DIR` (kept in lockstep). `/data`, matching the Fly recipe. */
33
+ /** The volume mount path; `FASTAGENT_STATE_DIR`/`FASTAGENT_SECRETS_DIR` are set to `.state`/`.secrets`
34
+ * under it (kept in lockstep). `/data`, matching the Fly recipe. */
34
35
  mountPath: string;
35
36
  /** `KEY=value` secrets set one-per-`variable set --stdin`: model key (env auth) or `FASTAGENT_AUTH_SEED`
36
37
  * (file auth) + channel secrets. Never on argv. */
@@ -43,6 +44,12 @@ export interface RailwayRunPlan {
43
44
  * by default so `--run` only creates on an unlinked dir and never deploys into a pre-existing (possibly
44
45
  * unrelated/production) project; the flag is the operator's explicit "yes, this project". */
45
46
  intoLinked: boolean;
47
+ /** `RAILWAY_DOCKERFILE_PATH` value (`/fastagent/Dockerfile`) — Railway's service-variable route to a
48
+ * non-root Dockerfile (builds/dockerfiles docs), set with the machinery variables BEFORE the first
49
+ * `up` so the build never falls back to auto-detecting the workspace root. The config-as-code file
50
+ * also carries the path, but pointing Railway at `fastagent/railway.json` is dashboard-only — the
51
+ * variable is the scriptable way. */
52
+ dockerfilePath: string;
46
53
  }
47
54
  /** Done (with the live URL), or a gate the operator must clear before re-running (printed + non-zero
48
55
  * exit by the CLI). */
@@ -128,8 +128,13 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram, reg
128
128
  // volume (which has no --service and would otherwise attach to that service) — so the mismatch fails
129
129
  // visibly with NO side effect. State root on argv (not secret); secrets one-per-`set --stdin` (value
130
130
  // on stdin, never argv). Idempotent. (Order vs the volume is free — both just need to precede `up`.)
131
- log(`setting FASTAGENT_STATE_DIR + ${Object.keys(plan.secrets).length} secret(s)…`);
132
- if ((await railway(["variables", "set", `FASTAGENT_STATE_DIR=${plan.mountPath}`, ...svc])).code !== 0) {
131
+ const machineryVars = [
132
+ `FASTAGENT_STATE_DIR=${plan.mountPath}/.state`,
133
+ `FASTAGENT_SECRETS_DIR=${plan.mountPath}/.secrets`,
134
+ `RAILWAY_DOCKERFILE_PATH=${plan.dockerfilePath}`,
135
+ ];
136
+ log(`setting ${machineryVars.map((v) => v.split("=")[0]).join("/")} + ${Object.keys(plan.secrets).length} secret(s)…`);
137
+ if ((await railway(["variables", "set", ...machineryVars, ...svc])).code !== 0) {
133
138
  return gate("`railway variables set` failed — see the railway output above");
134
139
  }
135
140
  for (const [k, v] of Object.entries(plan.secrets)) {
@@ -1,8 +1,10 @@
1
1
  interface RunResult {
2
2
  code: number;
3
- /** Captured stdout (for `--json` queries); empty when the command streamed to the terminal. The CLI's
4
- * stderr is always inherited straight to the terminal, so it is not a field here. */
3
+ /** Captured stdout (for `--json` queries); empty when the command streamed to the terminal. */
5
4
  stdout: string;
5
+ /** Captured stderr — ONLY when `captureStderr` was set (a caller that must CLASSIFY a failure, e.g.
6
+ * "not found" vs "denied"); otherwise undefined and stderr streams to the terminal as always. */
7
+ stderr?: string;
6
8
  }
7
9
  /** Run `bin args`: `capture` collects stdout (for `--json` queries), else the command streams to the
8
10
  * terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv).
@@ -10,6 +12,7 @@ interface RunResult {
10
12
  * values in argv or mutating the long-lived CLI process. */
11
13
  export type CliRunner = (args: string[], opts?: {
12
14
  capture?: boolean;
15
+ captureStderr?: boolean;
13
16
  input?: string;
14
17
  env?: NodeJS.ProcessEnv;
15
18
  }) => Promise<RunResult>;
@@ -14,13 +14,19 @@ export function spawnRunner(bin, cwd) {
14
14
  const child = spawn(bin, args, {
15
15
  cwd,
16
16
  env: opts?.env ? { ...process.env, ...opts.env } : process.env,
17
- stdio: [opts?.input ? "pipe" : "inherit", opts?.capture ? "pipe" : "inherit", "inherit"],
17
+ stdio: [
18
+ opts?.input ? "pipe" : "inherit",
19
+ opts?.capture ? "pipe" : "inherit",
20
+ opts?.captureStderr ? "pipe" : "inherit",
21
+ ],
18
22
  });
19
23
  let out = "";
24
+ let err = "";
20
25
  child.stdout?.on("data", (d) => (out += String(d)));
26
+ child.stderr?.on("data", (d) => (err += String(d)));
21
27
  if (opts?.input)
22
28
  child.stdin?.end(opts.input);
23
- child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
24
- child.on("error", () => res({ code: 127, stdout: "" })); // ENOENT: bin not on PATH
29
+ child.on("close", (code) => res({ code: code ?? 1, stdout: out, stderr: opts?.captureStderr ? err : undefined }));
30
+ child.on("error", () => res({ code: 127, stdout: "", stderr: opts?.captureStderr ? "" : undefined })); // ENOENT
25
31
  });
26
32
  }
@@ -1,12 +1,15 @@
1
+ import { type ResolvedPlacement } from "./paths.ts";
1
2
  /**
2
- * chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
3
- * the whole subtree, so everything outside the allowlist .fastagent state, node_modules, .git, and
4
- * any file/dir the agent writes as work product costs no watchers and triggers no restarts.
5
- * Helper code imported from OUTSIDE tools//channels/ is out of scope by design (keep it under
6
- * tools/, or restart manually) the startup log names the watched set.
3
+ * chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. When
4
+ * the agent sits INSIDE the workspace that means the surrounding tree never triggers a restart at all;
5
+ * when the agent IS the workspace the root is that tree, and the allowlist below is what keeps the
6
+ * author's own files out of scope. Ignoring a directory prunes the whole subtree, so everything outside it
7
+ * `.state/` machine state, node_modules, .git, and any file/dir the agent writes as work product
8
+ * costs no watchers and triggers no restarts. Helper code imported from OUTSIDE tools//channels/ is
9
+ * out of scope by design (keep it under tools/, or restart manually) — the startup log names the set.
7
10
  */
8
- export declare function devWatchIgnored(dir: string, agentDir: string): (path: string) => boolean;
9
- /** Spawn the dev worker and restart it on workspace edits; supervise its lifecycle until the process exits. */
10
- export declare function runDevSupervisor(dir: string, options?: {
11
+ export declare function devWatchIgnored(root: string, envFile: string): (path: string) => boolean;
12
+ /** Spawn the dev worker and restart it on agent-dir edits; supervise its lifecycle until the process exits. */
13
+ export declare function runDevSupervisor(placement: ResolvedPlacement, options?: {
11
14
  tunnel?: boolean;
12
15
  }): Promise<void>;