@fastagent-sh/fastagent 0.15.0 → 0.16.1

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 (181) hide show
  1. package/README.md +14 -4
  2. package/dist/bind.d.ts +34 -0
  3. package/dist/bind.js +74 -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 +266 -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 +5 -0
  13. package/dist/channels/context-buffer.js +5 -0
  14. package/dist/channels/feishu/context-buffer.d.ts +13 -8
  15. package/dist/channels/feishu/context-buffer.js +60 -6
  16. package/dist/channels/feishu/feishu-api.js +4 -1
  17. package/dist/channels/feishu/feishu.d.ts +0 -12
  18. package/dist/channels/feishu/feishu.js +143 -54
  19. package/dist/channels/feishu/invoke-turn.js +35 -18
  20. package/dist/channels/feishu/model.d.ts +0 -1
  21. package/dist/channels/feishu/normalize.js +0 -1
  22. package/dist/channels/feishu/parse.d.ts +21 -7
  23. package/dist/channels/feishu/parse.js +24 -7
  24. package/dist/channels/feishu/preview.js +3 -2
  25. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  26. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  27. package/dist/channels/feishu/setup-mode.d.ts +30 -1
  28. package/dist/channels/feishu/setup-mode.js +27 -1
  29. package/dist/channels/github/github.js +8 -1
  30. package/dist/channels/http.js +1 -1
  31. package/dist/channels/lark/scaffold/channel.ts +9 -8
  32. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  33. package/dist/channels/preview-kit.d.ts +7 -1
  34. package/dist/channels/preview-kit.js +3 -2
  35. package/dist/channels/slack/parse.d.ts +16 -1
  36. package/dist/channels/slack/parse.js +46 -3
  37. package/dist/channels/slack/preview.d.ts +1 -2
  38. package/dist/channels/slack/preview.js +68 -24
  39. package/dist/channels/slack/scaffold/channel.ts +5 -5
  40. package/dist/channels/slack/slack-api.d.ts +3 -23
  41. package/dist/channels/slack/slack-api.js +6 -22
  42. package/dist/channels/slack/slack.d.ts +13 -20
  43. package/dist/channels/slack/slack.js +95 -50
  44. package/dist/channels/state.d.ts +11 -4
  45. package/dist/channels/state.js +19 -12
  46. package/dist/channels/tasks.d.ts +0 -6
  47. package/dist/channels/tasks.js +16 -1
  48. package/dist/channels/telegram/parse.d.ts +0 -7
  49. package/dist/channels/telegram/parse.js +4 -2
  50. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  51. package/dist/channels/telegram/telegram.js +1 -1
  52. package/dist/channels/text.d.ts +14 -0
  53. package/dist/channels/text.js +14 -0
  54. package/dist/channels/thread-participants.d.ts +21 -0
  55. package/dist/channels/thread-participants.js +132 -0
  56. package/dist/channels/turn-queue.js +7 -0
  57. package/dist/cli/add-feishu.d.ts +7 -4
  58. package/dist/cli/add-feishu.js +57 -37
  59. package/dist/cli/add-slack.d.ts +2 -1
  60. package/dist/cli/add-slack.js +6 -11
  61. package/dist/cli/commands/add.js +50 -51
  62. package/dist/cli/commands/attach.js +8 -4
  63. package/dist/cli/commands/chat.js +8 -8
  64. package/dist/cli/commands/deploy.d.ts +14 -1
  65. package/dist/cli/commands/deploy.js +330 -78
  66. package/dist/cli/commands/dev.d.ts +1 -0
  67. package/dist/cli/commands/dev.js +38 -25
  68. package/dist/cli/commands/fire.js +15 -16
  69. package/dist/cli/commands/info.js +36 -29
  70. package/dist/cli/commands/init.d.ts +1 -1
  71. package/dist/cli/commands/init.js +65 -53
  72. package/dist/cli/commands/invoke.js +9 -6
  73. package/dist/cli/commands/login.js +35 -21
  74. package/dist/cli/commands/schedule.js +6 -8
  75. package/dist/cli/commands/start.d.ts +1 -0
  76. package/dist/cli/commands/start.js +101 -37
  77. package/dist/cli/commands/tool.js +30 -18
  78. package/dist/cli/fail.d.ts +17 -0
  79. package/dist/cli/fail.js +24 -0
  80. package/dist/cli/program.js +66 -36
  81. package/dist/cli/serve.d.ts +52 -8
  82. package/dist/cli/serve.js +133 -32
  83. package/dist/cli/shared.d.ts +21 -2
  84. package/dist/cli/shared.js +44 -18
  85. package/dist/deploy/agentcore/plan.d.ts +117 -0
  86. package/dist/deploy/agentcore/plan.js +721 -0
  87. package/dist/deploy/agentcore/run.d.ts +73 -0
  88. package/dist/deploy/agentcore/run.js +412 -0
  89. package/dist/deploy/agentcore/zip.d.ts +17 -0
  90. package/dist/deploy/agentcore/zip.js +68 -0
  91. package/dist/deploy/container.d.ts +26 -25
  92. package/dist/deploy/container.js +93 -89
  93. package/dist/deploy/docker/plan.d.ts +1 -1
  94. package/dist/deploy/docker/plan.js +12 -17
  95. package/dist/deploy/fly/plan.d.ts +2 -0
  96. package/dist/deploy/fly/plan.js +27 -19
  97. package/dist/deploy/fly/run.d.ts +12 -1
  98. package/dist/deploy/fly/run.js +36 -2
  99. package/dist/deploy/preflight.d.ts +11 -5
  100. package/dist/deploy/preflight.js +235 -65
  101. package/dist/deploy/railway/plan.d.ts +7 -0
  102. package/dist/deploy/railway/plan.js +41 -16
  103. package/dist/deploy/railway/run.d.ts +8 -1
  104. package/dist/deploy/railway/run.js +7 -2
  105. package/dist/deploy/runner.d.ts +5 -2
  106. package/dist/deploy/runner.js +9 -3
  107. package/dist/dev-supervisor.d.ts +11 -8
  108. package/dist/dev-supervisor.js +53 -51
  109. package/dist/engines/pi/auth.d.ts +8 -7
  110. package/dist/engines/pi/auth.js +12 -10
  111. package/dist/engines/pi/channel.d.ts +1 -1
  112. package/dist/engines/pi/channel.js +5 -5
  113. package/dist/engines/pi/chat.js +2 -2
  114. package/dist/engines/pi/config.d.ts +12 -48
  115. package/dist/engines/pi/config.js +29 -110
  116. package/dist/engines/pi/create.d.ts +32 -24
  117. package/dist/engines/pi/create.js +47 -23
  118. package/dist/engines/pi/definition.d.ts +7 -26
  119. package/dist/engines/pi/definition.js +8 -54
  120. package/dist/engines/pi/harness.d.ts +19 -5
  121. package/dist/engines/pi/harness.js +3 -5
  122. package/dist/engines/pi/login.d.ts +1 -1
  123. package/dist/engines/pi/models.d.ts +3 -3
  124. package/dist/engines/pi/models.js +1 -1
  125. package/dist/engines/pi/{workspace.d.ts → open.d.ts} +31 -25
  126. package/dist/engines/pi/{workspace.js → open.js} +27 -29
  127. package/dist/engines/pi/read-image.d.ts +4 -0
  128. package/dist/engines/pi/read-image.js +62 -0
  129. package/dist/engines/pi/search-tools.d.ts +6 -4
  130. package/dist/engines/pi/search-tools.js +3 -1
  131. package/dist/engines/pi/session-builder.d.ts +2 -2
  132. package/dist/engines/pi/session-builder.js +18 -13
  133. package/dist/engines/pi/tool.d.ts +13 -5
  134. package/dist/engines/pi/tool.js +4 -0
  135. package/dist/engines/pi/wake-tool.d.ts +3 -3
  136. package/dist/env.d.ts +16 -4
  137. package/dist/env.js +43 -5
  138. package/dist/host/node.d.ts +4 -2
  139. package/dist/host/node.js +2 -1
  140. package/dist/loader.d.ts +2 -2
  141. package/dist/loader.js +3 -3
  142. package/dist/log.d.ts +1 -1
  143. package/dist/log.js +1 -1
  144. package/dist/paths.d.ts +138 -0
  145. package/dist/paths.js +326 -0
  146. package/dist/pi.d.ts +2 -2
  147. package/dist/pi.js +2 -2
  148. package/dist/runtime.d.ts +7 -5
  149. package/dist/runtime.js +2 -2
  150. package/dist/scaffold/add-channel.d.ts +7 -3
  151. package/dist/scaffold/add-channel.js +55 -29
  152. package/dist/scaffold/init.d.ts +32 -41
  153. package/dist/scaffold/init.js +161 -185
  154. package/dist/scaffold/templates/env.example +15 -6
  155. package/dist/scaffold/templates/fastagent.config.mjs +1 -1
  156. package/dist/scaffold/templates/gitignore +14 -6
  157. package/dist/scaffold/templates/persona.md +4 -2
  158. package/dist/scaffold/templates/secrets.gitignore +5 -0
  159. package/dist/scaffold/templates.d.ts +1 -7
  160. package/dist/scaffold/templates.js +3 -25
  161. package/dist/scaffold/vendor-skill.d.ts +2 -2
  162. package/dist/scaffold/vendor-skill.js +13 -13
  163. package/dist/schedule/discover.js +4 -4
  164. package/dist/schedule/scheduler.d.ts +40 -1
  165. package/dist/schedule/scheduler.js +89 -56
  166. package/dist/schedule/state.js +1 -1
  167. package/dist/schedule/wake-alarm.d.ts +47 -0
  168. package/dist/schedule/wake-alarm.js +136 -0
  169. package/dist/schedule/wakeups.d.ts +1 -0
  170. package/dist/schedule/wakeups.js +18 -0
  171. package/dist/tunnel.d.ts +3 -3
  172. package/dist/tunnel.js +7 -7
  173. package/package.json +7 -4
  174. package/dist/channels/feishu/owned-threads.d.ts +0 -7
  175. package/dist/channels/feishu/owned-threads.js +0 -47
  176. package/dist/channels/slack/owned-threads.d.ts +0 -6
  177. package/dist/channels/slack/owned-threads.js +0 -43
  178. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  179. package/dist/scaffold/templates/gitignore.kit +0 -2
  180. package/dist/workspace.d.ts +0 -9
  181. package/dist/workspace.js +0 -45
