@fastagent-sh/fastagent 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/README.md +16 -6
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +258 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +35 -0
  13. package/dist/channels/context-buffer.js +103 -0
  14. package/dist/channels/control.d.ts +28 -0
  15. package/dist/channels/control.js +214 -0
  16. package/dist/channels/feishu/card.js +1 -1
  17. package/dist/channels/feishu/context-buffer.d.ts +15 -13
  18. package/dist/channels/feishu/context-buffer.js +62 -69
  19. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  20. package/dist/channels/feishu/feishu-api.js +24 -3
  21. package/dist/channels/feishu/feishu.d.ts +27 -27
  22. package/dist/channels/feishu/feishu.js +318 -207
  23. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  24. package/dist/channels/feishu/invoke-turn.js +44 -79
  25. package/dist/channels/feishu/model.d.ts +2 -2
  26. package/dist/channels/feishu/normalize.d.ts +2 -1
  27. package/dist/channels/feishu/normalize.js +0 -1
  28. package/dist/channels/feishu/parse.d.ts +22 -8
  29. package/dist/channels/feishu/parse.js +24 -7
  30. package/dist/channels/feishu/preview.d.ts +4 -7
  31. package/dist/channels/feishu/preview.js +27 -142
  32. package/dist/channels/feishu/register-app.d.ts +2 -1
  33. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  34. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  35. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  36. package/dist/channels/feishu/setup-mode.d.ts +37 -0
  37. package/dist/channels/feishu/setup-mode.js +28 -0
  38. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  39. package/dist/channels/feishu/ws-ingress.js +136 -0
  40. package/dist/channels/github/github.js +16 -7
  41. package/dist/channels/http.d.ts +14 -0
  42. package/dist/channels/http.js +36 -3
  43. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  44. package/dist/channels/invoke-turn-kit.js +87 -0
  45. package/dist/channels/lark/lark.d.ts +4 -2
  46. package/dist/channels/lark/lark.js +4 -1
  47. package/dist/channels/lark/onboard.d.ts +8 -4
  48. package/dist/channels/lark/onboard.js +8 -0
  49. package/dist/channels/lark/scaffold/channel.ts +9 -8
  50. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  51. package/dist/channels/preview-kit.d.ts +115 -0
  52. package/dist/channels/preview-kit.js +184 -0
  53. package/dist/channels/seen.d.ts +5 -0
  54. package/dist/channels/seen.js +35 -0
  55. package/dist/channels/slack/bot-auth.d.ts +15 -0
  56. package/dist/channels/slack/bot-auth.js +146 -0
  57. package/dist/channels/slack/config-api.d.ts +60 -0
  58. package/dist/channels/slack/config-api.js +149 -0
  59. package/dist/channels/slack/context-buffer.d.ts +24 -0
  60. package/dist/channels/slack/context-buffer.js +37 -0
  61. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  62. package/dist/channels/slack/invoke-turn.js +63 -0
  63. package/dist/channels/slack/manifest.d.ts +49 -0
  64. package/dist/channels/slack/manifest.js +69 -0
  65. package/dist/channels/slack/model.d.ts +67 -0
  66. package/dist/channels/slack/model.js +2 -0
  67. package/dist/channels/slack/onboard.d.ts +41 -0
  68. package/dist/channels/slack/onboard.js +120 -0
  69. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  70. package/dist/channels/slack/onboarding-state.js +69 -0
  71. package/dist/channels/slack/parse.d.ts +38 -0
  72. package/dist/channels/slack/parse.js +124 -0
  73. package/dist/channels/slack/preview.d.ts +23 -0
  74. package/dist/channels/slack/preview.js +403 -0
  75. package/dist/channels/slack/reaction.d.ts +24 -0
  76. package/dist/channels/slack/reaction.js +62 -0
  77. package/dist/channels/slack/register-webhook.d.ts +10 -0
  78. package/dist/channels/slack/register-webhook.js +49 -0
  79. package/dist/channels/slack/scaffold/channel.ts +33 -0
  80. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  81. package/dist/channels/slack/setup-server.d.ts +17 -0
  82. package/dist/channels/slack/setup-server.js +103 -0
  83. package/dist/channels/slack/slack-api.d.ts +57 -0
  84. package/dist/channels/slack/slack-api.js +399 -0
  85. package/dist/channels/slack/slack.d.ts +51 -0
  86. package/dist/channels/slack/slack.js +496 -0
  87. package/dist/channels/slack/welcomed.d.ts +5 -0
  88. package/dist/channels/slack/welcomed.js +32 -0
  89. package/dist/channels/state.d.ts +11 -4
  90. package/dist/channels/state.js +22 -15
  91. package/dist/channels/stop-command.d.ts +6 -0
  92. package/dist/channels/stop-command.js +36 -0
  93. package/dist/channels/tasks.d.ts +7 -0
  94. package/dist/channels/tasks.js +25 -0
  95. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  96. package/dist/channels/telegram/context-buffer.js +6 -85
  97. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  98. package/dist/channels/telegram/invoke-turn.js +11 -58
  99. package/dist/channels/telegram/parse.d.ts +0 -7
  100. package/dist/channels/telegram/parse.js +4 -2
  101. package/dist/channels/telegram/preview.d.ts +4 -7
  102. package/dist/channels/telegram/preview.js +24 -142
  103. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  104. package/dist/channels/telegram/telegram.js +24 -10
  105. package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
  106. package/dist/channels/{feishu/text.js → text.js} +15 -1
  107. package/dist/channels/thread-participants.d.ts +21 -0
  108. package/dist/channels/thread-participants.js +132 -0
  109. package/dist/channels/turn-queue.js +8 -1
  110. package/dist/channels/turn-store.d.ts +1 -1
  111. package/dist/channels/turn-store.js +2 -3
  112. package/dist/cli/add-feishu.d.ts +30 -0
  113. package/dist/cli/add-feishu.js +366 -0
  114. package/dist/cli/add-slack.d.ts +11 -0
  115. package/dist/cli/add-slack.js +199 -0
  116. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  117. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  118. package/dist/cli/commands/add.d.ts +4 -0
  119. package/dist/cli/commands/add.js +191 -60
  120. package/dist/cli/commands/attach.d.ts +82 -0
  121. package/dist/cli/commands/attach.js +563 -0
  122. package/dist/cli/commands/chat.d.ts +1 -0
  123. package/dist/cli/commands/chat.js +12 -7
  124. package/dist/cli/commands/deploy.d.ts +3 -1
  125. package/dist/cli/commands/deploy.js +384 -82
  126. package/dist/cli/commands/dev.js +34 -26
  127. package/dist/cli/commands/fire.js +17 -18
  128. package/dist/cli/commands/info.js +36 -29
  129. package/dist/cli/commands/init.d.ts +1 -1
  130. package/dist/cli/commands/init.js +65 -53
  131. package/dist/cli/commands/invoke.js +11 -9
  132. package/dist/cli/commands/login.js +48 -45
  133. package/dist/cli/commands/models.js +1 -1
  134. package/dist/cli/commands/schedule.js +6 -8
  135. package/dist/cli/commands/start.js +102 -39
  136. package/dist/cli/commands/tool.js +23 -18
  137. package/dist/cli/fail.d.ts +17 -2
  138. package/dist/cli/fail.js +25 -1
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +3 -2
  142. package/dist/cli/models-view.d.ts +21 -0
  143. package/dist/cli/models-view.js +66 -0
  144. package/dist/cli/program.js +135 -56
  145. package/dist/cli/serve.d.ts +60 -20
  146. package/dist/cli/serve.js +252 -47
  147. package/dist/cli/shared.d.ts +40 -7
  148. package/dist/cli/shared.js +180 -38
  149. package/dist/collect.d.ts +14 -3
  150. package/dist/collect.js +24 -0
  151. package/dist/core.d.ts +3 -1
  152. package/dist/core.js +2 -0
  153. package/dist/deploy/agentcore/plan.d.ts +117 -0
  154. package/dist/deploy/agentcore/plan.js +721 -0
  155. package/dist/deploy/agentcore/run.d.ts +73 -0
  156. package/dist/deploy/agentcore/run.js +412 -0
  157. package/dist/deploy/agentcore/zip.d.ts +17 -0
  158. package/dist/deploy/agentcore/zip.js +68 -0
  159. package/dist/deploy/container.d.ts +26 -25
  160. package/dist/deploy/container.js +93 -89
  161. package/dist/deploy/docker/plan.d.ts +3 -3
  162. package/dist/deploy/docker/plan.js +20 -23
  163. package/dist/deploy/fly/plan.d.ts +5 -1
  164. package/dist/deploy/fly/plan.js +39 -26
  165. package/dist/deploy/fly/run.d.ts +17 -5
  166. package/dist/deploy/fly/run.js +52 -7
  167. package/dist/deploy/preflight.d.ts +20 -8
  168. package/dist/deploy/preflight.js +267 -71
  169. package/dist/deploy/railway/plan.d.ts +10 -1
  170. package/dist/deploy/railway/plan.js +53 -20
  171. package/dist/deploy/railway/run.d.ts +13 -5
  172. package/dist/deploy/railway/run.js +22 -7
  173. package/dist/deploy/runner.d.ts +7 -3
  174. package/dist/deploy/runner.js +9 -3
  175. package/dist/deploy/secrets.d.ts +2 -1
  176. package/dist/deploy/secrets.js +23 -3
  177. package/dist/dev-supervisor.d.ts +11 -10
  178. package/dist/dev-supervisor.js +56 -50
  179. package/dist/engines/pi/auth.d.ts +8 -7
  180. package/dist/engines/pi/auth.js +12 -10
  181. package/dist/engines/pi/channel.d.ts +22 -16
  182. package/dist/engines/pi/channel.js +92 -62
  183. package/dist/engines/pi/chat.d.ts +4 -16
  184. package/dist/engines/pi/chat.js +8 -261
  185. package/dist/engines/pi/config.d.ts +20 -55
  186. package/dist/engines/pi/config.js +49 -121
  187. package/dist/engines/pi/create.d.ts +30 -10
  188. package/dist/engines/pi/create.js +33 -26
  189. package/dist/engines/pi/definition.d.ts +7 -26
  190. package/dist/engines/pi/definition.js +8 -54
  191. package/dist/engines/pi/harness.d.ts +49 -0
  192. package/dist/engines/pi/harness.js +90 -2
  193. package/dist/engines/pi/invoke.d.ts +55 -2
  194. package/dist/engines/pi/invoke.js +304 -19
  195. package/dist/engines/pi/login.d.ts +12 -1
  196. package/dist/engines/pi/login.js +16 -4
  197. package/dist/engines/pi/models.d.ts +59 -13
  198. package/dist/engines/pi/models.js +62 -24
  199. package/dist/engines/pi/open.d.ts +114 -0
  200. package/dist/engines/pi/open.js +127 -0
  201. package/dist/engines/pi/session-builder.d.ts +16 -0
  202. package/dist/engines/pi/session-builder.js +308 -0
  203. package/dist/engines/pi/session-control.d.ts +50 -0
  204. package/dist/engines/pi/session-control.js +604 -0
  205. package/dist/engines/pi/sessions.d.ts +17 -2
  206. package/dist/engines/pi/sessions.js +9 -0
  207. package/dist/engines/pi/tool-context.d.ts +18 -11
  208. package/dist/engines/pi/tool-context.js +3 -9
  209. package/dist/engines/pi/tool.d.ts +6 -8
  210. package/dist/engines/pi/tool.js +10 -1
  211. package/dist/engines/pi/wake-tool.d.ts +0 -3
  212. package/dist/engines/pi/wake-tool.js +9 -7
  213. package/dist/env.d.ts +16 -4
  214. package/dist/env.js +43 -5
  215. package/dist/feishu.d.ts +1 -1
  216. package/dist/feishu.js +1 -1
  217. package/dist/host/node.d.ts +25 -8
  218. package/dist/host/node.js +5 -4
  219. package/dist/index.d.ts +1 -0
  220. package/dist/index.js +1 -0
  221. package/dist/lark.d.ts +1 -1
  222. package/dist/lark.js +1 -1
  223. package/dist/loader.d.ts +2 -2
  224. package/dist/loader.js +3 -3
  225. package/dist/log.d.ts +1 -1
  226. package/dist/log.js +1 -1
  227. package/dist/observe.js +3 -0
  228. package/dist/paths.d.ts +138 -0
  229. package/dist/paths.js +326 -0
  230. package/dist/pi.d.ts +6 -5
  231. package/dist/pi.js +4 -3
  232. package/dist/runtime.d.ts +7 -5
  233. package/dist/runtime.js +2 -2
  234. package/dist/scaffold/add-channel.d.ts +26 -9
  235. package/dist/scaffold/add-channel.js +175 -40
  236. package/dist/scaffold/init.d.ts +32 -41
  237. package/dist/scaffold/init.js +161 -185
  238. package/dist/scaffold/templates/env.example +15 -6
  239. package/dist/scaffold/templates/fastagent.config.mjs +5 -4
  240. package/dist/scaffold/templates/gitignore +14 -6
  241. package/dist/scaffold/templates/persona.md +4 -2
  242. package/dist/scaffold/templates/secrets.gitignore +5 -0
  243. package/dist/scaffold/templates.d.ts +1 -7
  244. package/dist/scaffold/templates.js +3 -25
  245. package/dist/scaffold/vendor-skill.d.ts +2 -2
  246. package/dist/scaffold/vendor-skill.js +13 -13
  247. package/dist/schedule/discover.js +4 -4
  248. package/dist/schedule/scheduler.d.ts +40 -1
  249. package/dist/schedule/scheduler.js +89 -56
  250. package/dist/schedule/state.js +1 -1
  251. package/dist/schedule/wake-alarm.d.ts +47 -0
  252. package/dist/schedule/wake-alarm.js +136 -0
  253. package/dist/schedule/wakeups.d.ts +1 -3
  254. package/dist/schedule/wakeups.js +19 -1
  255. package/dist/session-remote.d.ts +53 -0
  256. package/dist/session-remote.js +336 -0
  257. package/dist/session.d.ts +265 -0
  258. package/dist/session.js +37 -0
  259. package/dist/slack.d.ts +2 -0
  260. package/dist/slack.js +2 -0
  261. package/dist/tunnel.d.ts +7 -4
  262. package/dist/tunnel.js +23 -12
  263. package/package.json +22 -7
  264. package/dist/channels/feishu/owned-threads.d.ts +0 -11
  265. package/dist/channels/feishu/owned-threads.js +0 -47
  266. package/dist/channels/feishu/seen.d.ts +0 -5
  267. package/dist/channels/feishu/seen.js +0 -47
  268. package/dist/cli-add-feishu.d.ts +0 -8
  269. package/dist/cli-add-feishu.js +0 -223
  270. package/dist/cli-models.d.ts +0 -11
  271. package/dist/cli-models.js +0 -20
  272. package/dist/engines/pi/workspace.d.ts +0 -56
  273. package/dist/engines/pi/workspace.js +0 -72
  274. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  275. package/dist/scaffold/templates/gitignore.kit +0 -2
  276. package/dist/workspace.d.ts +0 -9
  277. package/dist/workspace.js +0 -45