@@ -11,15 +11,19 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
11
11
  * (resolveModel, resolveModelSpec). One concern: everything about fastagent.config.ts.
12
12
  *
13
13
  * Red line: config describes deployment/runtime choices, never authored identity or expertise (those
14
- * live in persona.md + skills, with AGENTS.md as project context). Deleting the config still leaves a
15
- * zero-config agent runnable with a model supplied by --model / FASTAGENT_MODEL.
14
+ * live in persona.md + skills, with AGENTS.md as project context). It has no say in PLACEMENT either —
15
+ * that rule lives in the neutral src/paths.ts — but its FILE is what marks a directory as an agent, so a
16
+ * served agent always has one (its contents may be `export default {}`; a model can still come from
17
+ * --model / FASTAGENT_MODEL). The loader below stays tolerant of a missing file because it is a loader:
18
+ * callers that have not resolved a placement (deploy inspection, tests) may point it anywhere.
16
19
  */
17
- import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
18
- import { homedir } from "node:os";
19
- import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
20
+ import { existsSync, statSync } from "node:fs";
21
+ import { basename, join } from "node:path";
20
22
  import { pathToFileURL } from "node:url";
21
23
  import { THINKING_LEVELS } from "./harness.js";
24
+ import { isBindAddress } from "../../bind.js";
22
25
  import { moduleLoadHint } from "../../loader.js";
26
+ import { AGENT_CONFIG_NAMES, resolveOverridePath, resolveSecretsDir } from "../../paths.js";
23
27
  /** Identity function for typing and IDE completion (vite/next-style). */
24
28
  export function defineConfig(config) {
25
29
  return config;
@@ -41,13 +45,9 @@ function validateStringList(value, key, shape, desc, path) {
41
45
  }
42
46
  }
43
47
  }
44
- /** The config filenames that make a directory a fastagent workspace, in load precedence. ONE source: the
45
- * loader (below) and `scaffoldWorkspace`'s already-a-workspace refusal both read this, so "is there a
46
- * config?" can't diverge between them when the set changes. */
47
- export const WORKSPACE_CONFIG_NAMES = ["fastagent.config.ts", "fastagent.config.js", "fastagent.config.mjs"];
48
- /** Load `<dir>/fastagent.config.ts|.js|.mjs`. No file = zero-config; a wrong-shape file throws. */
48
+ /** Load `<dir>/fastagent.config.ts|.js|.mjs`. No file = defaults; a wrong-shape file throws. */
49
49
  export async function loadConfig(dir) {
50
- const found = WORKSPACE_CONFIG_NAMES.map((name) => join(dir, name)).filter((path) => existsSync(path));
50
+ const found = AGENT_CONFIG_NAMES.map((name) => join(dir, name)).filter((path) => existsSync(path));
51
51
  if (found.length === 0)
52
52
  return { config: {} };
53
53
  if (found.length > 1) {
@@ -75,17 +75,16 @@ export async function loadConfig(dir) {
75
75
  }
76
76
  const c = config;
77
77
  // Unknown keys throw: defineConfig only type-protects .ts authors; a typo in a .js/.mjs config
78
- // (`modle:`) must not silently degrade to zero-config.
78
+ // (`modle:`) must not silently degrade to defaults.
79
79
  for (const key of Object.keys(c)) {
80
80
  if (key !== "model" &&
81
81
  key !== "thinkingLevel" &&
82
- key !== "agentDir" &&
83
82
  key !== "tools" &&
84
83
  key !== "http" &&
85
84
  key !== "deploy" &&
86
85
  key !== "selfSchedule" &&
87
86
  key !== "sessionControl") {
88
- throw new Error(`${path}: unknown key "${key}" (valid keys: model, thinkingLevel, agentDir, tools, http, deploy, selfSchedule, sessionControl)`);
87
+ throw new Error(`${path}: unknown key "${key}" (valid keys: model, thinkingLevel, tools, http, deploy, selfSchedule, sessionControl)`);
89
88
  }
90
89
  }
91
90
  if (c.model !== undefined && typeof c.model !== "string") {
@@ -97,47 +96,6 @@ export async function loadConfig(dir) {
97
96
  if (c.thinkingLevel !== undefined && !THINKING_LEVELS.has(c.thinkingLevel)) {
98
97
  throw new Error(`${path}: "thinkingLevel" must be one of ${[...THINKING_LEVELS].join(", ")}`);
99
98
  }
100
- if (c.agentDir !== undefined && typeof c.agentDir !== "string") {
101
- throw new Error(`${path}: "agentDir" must be a string (a subdirectory relative to the config file)`);
102
- }
103
- if (typeof c.agentDir === "string") {
104
- // Enforce the documented "subdirectory of the config dir" contract: an escaping agentDir (e.g.
105
- // "../shared") would still resolve for tool/channel/persona discovery, but `dev`'s chokidar only
106
- // watches the config dir subtree — edits outside it would silently never trigger a restart. Reject
107
- // it here (fail visibly) rather than let hot-reload break without a signal.
108
- const rel = relative(dir, resolve(dir, c.agentDir));
109
- if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
110
- throw new Error(`${path}: "agentDir" ("${c.agentDir}") must be a subdirectory of the config directory, not escape it`);
111
- }
112
- // An explicitly declared agentDir that doesn't exist is a typo until proven otherwise ("./agnet"):
113
- // without this check every opener would assemble an EMPTY agent (no persona, no skills, no tools)
114
- // with zero errors — the worst silent failure this config can produce. Deliberately NOT auto-created:
115
- // config load is read-only (no implicit operations), and a mkdir would turn the typo into a served
116
- // empty agent plus a junk directory.
117
- // lstat, not stat: a symlink would pass the literal containment check above while its TARGET lives
118
- // outside the config dir — exactly what that check exists to prevent (dev's watch would silently
119
- // never see edits). Same rule as init's parent preflight: reject, don't follow.
120
- const agentDirAbs = resolve(dir, c.agentDir);
121
- const st = lstatSync(agentDirAbs, { throwIfNoEntry: false });
122
- if (!st) {
123
- throw new Error(`${path}: "agentDir" ("${c.agentDir}") does not exist — create it, or fix the path`);
124
- }
125
- if (st.isSymbolicLink()) {
126
- // Separate message: to its user a symlink LOOKS like a working directory — name the reason and the fix.
127
- throw new Error(`${path}: "agentDir" ("${c.agentDir}") is a symlink — not allowed (its target can live outside the ` +
128
- `config directory, where dev's watch would never see edits); use a real directory, or point agentDir at the target's real path`);
129
- }
130
- if (!st.isDirectory()) {
131
- throw new Error(`${path}: "agentDir" ("${c.agentDir}") is not a directory`);
132
- }
133
- // The leaf lstat can't see a symlinked INTERMEDIATE segment (agentDir "./a/b" with `a` → outside):
134
- // realpath equality covers every segment under the config dir in one comparison. dir itself is
135
- // realpath'd on both sides, so a symlinked config-dir path (macOS /tmp) stays legal.
136
- if (realpathSync(agentDirAbs) !== resolve(realpathSync(dir), relative(dir, agentDirAbs))) {
137
- throw new Error(`${path}: "agentDir" ("${c.agentDir}") resolves through a symlink — not allowed (the target can ` +
138
- `live outside the config directory, where dev's watch would never see edits); use the real path`);
139
- }
140
- }
141
99
  if (c.selfSchedule !== undefined && typeof c.selfSchedule !== "boolean") {
142
100
  throw new Error(`${path}: "selfSchedule" must be a boolean`);
143
101
  }
@@ -159,13 +117,18 @@ export async function loadConfig(dir) {
159
117
  throw new Error(`${path}: "http" must be an object`);
160
118
  }
161
119
  for (const key of Object.keys(c.http ?? {})) {
162
- if (key !== "port") {
163
- throw new Error(`${path}: unknown key "http.${key}" (valid keys: port)`);
120
+ if (key !== "port" && key !== "host") {
121
+ throw new Error(`${path}: unknown key "http.${key}" (valid keys: port, host)`);
164
122
  }
165
123
  }
166
124
  if (c.http?.port !== undefined && (typeof c.http.port !== "number" || !isValidPort(c.http.port))) {
167
125
  throw new Error(`${path}: "http.port" must be an integer 0-65535`);
168
126
  }
127
+ // Validated as strictly as http.port: an unbindable string ("banana") must fail HERE, not surface
128
+ // later as a topology diagnostic about "the interface you bound".
129
+ if (c.http?.host !== undefined && (typeof c.http.host !== "string" || !isBindAddress(c.http.host))) {
130
+ throw new Error(`${path}: "http.host" must be an IP address or "localhost" (e.g. "127.0.0.1", "0.0.0.0")`);
131
+ }
169
132
  if (c.deploy !== undefined && (typeof c.deploy !== "object" || c.deploy === null)) {
170
133
  throw new Error(`${path}: "deploy" must be an object`);
171
134
  }
@@ -181,15 +144,6 @@ export async function loadConfig(dir) {
181
144
  validateStringList(c.deploy?.apt, "deploy.apt", /^[a-z0-9][a-z0-9.+-]*$/, "a Debian package name", path);
182
145
  return { config: c, path };
183
146
  }
184
- /**
185
- * The agent-definition dir from config: `config.agentDir` resolved against `dir`, or `dir` itself when
186
- * unset (flat). The ONE place this is computed — every opener (`dev`/`start`/`info`/`tool`/`deploy`/`chat`)
187
- * calls it, so the "relative to the config dir, default `.`" rule can never diverge. loadConfig has
188
- * already validated that agentDir stays under `dir`.
189
- */
190
- export function resolveAgentDir(dir, config) {
191
- return resolve(dir, config.agentDir ?? ".");
192
- }
193
147
  /** The provider prefix of a "provider/modelId" spec. A spec without "/" returns whole — downstream
194
148
  * lookups then miss visibly (an unknown-provider error / a login-required hint), never a mangled id
195
149
  * (`slice(0, indexOf("/"))` silently drops the last char when "/" is absent). */
@@ -223,7 +177,7 @@ export function listModels(models) {
223
177
  /**
224
178
  * Rewrite the `model` in a config file's SOURCE TEXT to `spec`, for the first-run picker's write-back.
225
179
  * Handles the scaffold's commented placeholder (`// model: "…"`) and an existing `model:` line; returns
226
- * null when neither is present (zero-config or a hand-shaped config) so the caller falls back to a
180
+ * null when neither is present (no config file, or a hand-shaped one) so the caller falls back to a
227
181
  * printed hint instead of guessing where to insert. Text-level (not AST) on purpose — it only ever
228
182
  * touches a line it recognizes, never reformats the author's file.
229
183
  */
@@ -248,19 +202,6 @@ export function rewriteConfigModel(src, spec) {
248
202
  export function resolveModelSpec(flag, config, env = process.env) {
249
203
  return flag ?? env.FASTAGENT_MODEL ?? config.model;
250
204
  }
251
- /**
252
- * Resolve a user-supplied path override (a CLI flag or an env var) to an absolute path, expanding a
253
- * leading `~`/`~/` to the home dir FIRST. Path-valued config from `.env` (or any non-shell source)
254
- * never gets the shell's `~` expansion, so a bare `resolve("~/x")` would silently create a literal `~`
255
- * directory — a fail-silently footgun for a secret/state path. Expanding here makes `~` mean home
256
- * everywhere these knobs are read.
257
- */
258
- function resolveOverridePath(raw) {
259
- if (!raw)
260
- return undefined;
261
- const expanded = raw === "~" ? homedir() : raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw;
262
- return resolve(expanded);
263
- }
264
205
  /**
265
206
  * `start`'s sessions-dir override: `--sessions-dir` flag > `FASTAGENT_SESSIONS_DIR` env > undefined
266
207
  * (the opener then falls back to {@link defaultSessionsDir} under the {@link resolveStateRoot} root).
@@ -271,43 +212,21 @@ export function resolveSessionsDirOverride(flag, env = process.env) {
271
212
  }
272
213
  /**
273
214
  * The auth-file override: `--auth-path` flag > `FASTAGENT_AUTH_PATH` env > undefined (the opener then
274
- * falls back to {@link defaultAuthPath} under the {@link resolveStateRoot} root). Resolved to absolute
215
+ * falls back to {@link defaultAuthPath} under the {@link resolveSecretsDir} dir). Resolved to absolute
275
216
  * so the store and the startup report agree regardless of cwd. No implicit project↔global fallback (isolation
276
217
  * + fail-visibly; see auth.ts); to share one account across projects, point this at the global
277
- * `~/.fastagent/auth.json` — sharing ONE file is safe under the store's cross-process refresh lock.
218
+ * `~/.fastagent/.secrets/auth.json` — sharing ONE file is safe under the store's cross-process refresh lock.
278
219
  */
279
- export function resolveAuthPathOverride(flag, env = process.env) {
220
+ function resolveAuthPathOverride(flag, env = process.env) {
280
221
  return resolveOverridePath(flag ?? env.FASTAGENT_AUTH_PATH);
281
222
  }
282
- /**
283
- * The IN-TREE default state root, `<dir>/.fastagent` — what {@link resolveStateRoot} falls back to when
284
- * `FASTAGENT_STATE_DIR` moves state nowhere. THE single definition of that path segment.
285
- */
286
- function projectStateDir(dir) {
287
- return join(dir, ".fastagent");
288
- }
289
- /**
290
- * The resolved state root — the ONE durable machine-state home everything derives from (auth.json,
291
- * sessions/, channels/<kind>/): `FASTAGENT_STATE_DIR` env > `<dir>/.fastagent`. Absolute, so channels
292
- * and the startup report agree regardless of cwd. Definition: single lifecycle (precious, survives
293
- * redeploy), single process — a container mounts ONE volume here. The finer knobs
294
- * (`FASTAGENT_SESSIONS_DIR`, `FASTAGENT_AUTH_PATH`) still override their specific path on top.
295
- *
296
- * `FASTAGENT_STATE_DIR` is an OPERATOR override, so a relative value resolves against `process.cwd()`
297
- * — the CLI convention its sibling knobs share (`resolveOverridePath`), NOT against `dir`. Only the
298
- * DEFAULT (`<dir>/.fastagent`) is dir-anchored. Deployments set an absolute path (a mounted volume);
299
- * a relative value is in-tree — hence self-ignored — only when run from the definition dir (cwd == dir).
300
- */
301
- export function resolveStateRoot(dir, env = process.env) {
302
- return resolveOverridePath(env.FASTAGENT_STATE_DIR) ?? resolve(projectStateDir(dir));
303
- }
304
- /** The default credentials file under a resolved state root ({@link resolveStateRoot}). */
305
- export function defaultAuthPath(stateRoot) {
306
- return join(stateRoot, "auth.json");
223
+ /** The default credentials file under a resolved secrets dir ({@link resolveSecretsDir}). */
224
+ export function defaultAuthPath(secretsDir) {
225
+ return join(secretsDir, "auth.json");
307
226
  }
308
- /** The effective auth file for a workspace: override if present, else the project-level auth.json. */
227
+ /** The effective auth file for an agent: override if present, else `<secrets dir>/auth.json`. */
309
228
  export function resolveAuthPath(dir, flag, env = process.env) {
310
- return resolveAuthPathOverride(flag, env) ?? defaultAuthPath(resolveStateRoot(dir, env));
229
+ return resolveAuthPathOverride(flag, env) ?? defaultAuthPath(resolveSecretsDir(dir, env));
311
230
  }
312
231
  /** The default sessions dir under a resolved state root ({@link resolveStateRoot}). */
313
232
  export function defaultSessionsDir(stateRoot) {
@@ -1,4 +1,4 @@
1
- import type { AgentTool, ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
1
+ import { type ExecutionEnv, type Skill, type ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Models, Provider } from "@earendil-works/pi-ai";
3
3
  import type { Agent } from "../../agent.ts";
4
4
  import { type FastagentConfig } from "./config.ts";
@@ -6,19 +6,20 @@ import { type LoadedDefinition } from "./definition.ts";
6
6
  import { piHarnessFactory } from "./harness.ts";
7
7
  import { type PiSessionStore } from "./sessions.ts";
8
8
  import type { ModuleLoadFailure } from "../../loader.ts";
9
- import { type FastagentTool, type ToolCollision } from "./tool.ts";
9
+ import { type ToolCollision, type MountedTool } from "./tool.ts";
10
10
  import { type Lease, type SessionObserver } from "./invoke.ts";
11
- /** pi's core default toolset (read/bash/edit/write), rooted at cwd. */
12
- export declare function piDefaultTools(cwd: string): AgentTool[];
11
+ /** pi's core default toolset (read/bash/edit/write). Rooted at the ExecutionEnv's cwd, supplied per
12
+ * turn as the harness tool context — hence no argument here. */
13
+ export declare function piDefaultTools(): MountedTool[];
13
14
  /** `config.tools` semantics: extra tools APPENDED after pi's defaults, never replacing them. */
14
- export declare function resolveTools(config: FastagentConfig, cwd: string): AgentTool[];
15
+ export declare function resolveTools(config: FastagentConfig): MountedTool[];
15
16
  /**
16
- * The full tool set a workspace mounts: pi defaults + `config.tools` + discovered `tools/` (deduped,
17
+ * The full tool set an agent mounts: pi defaults + `config.tools` + discovered `tools/` (deduped,
17
18
  * existing win), plus the non-default tool names and collisions to report. One source for the
18
19
  * dev/start openers AND `fastagent tool`, so they all mount exactly the same set.
19
20
  */
20
- export declare function resolveWorkspaceTools(config: FastagentConfig, agentDir: string, cwd?: string): Promise<{
21
- tools: AgentTool[];
21
+ export declare function resolveAgentTools(config: FastagentConfig, agentDir: string): Promise<{
22
+ tools: MountedTool[];
22
23
  toolNames: string[];
23
24
  /** Tools registered but not initially active (defineTool `deferred: true`) — discovered/activated
24
25
  * via the built-in `search_tools` loader. Surfaced so the operator can see deferral took effect. */
@@ -33,7 +34,7 @@ export declare function resolveWorkspaceTools(config: FastagentConfig, agentDir:
33
34
  * `persona` (from persona.md) replaces the default identity line, keeping the tools list + guidelines.
34
35
  */
35
36
  export declare function piBasePrompt(options?: {
36
- tools?: AgentTool[];
37
+ tools?: MountedTool[];
37
38
  persona?: string;
38
39
  }): string;
39
40
  export interface AssembleSystemPromptOptions {
@@ -76,8 +77,10 @@ export interface CreatePiAgentOptions {
76
77
  * or a factory re-evaluated per invoke. When {@link skills} are mounted their listing is appended.
77
78
  */
78
79
  instructions?: string | (() => string);
79
- /** `FastagentTool` = AgentTool plus the optional `deferred` marker (see {@link DefineToolOptions}). */
80
- tools?: FastagentTool[];
80
+ /** The tool set to mount. `FastagentTool` (AgentTool plus the optional `deferred` marker, see
81
+ * {@link DefineToolOptions}) widens into {@link MountedTool}, which additionally admits pi's default
82
+ * coding tools — they read the turn's ExecutionEnv as a fifth `execute` parameter. */
83
+ tools?: MountedTool[];
81
84
  skills?: Skill[];
82
85
  /**
83
86
  * Extra providers registered on top of the built-ins — your own gateway / self-hosted endpoint /
@@ -86,15 +89,17 @@ export interface CreatePiAgentOptions {
86
89
  */
87
90
  providers?: Provider[];
88
91
  /**
89
- * Credentials file for stored OAuth/API-key auth. Defaults to `~/.fastagent/auth.json`; the
90
- * directory opener passes the project-level `<dir>/.fastagent/auth.json` instead. Env vars are still
92
+ * Credentials file for stored OAuth/API-key auth. Defaults to `~/.fastagent/.secrets/auth.json`; the
93
+ * directory opener passes the project-level `<root>/.secrets/auth.json` instead. Env vars are still
91
94
  * consulted when a provider is absent from the file (resolution order is upstream-owned).
92
95
  */
93
96
  authPath?: string;
94
97
  /** Session persistence. Defaults to in-memory; inject jsonlSessionStore for restart-surviving continuity. */
95
98
  sessions?: PiSessionStore;
96
- /** Harness filesystem/process environment. Defaults to local NodeExecutionEnv (cwd). This is not yet
97
- * a sandbox boundary for pi's cwd-bound coding tools; a sandbox adapter must wire those tools too. */
99
+ /** Filesystem/process environment. Defaults to a local NodeExecutionEnv at `process.cwd()`, and its
100
+ * cwd is the agent's. The default coding tools (read/bash/edit/write) take it as the turn's tool
101
+ * context, so injecting a constrained one narrows where the agent reads, writes and shells. It does
102
+ * NOT constrain author-written `tools/`, which are code and can import anything. */
98
103
  env?: ExecutionEnv;
99
104
  /** Single-writer lease. Defaults to in-process fail-fast inProcessLease(). */
100
105
  lease?: Lease;
@@ -116,27 +121,30 @@ export interface CreatePiAgentFromDefinitionOptions {
116
121
  /** Override the engine base prompt (segment ①). Defaults to piBasePrompt({ tools, persona }) using the
117
122
  * live-read persona.md; pass base to fully opt out of persona.md. */
118
123
  base?: string;
119
- /** Override tools. Defaults to piDefaultTools (lock down with a custom list). `FastagentTool` =
120
- * AgentTool plus the optional `deferred` marker. */
121
- tools?: FastagentTool[];
124
+ /** Override tools. Defaults to {@link piDefaultTools} (lock down with a custom list). An authored
125
+ * `FastagentTool[]` (AgentTool plus the optional `deferred` marker) widens into {@link MountedTool}. */
126
+ tools?: MountedTool[];
122
127
  /**
123
128
  * The agent's working directory: where the default tools operate AND whose ancestors are walked for
124
- * ② project context (AGENTS.md). Defaults to `dir` (flat: the definition dir is also the run root).
125
- * Set it to the enclosing repo so a coding agent whose definition lives in `dir` operates on — and
126
- * reads the AGENTS.md of that repo (core.md scenario grid).
129
+ * ② project context (AGENTS.md). Defaults to `dir`. Set it to the enclosing repo so a coding agent
130
+ * whose definition lives in `dir` operates on — and reads the AGENTS.md of — that repo (core.md
131
+ * scenario grid); that is what the CLI's opener does with the workspace.
127
132
  */
128
133
  cwd?: string;
129
134
  /** Extra providers registered on top of the built-ins (your own gateway / self-hosted endpoint). */
130
135
  providers?: Provider[];
131
136
  /**
132
137
  * Credentials file (see {@link CreatePiAgentOptions.authPath}). Being dir-aware, this rung defaults
133
- * to the PROJECT-level `<dir>/.fastagent/auth.json` (matching `fastagent dev`/`start` on the same
138
+ * to the PROJECT-level `<dir>/.secrets/auth.json` (matching `fastagent dev`/`start` on the same
134
139
  * dir) — unlike the dir-less {@link createPiAgent}/{@link createPiModels}, which default global.
135
140
  */
136
141
  authPath?: string;
137
142
  sessions?: PiSessionStore;
138
- /** Harness environment; see {@link CreatePiAgentOptions.env}. The default coding tools and project-
139
- * context loader remain local today, so injecting this alone does not sandbox a directory agent. */
143
+ /** Filesystem/process environment; see {@link CreatePiAgentOptions.env}. At THIS rung it does more
144
+ * than root the default tools: persona.md and skills/ are read through it too. Two surfaces stay
145
+ * OUTSIDE it — ② project context (pi's loadProjectContextFiles uses node fs directly; see
146
+ * definition.ts) and author-written `tools/`, which are code and can import anything. Injecting an
147
+ * env narrows the blast radius rather than closing it. */
140
148
  env?: ExecutionEnv;
141
149
  lease?: Lease;
142
150
  /** Observation-plane tap; see {@link CreatePiAgentOptions.observer}. */
@@ -6,14 +6,16 @@
6
6
  * L1 createPiAgent(options) — assemble from typed parts (the canonical ctor).
7
7
  * L0 createPiAgentFromHarness({ harnessFactory }) — in invoke.ts (its body is the turn mechanism).
8
8
  *
9
- * Above L2 sits the workspace opener createPiAgentFromWorkspace (workspace.ts), which both `dev` and
9
+ * Above L2 sits the agent opener createPiAgentFromDir (open.ts), which both `dev` and
10
10
  * `start` drive. Each rung calls the one below; options narrow as you go up (L2 owns systemPrompt/skills —
11
11
  * they come from the definition; the openers own model/tools — from config resolution).
12
12
  */
13
13
  import { formatSkillsForSystemPrompt } from "@earendil-works/pi-agent-core";
14
+ import { createBashTool, createEditTool, createReadTool, createWriteTool, } from "@earendil-works/pi-agent-core";
14
15
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
- import { createCodingTools } from "@earendil-works/pi-coding-agent";
16
- import { defaultAuthPath, resolveModel, resolveStateRoot } from "./config.js";
16
+ import { readImageProcessor } from "./read-image.js";
17
+ import { defaultAuthPath, resolveModel } from "./config.js";
18
+ import { resolveSecretsDir } from "../../paths.js";
17
19
  import { loadAgentDefinition } from "./definition.js";
18
20
  import { piHarnessFactory } from "./harness.js";
19
21
  import { createPiModels } from "./models.js";
@@ -25,30 +27,52 @@ import { createPiAgentFromHarness, inProcessLease } from "./invoke.js";
25
27
  // ── §1 tools ─────────────────────────────────────────────────────────────────
26
28
  //
27
29
  // The full pi toolset is the default for fidelity: authors vibe in local pi with it, so serving with
28
- // fewer tools is behavior drift. Isolation is the K-side ExecutionEnv/sandbox's job, not the tool
29
- // layer's; locking down for public exposure = passing a restricted `tools` list (a deployment posture).
30
- /** pi's core default toolset (read/bash/edit/write), rooted at cwd. */
31
- export function piDefaultTools(cwd) {
32
- return createCodingTools(cwd);
30
+ // fewer tools is behavior drift. Locking down for public exposure = passing a restricted `tools` list
31
+ // (a deployment posture).
32
+ //
33
+ // These are pi-agent-core's tools, which reach the filesystem and the shell through the
34
+ // {@link ExecutionEnv} the harness hands them per turn — NOT pi-coding-agent's, which are the same four
35
+ // tools wired to `node:fs` directly. Going through the env is the point, and the whole of it: it makes
36
+ // {@link CreatePiAgentOptions.env} the one seam a sandbox adapter has to implement, instead of a knob
37
+ // that governed everything except the tools that actually touch the machine. (It buys no decoupling
38
+ // from pi-coding-agent — definition.ts, models.ts and read-image.ts all import it regardless.)
39
+ //
40
+ // The swap holds only while the two behave alike, which they do NOT for free: core's `read` does
41
+ // nothing with images unless a processor is injected (read-image.ts), and both families are compared
42
+ // on every path in test/tools-parity.test.ts.
43
+ //
44
+ // `chat` is unaffected: it takes these NAMES only and lets pi's own runtime rebuild the tools it
45
+ // renders (see session-builder.ts).
46
+ /** pi's core default toolset (read/bash/edit/write). Rooted at the ExecutionEnv's cwd, supplied per
47
+ * turn as the harness tool context — hence no argument here. */
48
+ export function piDefaultTools() {
49
+ // `read` needs its image pipeline INJECTED (core ships none); see read-image.ts for what is at stake.
50
+ return [
51
+ createReadTool({ imageProcessor: readImageProcessor }),
52
+ createBashTool(),
53
+ createEditTool(),
54
+ createWriteTool(),
55
+ ];
33
56
  }
34
57
  /** `config.tools` semantics: extra tools APPENDED after pi's defaults, never replacing them. */
35
- export function resolveTools(config, cwd) {
36
- const defaults = piDefaultTools(cwd);
58
+ export function resolveTools(config) {
59
+ const defaults = piDefaultTools();
37
60
  return config.tools ? [...defaults, ...config.tools] : defaults;
38
61
  }
39
62
  /**
40
- * The full tool set a workspace mounts: pi defaults + `config.tools` + discovered `tools/` (deduped,
63
+ * The full tool set an agent mounts: pi defaults + `config.tools` + discovered `tools/` (deduped,
41
64
  * existing win), plus the non-default tool names and collisions to report. One source for the
42
65
  * dev/start openers AND `fastagent tool`, so they all mount exactly the same set.
43
66
  */
44
- export async function resolveWorkspaceTools(config, agentDir, cwd = agentDir) {
45
- // Default coding tools (read/bash/edit/write) are rooted at `cwd` (the run root the agent operates on);
46
- // discovered `tools/` come from `agentDir` (the agent's own surface). They coincide in the flat case.
67
+ export async function resolveAgentTools(config, agentDir) {
68
+ // Discovered `tools/` come from `agentDir` (the agent's own surface); the default coding tools carry
69
+ // no root of their own — they operate through the ExecutionEnv handed to them per turn, whose cwd is
70
+ // the workspace.
47
71
  const discovered = await loadTools(agentDir);
48
- const merged = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
49
- // The built-in `search_tools` loader mounts here — the one place the workspace's full tool set is
50
- // computed — so `dev`/`start`/`info`/`fastagent tool` all see the same surface (idempotent; a
51
- // workspace-defined search_tools wins).
72
+ const merged = mergeDiscoveredTools(resolveTools(config), discovered.tools);
73
+ // The built-in `search_tools` loader mounts here — the one place the agent's full tool set is
74
+ // computed — so `dev`/`start`/`info`/`fastagent tool` all see the same surface (idempotent; an
75
+ // agent-defined search_tools wins).
52
76
  const tools = withSearchTool(merged.tools);
53
77
  // Builtin = a search_tools that was ABSENT before withSearchTool (a reference compare would misfire
54
78
  // on the deferred-authored-loader case, where withSearchTool returns a new array without adding one).
@@ -58,7 +82,7 @@ export async function resolveWorkspaceTools(config, agentDir, cwd = agentDir) {
58
82
  // defaults, the builtin loader (like wake, a builtin gets its own report line, not an anonymous
59
83
  // slot in the author's list — an author-DEFINED search_tools still shows), and deferred tools —
60
84
  // each name lives in exactly ONE report slot, and deferred names live in `deferredToolNames`.
61
- const defaultNames = new Set(piDefaultTools(cwd).map((t) => t.name));
85
+ const defaultNames = new Set(piDefaultTools().map((t) => t.name));
62
86
  const toolNames = tools
63
87
  .filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
64
88
  .map((t) => t.name);
@@ -98,7 +122,7 @@ export function piBasePrompt(options = {}) {
98
122
  const deferredCount = mounted.length - tools.length;
99
123
  const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
100
124
  // Segment ① identity: an authored persona (persona.md) replaces the default engine identity line
101
- // (the standalone×code-repo cell's persona; core.md §11), keeping the tools list + guidelines below.
125
+ // (core.md §11), keeping the tools list + guidelines below.
102
126
  const identity = options.persona?.trim() ||
103
127
  "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
104
128
  const deferredNote = deferredCount > 0
@@ -215,14 +239,14 @@ export async function createPiAgentFromDefinition(dir, options) {
215
239
  let reportedFindings = findingsSignature(definition);
216
240
  // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
217
241
  // it; a caller's own search_tools wins).
218
- const tools = withSearchTool(options.tools ?? piDefaultTools(env.cwd));
242
+ const tools = withSearchTool(options.tools ?? piDefaultTools());
219
243
  const agent = buildPiAgent({
220
244
  model: options.model,
221
245
  thinkingLevel: options.thinkingLevel,
222
246
  providers: options.providers,
223
- // Dir-aware default: the same state-root-derived file the opener uses for this dir (the opener
247
+ // Dir-aware default: the same secrets-dir-derived file the opener uses for this dir (the opener
224
248
  // passes an explicit authPath, so this only affects direct L2 callers).
225
- authPath: options.authPath ?? defaultAuthPath(resolveStateRoot(dir)),
249
+ authPath: options.authPath ?? defaultAuthPath(resolveSecretsDir(dir)),
226
250
  // The directory is the agent, LIVE: re-read the definition on every invoke, so AGENTS.md/skills
227
251
  // edits (the author's, or the agent's own self-modification) take effect on the next turn with
228
252
  // no process restart — restarts are reserved for code (tools/channels/config, module cache).
@@ -32,9 +32,9 @@ export interface LoadedDefinition {
32
32
  }
33
33
  export interface LoadAgentDefinitionOptions {
34
34
  /**
35
- * Working directory whose ancestors are walked for context files (segment ②). Default = `agentDir`
36
- * (flat: the agent dir is also the run root). The opener passes the run root so a coding agent that
37
- * lives in `agentDir` picks up the host repo's AGENTS.md up the tree (core.md scenario grid).
35
+ * Working directory whose ancestors are walked for context files (segment ②). Default = `agentDir`.
36
+ * The opener passes the workspace instead, so an agent that lives in `agentDir` picks up the
37
+ * project's AGENTS.md up the tree (core.md scenario grid).
38
38
  */
39
39
  cwd?: string;
40
40
  env?: ExecutionEnv;
@@ -42,31 +42,12 @@ export interface LoadAgentDefinitionOptions {
42
42
  /** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
43
43
  export declare function loadAgentDefinition(agentDir: string, options?: LoadAgentDefinitionOptions): Promise<LoadedDefinition>;
44
44
  /**
45
- * Whether `targetPath` lives inside `baseDir` (same path counts). The self-ignore guard uses it to ask
46
- * "does the resolved state root land inside the workspace tree?" an in-tree root (the default
47
- * `.fastagent`, or a custom `FASTAGENT_STATE_DIR` pointed inside the agent dir) is ours to self-ignore;
48
- * a root on an external volume resolves outside and must not be (we never write a `.gitignore` outside
49
- * the tree). Whether a relative override lands in-tree is a cwd question — see `resolveStateRoot`.
45
+ * Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
46
+ * this OUT of the agent?" the startup report's redeploy notes, `add`'s printed `.env` label, and the
47
+ * dev watcher's "your .env is not watched" warning all turn on that fact. Reporting only: fastagent
48
+ * does not act on where a user's paths point.
50
49
  */
51
50
  export declare function isUnderDir(targetPath: string, baseDir: string): boolean;
52
- /**
53
- * The single owner of the self-ignore MECHANISM: iff the resolved state ROOT lands inside the workspace
54
- * tree, write `<stateRoot>/.gitignore="*"` — which then covers EVERYTHING under it (sessions, auth.json,
55
- * every channel's `channels/<kind>` home). `ensureStateDirSelfIgnored` is private, so a caller cannot
56
- * write a `.gitignore` bypassing this.
57
- *
58
- * ROOT-based, not path-based: everything derives from the state root (config.ts), so protecting the
59
- * root protects all of it — INCLUDING a custom in-tree root (a `FASTAGENT_STATE_DIR` inside the agent
60
- * dir), the case a path-based (`.fastagent`-only) guard would leak. A per-path override
61
- * (`--sessions-dir`/`--auth-path`) is operator-owned: pointed at an external volume it is out-of-tree
62
- * (correctly not ours to ignore); pointed at a custom in-tree dir WE DON'T OWN, we do not write a
63
- * `.gitignore` into it (it may be a directory the operator deliberately tracks).
64
- *
65
- * Excludes the user's HOME-global `~/.fastagent` (e.g. `login`/`dev` run from `$HOME`): self-ignore is
66
- * for protecting state inside an agent PROJECT tree, not for writing a `.gitignore` into the user's
67
- * home, which a dotfiles repo may track. The global credential file there was never self-ignored.
68
- */
69
- export declare function ensureStateRootSelfIgnored(dir: string, stateRoot: string): Promise<void>;
70
51
  /** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath.
71
52
  * A non-existent path can't be realpath'd, so it stays as the plain absolute resolve. */
72
53
  export declare function canonicalPath(p: string): string;
@@ -14,12 +14,11 @@
14
14
  * (bad skill files, name collisions) are returned as data. An unreadable ② context file only warns (pi).
15
15
  */
16
16
  import { realpathSync } from "node:fs";
17
- import { mkdir, writeFile } from "node:fs/promises";
18
- import { homedir } from "node:os";
19
17
  import { isAbsolute, join, relative, resolve } from "node:path";
20
18
  import { loadSkills } from "@earendil-works/pi-agent-core";
21
19
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
22
20
  import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
21
+ import { assertInsideAgentDir } from "../../paths.js";
23
22
  /** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
24
23
  export async function loadAgentDefinition(agentDir, options = {}) {
25
24
  // One resolved default for the working directory (env cwd AND the context-walk start), so they can
@@ -44,7 +43,9 @@ export async function loadAgentDefinition(agentDir, options = {}) {
44
43
  }
45
44
  const persona = personaRead.ok ? personaRead.value : undefined;
46
45
  // Skills come ONLY from the definition's own skills/ (no external/global mount), so the same
47
- // definition loads the same skills on every machine.
46
+ // definition loads the same skills on every machine — and, like tools/channels/schedules, a symlink
47
+ // that escapes the agent dir is refused rather than followed (the fourth of four surfaces).
48
+ await assertInsideAgentDir(root, "skills");
48
49
  const { skills: raw, diagnostics } = await loadSkills(e, [join(root, "skills")]);
49
50
  const byName = new Map();
50
51
  const collisions = [];
@@ -60,62 +61,15 @@ export async function loadAgentDefinition(agentDir, options = {}) {
60
61
  return { contextFiles, persona, skills: [...byName.values()], diagnostics, collisions, dir: root };
61
62
  }
62
63
  /**
63
- * Whether `targetPath` lives inside `baseDir` (same path counts). The self-ignore guard uses it to ask
64
- * "does the resolved state root land inside the workspace tree?" an in-tree root (the default
65
- * `.fastagent`, or a custom `FASTAGENT_STATE_DIR` pointed inside the agent dir) is ours to self-ignore;
66
- * a root on an external volume resolves outside and must not be (we never write a `.gitignore` outside
67
- * the tree). Whether a relative override lands in-tree is a cwd question — see `resolveStateRoot`.
64
+ * Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
65
+ * this OUT of the agent?" the startup report's redeploy notes, `add`'s printed `.env` label, and the
66
+ * dev watcher's "your .env is not watched" warning all turn on that fact. Reporting only: fastagent
67
+ * does not act on where a user's paths point.
68
68
  */
69
69
  export function isUnderDir(targetPath, baseDir) {
70
70
  const rel = relative(baseDir, targetPath);
71
71
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
72
72
  }
73
- /**
74
- * Self-ignore a state dir: create it if missing, then write `<stateDir>/.gitignore` = "*" (idempotent
75
- * — an existing one is kept), so a workspace that runs dev/start never shows machine state as
76
- * untracked. Creates the dir because a caller may self-ignore it before anything else populates it
77
- * (e.g. `login` writing auth.json into a not-yet-created root).
78
- *
79
- * Module-PRIVATE on purpose: the only entry to the leak guard is {@link ensureStateRootSelfIgnored}
80
- * (home exclusion + containment). Keeping this unexported makes that single-owner claim hold at the
81
- * type level — a sibling command can't bypass those checks by writing a `.gitignore` directly.
82
- */
83
- async function ensureStateDirSelfIgnored(stateDir) {
84
- await mkdir(stateDir, { recursive: true });
85
- await writeFile(join(stateDir, ".gitignore"), "*\n", { flag: "wx" }).catch((e) => {
86
- if (e.code !== "EEXIST")
87
- throw e;
88
- });
89
- }
90
- /**
91
- * The single owner of the self-ignore MECHANISM: iff the resolved state ROOT lands inside the workspace
92
- * tree, write `<stateRoot>/.gitignore="*"` — which then covers EVERYTHING under it (sessions, auth.json,
93
- * every channel's `channels/<kind>` home). `ensureStateDirSelfIgnored` is private, so a caller cannot
94
- * write a `.gitignore` bypassing this.
95
- *
96
- * ROOT-based, not path-based: everything derives from the state root (config.ts), so protecting the
97
- * root protects all of it — INCLUDING a custom in-tree root (a `FASTAGENT_STATE_DIR` inside the agent
98
- * dir), the case a path-based (`.fastagent`-only) guard would leak. A per-path override
99
- * (`--sessions-dir`/`--auth-path`) is operator-owned: pointed at an external volume it is out-of-tree
100
- * (correctly not ours to ignore); pointed at a custom in-tree dir WE DON'T OWN, we do not write a
101
- * `.gitignore` into it (it may be a directory the operator deliberately tracks).
102
- *
103
- * Excludes the user's HOME-global `~/.fastagent` (e.g. `login`/`dev` run from `$HOME`): self-ignore is
104
- * for protecting state inside an agent PROJECT tree, not for writing a `.gitignore` into the user's
105
- * home, which a dotfiles repo may track. The global credential file there was never self-ignored.
106
- */
107
- export async function ensureStateRootSelfIgnored(dir, stateRoot) {
108
- // Compare CANONICAL paths for the home check: `dir` arrives realpath-resolved (it is `process.cwd()`
109
- // or `resolve(".")`) but `homedir()` returns the raw `$HOME`, so a symlinked home would slip past raw
110
- // equality and we'd write a `.gitignore` into the real `~/.fastagent` — the very thing the doc forbids
111
- // (chat.ts canonicalizes for the same reason).
112
- if (canonicalPath(dir) === canonicalPath(homedir()))
113
- return;
114
- // Containment on RAW paths: stateRoot is resolve()'d (config.ts) and `dir` is absolute, so it is exact
115
- // by construction. An external-volume root resolves outside the tree → skip (not ours to ignore).
116
- if (isUnderDir(stateRoot, dir))
117
- await ensureStateDirSelfIgnored(stateRoot);
118
- }
119
73
  /** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath.
120
74
  * A non-existent path can't be realpath'd, so it stays as the plain absolute resolve. */
121
75
  export function canonicalPath(p) {