@@ -2,28 +2,50 @@ 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
- const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets);
48
+ const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets, input.longConnectionChannels);
27
49
  const requiredSecrets = secrets.filter((secret) => secret.required);
28
50
  const optionalSecrets = secrets.filter((secret) => !secret.required);
29
51
  // Order matters, not cosmetics: `railway init` creates a PROJECT with no service, but the volume and
@@ -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,20 +87,24 @@ 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).
78
103
  const hasFeishuCloudChannel = ["feishu", "lark"].some((kind) => channels.includes(kind));
79
- if (channels.includes("telegram") || channels.includes("github") || hasFeishuCloudChannel) {
104
+ if (channels.includes("telegram") ||
105
+ channels.includes("github") ||
106
+ channels.includes("slack") ||
107
+ hasFeishuCloudChannel) {
80
108
  runbook.push(``, `# Public URL — Railway mints a *.up.railway.app domain (NOT deterministic). Generate it, then read`, `# the printed https URL and use it as <your-domain> in the webhook step(s) below:`, `railway domain`);
81
109
  }
82
110
  if (channels.includes("telegram")) {
@@ -85,8 +113,11 @@ export function planRailwayDeploy(input) {
85
113
  if (channels.includes("github")) {
86
114
  runbook.push(`# Set the GitHub webhook (repo Settings → Webhooks). Default route POST /webhook; if you remapped it`, `# in channels/github.ts, use your path:`, `# Payload URL = https://<your-domain>/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
87
115
  }
116
+ if (channels.includes("slack")) {
117
+ runbook.push(`# Set Slack Event Subscriptions → Request URL (default route POST /slack; the running service`, `# answers Slack's challenge), and match scopes/subscriptions to channels/slack.ts groupBehavior:`, `# Request URL = https://<your-domain>/slack`);
118
+ }
88
119
  for (const kind of ["feishu", "lark"]) {
89
- if (!channels.includes(kind))
120
+ if (!channels.includes(kind) || input.longConnectionChannels?.includes(kind))
90
121
  continue;
91
122
  const label = kind === "feishu" ? "Feishu" : "Lark";
92
123
  runbook.push(`# Set the ${label} event Request URL (developer console → Events & Callbacks). Default route`, `# POST /${kind}; the service must be RUNNING when you save (the console verifies with a challenge):`, `# Request URL = https://<your-domain>/${kind}`);
@@ -98,6 +129,8 @@ export function planRailwayDeploy(input) {
98
129
  ? `# Scale-to-zero: do NOT enable App Sleeping — github turns have no replay, a sleep mid-review is lost.`
99
130
  : input.hasTimeTriggers
100
131
  ? `# Scale-to-zero: do NOT enable App Sleeping — schedules/wake-ups have no external wake-up; a sleeping service sleeps through them.`
101
- : `# Scale-to-zero (optional, dashboard-only — no CLI/API): Settings → Deploy → Serverless → App Sleeping.`, `# Keep this a SINGLE service: the ${MOUNT} volume is tied to one service; extra replicas split state.`);
132
+ : (input.longConnectionChannels?.length ?? 0) > 0
133
+ ? `# Scale-to-zero: do NOT enable App Sleeping — a long-connection channel must remain connected.`
134
+ : `# Scale-to-zero (optional, dashboard-only — no CLI/API): Settings → Deploy → Serverless → App Sleeping.`, `# Keep this a SINGLE service: the ${MOUNT} volume is tied to one service; extra replicas split state.`);
102
135
  return { artifacts, runbook };
103
136
  }
@@ -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. */
@@ -38,10 +39,17 @@ export interface RailwayRunPlan {
38
39
  /** Required secret names with NO local value — the run gates on these before any side effect. */
39
40
  missingSecrets: string[];
40
41
  channels: ChannelKind[];
42
+ longConnectionChannels?: string[];
41
43
  /** Opt-in (CLI `--into-linked`) to provision INTO the project this directory is already linked to. Off
42
44
  * by default so `--run` only creates on an unlinked dir and never deploys into a pre-existing (possibly
43
45
  * unrelated/production) project; the flag is the operator's explicit "yes, this project". */
44
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;
45
53
  }
46
54
  /** Done (with the live URL), or a gate the operator must clear before re-running (printed + non-zero
47
55
  * exit by the CLI). */
@@ -76,9 +84,9 @@ export declare function parseDomainUrl(stdout: string): string | undefined;
76
84
  */
77
85
  export declare function parseHasVolume(stdout: string, mountPath: string): boolean;
78
86
  /**
79
- * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` /
80
- * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
81
- * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
87
+ * Run the deploy through `railway`. `log` reports progress; the injected Telegram/Feishu/Slack
88
+ * registrars perform post-deploy webhook steps from the builder machine (Slack's control credential
89
+ * never travels to the host). Absent, the manual console
82
90
  * instruction is printed. Every gate is fail-visible.
83
91
  */
84
- export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>): Promise<RailwayRunOutcome>;
92
+ export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>): Promise<RailwayRunOutcome>;
@@ -62,12 +62,12 @@ export function parseHasVolume(stdout, mountPath) {
62
62
  return jsonStrings(stdout).includes(mountPath);
63
63
  }
64
64
  /**
65
- * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` /
66
- * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
67
- * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
65
+ * Run the deploy through `railway`. `log` reports progress; the injected Telegram/Feishu/Slack
66
+ * registrars perform post-deploy webhook steps from the builder machine (Slack's control credential
67
+ * never travels to the host). Absent, the manual console
68
68
  * instruction is printed. Every gate is fail-visible.
69
69
  */
70
- export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu) {
70
+ export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu, registerSlack) {
71
71
  const gate = (g) => ({ ok: false, gate: g });
72
72
  // Every --service below targets plan.name — the name this tool gives BOTH the project and the service
73
73
  // (`init --name` + `add --service`). On a fresh create they match; on `--into-linked` into a hand-made
@@ -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)) {
@@ -179,8 +184,18 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram, reg
179
184
  log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
180
185
  reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
181
186
  }
187
+ if (plan.channels.includes("slack")) {
188
+ if (registerSlack) {
189
+ log("registering slack event URL…");
190
+ reg.track("slack", await registerSlack(url));
191
+ }
192
+ else {
193
+ log(`slack: set Event Subscriptions → Request URL → ${url}/slack`);
194
+ reg.track("slack", "manual");
195
+ }
196
+ }
182
197
  for (const kind of ["feishu", "lark"]) {
183
- if (!plan.channels.includes(kind))
198
+ if (!plan.channels.includes(kind) || plan.longConnectionChannels?.includes(kind))
184
199
  continue;
185
200
  if (registerFeishu) {
186
201
  log(`registering ${kind} event URL…`);
@@ -1,8 +1,10 @@
1
- export interface RunResult {
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 @@ export 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>;
@@ -19,3 +22,4 @@ export type CliRunner = (args: string[], opts?: {
19
22
  * ENOENT (the CLI not on PATH) resolves to code 127 so the caller can gate with an install hint.
20
23
  */
21
24
  export declare function spawnRunner(bin: string, cwd: string): CliRunner;
25
+ export {};
@@ -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
  }
@@ -17,7 +17,7 @@ export declare function isEnvKey(source: string | undefined): source is string;
17
17
  * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
18
18
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
19
19
  */
20
- export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
20
+ export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[], longConnectionChannels?: string[]): {
21
21
  name: string;
22
22
  hint: string;
23
23
  required: boolean;
@@ -40,6 +40,7 @@ export declare function assembleSecrets(input: {
40
40
  modelAuth: string | undefined;
41
41
  authFile: Buffer | undefined;
42
42
  channels: ChannelKind[];
43
+ longConnectionChannels?: string[];
43
44
  /** Extra secret env-var names from `fastagent.config` deploy.secrets — carried like channel secrets. */
44
45
  extraSecrets?: string[];
45
46
  env: NodeJS.ProcessEnv;
@@ -19,13 +19,15 @@ export function isEnvKey(source) {
19
19
  * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
20
20
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
21
21
  */
22
- export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
22
+ export function deploymentSecrets(modelAuth, channels, extraSecrets = [], longConnectionChannels = []) {
23
23
  const secrets = [];
24
24
  if (isEnvKey(modelAuth))
25
25
  secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
26
26
  for (const kind of channels) {
27
- for (const e of channelSetup(kind).env)
27
+ const setupMode = longConnectionChannels.includes(kind) ? "websocket" : "webhook";
28
+ for (const e of channelSetup(kind, setupMode).env) {
28
29
  secrets.push({ name: e.name, hint: e.hint, required: e.required });
30
+ }
29
31
  }
30
32
  // Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
31
33
  for (const name of extraSecrets) {
@@ -67,7 +69,8 @@ export function assembleSecrets(input) {
67
69
  needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
68
70
  }
69
71
  for (const kind of input.channels) {
70
- for (const e of channelSetup(kind).env) {
72
+ const setupMode = input.longConnectionChannels?.includes(kind) ? "websocket" : "webhook";
73
+ for (const e of channelSetup(kind, setupMode).env) {
71
74
  const v = input.env[e.name];
72
75
  if (v)
73
76
  secrets[e.name] = v; // optional channel values travel when configured
@@ -76,6 +79,23 @@ export function assembleSecrets(input) {
76
79
  }
77
80
  }
78
81
  }
82
+ // Slack bot-token rotation is an all-or-nothing credential bundle. Its fields remain optional so a
83
+ // manually configured long-lived token works, but a partial bundle must gate before the container
84
+ // reaches slackChannel construction.
85
+ if (input.channels.includes("slack")) {
86
+ const rotation = [
87
+ "SLACK_BOT_REFRESH_TOKEN",
88
+ "SLACK_BOT_TOKEN_EXPIRES_AT",
89
+ "SLACK_CLIENT_ID",
90
+ "SLACK_CLIENT_SECRET",
91
+ ];
92
+ if (rotation.some((name) => !!input.env[name])) {
93
+ for (const name of rotation) {
94
+ if (!input.env[name] && !missingSecrets.includes(name))
95
+ missingSecrets.push(name);
96
+ }
97
+ }
98
+ }
79
99
  for (const name of input.extraSecrets ?? []) {
80
100
  if (name in secrets || missingSecrets.includes(name))
81
101
  continue; // already covered by model/channel — no dup
@@ -1,14 +1,15 @@
1
- /** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
2
- export declare const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
1
+ import { type ResolvedPlacement } from "./paths.ts";
3
2
  /**
4
- * chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
5
- * the whole subtree, so everything outside the allowlist .fastagent state, node_modules, .git, and
6
- * any file/dir the agent writes as work product costs no watchers and triggers no restarts.
7
- * Helper code imported from OUTSIDE tools//channels/ is out of scope by design (keep it under
8
- * 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.
9
10
  */
10
- export declare function devWatchIgnored(dir: string, agentDir: string): (path: string) => boolean;
11
- /** Spawn the dev worker and restart it on workspace edits; supervise its lifecycle until the process exits. */
12
- 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?: {
13
14
  tunnel?: boolean;
14
15
  }): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The `fastagent dev` process supervisor: re-spawn the CLI as a worker (`FASTAGENT_DEV_WORKER=1`) and
3
- * restart it on debounced edits to the workspace's CODE inputs. Each restart is a fresh process
3
+ * restart it on debounced edits to the agent's CODE inputs. Each restart is a fresh process
4
4
  * (always-latest, no stale module cache). The supervisor never exits on a bad edit — the worker fails
5
5
  * loudly and it waits for the next save.
6
6
  *
@@ -13,65 +13,61 @@
13
13
  import { spawn } from "node:child_process";
14
14
  import { relative, sep } from "node:path";
15
15
  import { watch as watchTree } from "chokidar";
16
- import { loadConfig, resolveAgentDir } from "./engines/pi/config.js";
16
+ import { AGENT_CONFIG_NAMES, resolveStateRoot } from "./paths.js";
17
+ import { isUnderDir } from "./engines/pi/definition.js";
18
+ import { dotEnvPath } from "./env.js";
17
19
  import { log } from "./log.js";
18
20
  import { installProxyFetch } from "./proxy.js";
19
21
  import { openExternalUrl } from "./open-url.js";
20
22
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
21
- /** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
22
- export const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
23
+ /** What the dev watcher restarts on (agent-dir-relative): the process-bound code inputs only. */
24
+ const WATCHED_HINT = "tools/, channels/, schedules/, package.json, fastagent.config.*, .secrets/.env";
23
25
  /**
24
- * chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
25
- * the whole subtree, so everything outside the allowlist .fastagent state, node_modules, .git, and
26
- * any file/dir the agent writes as work product costs no watchers and triggers no restarts.
27
- * Helper code imported from OUTSIDE tools//channels/ is out of scope by design (keep it under
28
- * tools/, or restart manually) the startup log names the watched set.
26
+ * chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. When
27
+ * the agent sits INSIDE the workspace that means the surrounding tree never triggers a restart at all;
28
+ * when the agent IS the workspace the root is that tree, and the allowlist below is what keeps the
29
+ * author's own files out of scope. Ignoring a directory prunes the whole subtree, so everything outside it
30
+ * `.state/` machine state, node_modules, .git, and any file/dir the agent writes as work product
31
+ * costs no watchers and triggers no restarts. Helper code imported from OUTSIDE tools//channels/ is
32
+ * out of scope by design (keep it under tools/, or restart manually) — the startup log names the set.
29
33
  */
30
- export function devWatchIgnored(dir, agentDir) {
34
+ export function devWatchIgnored(root, envFile) {
35
+ // The `.env` is allow-listed by its RESOLVED path, not by the `.secrets` name: FASTAGENT_SECRETS_DIR
36
+ // can put it in an in-agent directory called anything, and a name-based rule would prune the very
37
+ // file the worker loads (a credential edit would then silently never restart it).
38
+ const envRel = relative(root, envFile).split(sep);
31
39
  return (path) => {
32
- if (path === dir || path === agentDir)
33
- return false; // the roots themselves must not be pruned
34
- // Never prune a directory on the path from the watch root down to agentDir, so chokidar can descend
35
- // into `agentDir/tools` even when agentDir is a subdir (config.agentDir = "./agent").
36
- if (agentDir.startsWith(path + sep))
40
+ if (path === root)
41
+ return false; // the root itself must not be pruned
42
+ const rel = relative(root, path);
43
+ // Code inputs at the agent dir root: config, package.json, and the dirs loaded once per worker
44
+ // (a restart is their only re-read). Everything else (skills/, persona.md, AGENTS.md) is
45
+ // live-read — pruned, no restart.
46
+ // The config NAMES come from paths.ts, not a regex spelled here: adding a name there must not
47
+ // silently stop `dev` restarting on edits to it.
48
+ if (AGENT_CONFIG_NAMES.includes(rel))
37
49
  return false;
38
- const rel = relative(dir, path);
39
- // Run-root (cwd) inputs: .env + fastagent.config.* live where config lives, not in agentDir.
40
- if (rel === ".env")
50
+ if (rel === "package.json")
41
51
  return false;
42
- if (/^fastagent\.config\.[cm]?[jt]s$/.test(rel))
52
+ const segments = rel.split(sep);
53
+ if (segments[0] === "tools" || segments[0] === "channels" || segments[0] === "schedules")
54
+ return false;
55
+ // The `.env` restarts too (credentials are process-bound). Keep it AND its ancestor directories
56
+ // un-pruned so chokidar can descend to it; every sibling inside them (auth.json, .env.example)
57
+ // prunes normally. An out-of-agent `.env` yields a `..`-prefixed envRel that matches nothing here
58
+ // — the supervisor warns about that case instead of pretending to watch it.
59
+ if (segments.length <= envRel.length && segments.every((seg, i) => seg === envRel[i]))
43
60
  return false;
44
- // Agent code inputs live in agentDir: tools/, channels/, schedules/ (loaded once per worker — a
45
- // restart is their only re-read), package.json (its own deps). Everything else under agentDir
46
- // (skills/, persona.md, AGENTS.md) is live-read — pruned, no restart.
47
- const relAgent = relative(agentDir, path);
48
- if (!relAgent.startsWith("..")) {
49
- const [head] = relAgent.split(sep);
50
- if (head === "tools" || head === "channels" || head === "schedules")
51
- return false;
52
- if (relAgent === "package.json")
53
- return false;
54
- }
55
61
  return true;
56
62
  };
57
63
  }
58
- /** Spawn the dev worker and restart it on workspace edits; supervise its lifecycle until the process exits. */
59
- export async function runDevSupervisor(dir, options = {}) {
60
- // The watch root is `dir` (cwd); tools/channels the restart-watch cares about live in agentDir. On a
61
- // config error, default agentDir=dir and let the spawned worker surface the real error (fail-visibly).
62
- // agentDir is assumed STATIC for the dev session: the supervisor computes it once here and each spawned
63
- // worker recomputes its own from the same config config validation guarantees it stays under `dir`
64
- // (so the watch scope is always right); a config edit that changes agentDir mid-session (rare) is out
65
- // of scope for watch-scope re-sync (it triggers a worker restart regardless).
66
- // A genuine config error (not just a missing agentDir key) — debug-log it here so the silence is not
67
- // total before the spawned worker crash-loops and surfaces the real message; default agentDir=dir.
68
- const config = await loadConfig(dir)
69
- .then((r) => r.config)
70
- .catch((err) => {
71
- log.debug(`[fastagent] dev: config load failed while resolving agentDir (worker will report): ${String(err)}`);
72
- return {};
73
- });
74
- const agentDir = resolveAgentDir(dir, config);
64
+ /** Spawn the dev worker and restart it on agent-dir edits; supervise its lifecycle until the process exits. */
65
+ export async function runDevSupervisor(placement, options = {}) {
66
+ // The placement arrives RESOLVED from the command (which already routed its refusal through
67
+ // failStartup): re-resolving here would duplicate the rule and surface the same user-fixable
68
+ // refusal as a raw stack. The watch root is the AGENT DIR every restart-relevant code input lives
69
+ // under it, so the surrounding workspace costs no watchers at all. Placement is assumed STATIC for
70
+ // the session (creating/removing `fastagent/` mid-session is out of scope for watch re-sync).
75
71
  let worker;
76
72
  let reloadPending = false;
77
73
  let everServed = false; // has any worker successfully bound (sent `ready`) yet?
@@ -100,7 +96,11 @@ export async function runDevSupervisor(dir, options = {}) {
100
96
  void startCloudflareTunnel(m.port).then((t) => {
101
97
  if (t) {
102
98
  tunnel = t;
103
- void announceWebhooks(agentDir, t.url, { openUrl: openExternalUrl });
99
+ void announceWebhooks(placement.agentDir, t.url, {
100
+ openUrl: openExternalUrl,
101
+ routeChannels: m.routeChannels,
102
+ stateRoot: resolveStateRoot(placement.agentDir),
103
+ });
104
104
  }
105
105
  });
106
106
  }
@@ -137,9 +137,9 @@ export async function runDevSupervisor(dir, options = {}) {
137
137
  };
138
138
  // chokidar gives reliable cross-platform recursion + structural ignore that native fs.watch
139
139
  // cannot; devWatchIgnored (above) narrows the scope to the process-bound code inputs.
140
- const watcher = watchTree(dir, {
140
+ const watcher = watchTree(placement.agentDir, {
141
141
  ignoreInitial: true, // the startup scan is not a change
142
- ignored: devWatchIgnored(dir, agentDir),
142
+ ignored: devWatchIgnored(placement.agentDir, dotEnvPath(placement.agentDir)),
143
143
  });
144
144
  watcher.on("all", () => {
145
145
  clearTimeout(timer);
@@ -147,6 +147,12 @@ export async function runDevSupervisor(dir, options = {}) {
147
147
  });
148
148
  watcher.on("error", (error) => log.warn(`[fastagent] file watching error (${error.message}); some edits may need a manual restart`));
149
149
  log.info(`[fastagent] watching ${WATCHED_HINT} — code edits restart the dev worker (--no-watch to disable); AGENTS.md/persona.md/skills edits go live next turn without a restart`);
150
+ // FASTAGENT_SECRETS_DIR can move the `.env` OUT of the agent dir entirely; the watcher follows it
151
+ // anywhere inside (the resolved path is allow-listed above), but outside the watch root the worker
152
+ // would load a file no watcher sees. Say so once instead of leaving the hint above lying.
153
+ if (!isUnderDir(dotEnvPath(placement.agentDir), placement.agentDir)) {
154
+ log.warn(`[fastagent] .env lives outside the agent dir (FASTAGENT_SECRETS_DIR → ${dotEnvPath(placement.agentDir)}) — it is NOT watched; restart dev after editing it`);
155
+ }
150
156
  const shutdown = () => {
151
157
  worker?.kill("SIGTERM");
152
158
  tunnel?.close();
@@ -1,11 +1,12 @@
1
1
  import type { CredentialStore } from "@earendil-works/pi-ai";
2
2
  /**
3
- * The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`). The project-level default is
4
- * `<dir>/.fastagent/auth.json` (computed by the opener and by `fastagent login`); this is only the
5
- * `loginFlow()` PROGRAMMATIC fallback (when a caller omits `authPath`) and the path to point
6
- * `--auth-path`/`FASTAGENT_AUTH_PATH` at to deliberately share ONE credential file across projects
7
- * (safe one file, one lock-serialized refresh lifecycle). The `fastagent login` CLI is project-
8
- * level by default, never this.
3
+ * The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`), under the user-global machinery
4
+ * home `~/.fastagent/` which carries the same unified shape as a workspace (`.secrets/auth.json`).
5
+ * The project-level default is `<agentDir>/.secrets/auth.json` (computed by the opener and by
6
+ * `fastagent login`); this is only the `loginFlow()` PROGRAMMATIC fallback (when a caller omits
7
+ * `authPath`) and the path to point `--auth-path`/`FASTAGENT_AUTH_PATH` at to deliberately share ONE
8
+ * credential file across projects (safe — one file, one lock-serialized refresh lifecycle). The
9
+ * `fastagent login` CLI is project-level by default, never this.
9
10
  */
10
11
  export declare const GLOBAL_AUTH_PATH: string;
11
12
  export interface FastagentAuthOptions {
@@ -13,5 +14,5 @@ export interface FastagentAuthOptions {
13
14
  warn?: (message: string) => void;
14
15
  }
15
16
  /** A read-write `CredentialStore` backed by the given credentials file (default {@link GLOBAL_AUTH_PATH};
16
- * the directory opener passes the project-level `<dir>/.fastagent/auth.json`). */
17
+ * the directory opener passes the project-level `<root>/.secrets/auth.json`). */
17
18
  export declare function fastagentCredentialStore(authPath?: string, options?: FastagentAuthOptions): CredentialStore;