@fastagent-sh/fastagent 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +13 -3
  2. package/dist/channels/agentcore-limits.d.ts +7 -0
  3. package/dist/channels/agentcore-limits.js +9 -0
  4. package/dist/channels/agentcore-state.d.ts +83 -0
  5. package/dist/channels/agentcore-state.js +258 -0
  6. package/dist/channels/agentcore.d.ts +98 -0
  7. package/dist/channels/agentcore.js +260 -0
  8. package/dist/channels/busy.d.ts +23 -0
  9. package/dist/channels/busy.js +53 -0
  10. package/dist/channels/context-buffer.d.ts +5 -0
  11. package/dist/channels/context-buffer.js +5 -0
  12. package/dist/channels/feishu/context-buffer.d.ts +13 -8
  13. package/dist/channels/feishu/context-buffer.js +60 -6
  14. package/dist/channels/feishu/feishu-api.js +4 -1
  15. package/dist/channels/feishu/feishu.d.ts +0 -12
  16. package/dist/channels/feishu/feishu.js +143 -54
  17. package/dist/channels/feishu/invoke-turn.js +35 -18
  18. package/dist/channels/feishu/model.d.ts +0 -1
  19. package/dist/channels/feishu/normalize.js +0 -1
  20. package/dist/channels/feishu/parse.d.ts +21 -7
  21. package/dist/channels/feishu/parse.js +24 -7
  22. package/dist/channels/feishu/preview.js +3 -2
  23. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -1
  26. package/dist/channels/feishu/setup-mode.js +27 -1
  27. package/dist/channels/github/github.js +8 -1
  28. package/dist/channels/http.js +1 -1
  29. package/dist/channels/lark/scaffold/channel.ts +9 -8
  30. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  31. package/dist/channels/preview-kit.d.ts +7 -1
  32. package/dist/channels/preview-kit.js +3 -2
  33. package/dist/channels/slack/parse.d.ts +16 -1
  34. package/dist/channels/slack/parse.js +46 -3
  35. package/dist/channels/slack/preview.d.ts +1 -2
  36. package/dist/channels/slack/preview.js +68 -24
  37. package/dist/channels/slack/scaffold/channel.ts +5 -5
  38. package/dist/channels/slack/slack-api.d.ts +3 -23
  39. package/dist/channels/slack/slack-api.js +6 -22
  40. package/dist/channels/slack/slack.d.ts +13 -20
  41. package/dist/channels/slack/slack.js +95 -50
  42. package/dist/channels/state.d.ts +11 -4
  43. package/dist/channels/state.js +19 -12
  44. package/dist/channels/tasks.d.ts +0 -6
  45. package/dist/channels/tasks.js +16 -1
  46. package/dist/channels/telegram/parse.d.ts +0 -7
  47. package/dist/channels/telegram/parse.js +4 -2
  48. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  49. package/dist/channels/telegram/telegram.js +1 -1
  50. package/dist/channels/text.d.ts +14 -0
  51. package/dist/channels/text.js +14 -0
  52. package/dist/channels/thread-participants.d.ts +21 -0
  53. package/dist/channels/thread-participants.js +132 -0
  54. package/dist/channels/turn-queue.js +7 -0
  55. package/dist/cli/add-feishu.d.ts +7 -4
  56. package/dist/cli/add-feishu.js +57 -37
  57. package/dist/cli/add-slack.d.ts +2 -1
  58. package/dist/cli/add-slack.js +6 -11
  59. package/dist/cli/commands/add.js +50 -51
  60. package/dist/cli/commands/attach.js +8 -4
  61. package/dist/cli/commands/chat.js +8 -8
  62. package/dist/cli/commands/deploy.d.ts +1 -1
  63. package/dist/cli/commands/deploy.js +323 -77
  64. package/dist/cli/commands/dev.js +24 -22
  65. package/dist/cli/commands/fire.js +15 -16
  66. package/dist/cli/commands/info.js +36 -29
  67. package/dist/cli/commands/init.d.ts +1 -1
  68. package/dist/cli/commands/init.js +65 -53
  69. package/dist/cli/commands/invoke.js +9 -6
  70. package/dist/cli/commands/login.js +35 -21
  71. package/dist/cli/commands/schedule.js +6 -8
  72. package/dist/cli/commands/start.js +93 -37
  73. package/dist/cli/commands/tool.js +22 -18
  74. package/dist/cli/fail.d.ts +17 -0
  75. package/dist/cli/fail.js +24 -0
  76. package/dist/cli/program.js +57 -36
  77. package/dist/cli/serve.d.ts +26 -6
  78. package/dist/cli/serve.js +62 -15
  79. package/dist/cli/shared.d.ts +15 -2
  80. package/dist/cli/shared.js +30 -18
  81. package/dist/deploy/agentcore/plan.d.ts +117 -0
  82. package/dist/deploy/agentcore/plan.js +721 -0
  83. package/dist/deploy/agentcore/run.d.ts +73 -0
  84. package/dist/deploy/agentcore/run.js +412 -0
  85. package/dist/deploy/agentcore/zip.d.ts +17 -0
  86. package/dist/deploy/agentcore/zip.js +68 -0
  87. package/dist/deploy/container.d.ts +26 -25
  88. package/dist/deploy/container.js +93 -89
  89. package/dist/deploy/docker/plan.d.ts +1 -1
  90. package/dist/deploy/docker/plan.js +12 -17
  91. package/dist/deploy/fly/plan.d.ts +2 -0
  92. package/dist/deploy/fly/plan.js +27 -19
  93. package/dist/deploy/fly/run.d.ts +12 -1
  94. package/dist/deploy/fly/run.js +36 -2
  95. package/dist/deploy/preflight.d.ts +11 -5
  96. package/dist/deploy/preflight.js +217 -65
  97. package/dist/deploy/railway/plan.d.ts +7 -0
  98. package/dist/deploy/railway/plan.js +41 -16
  99. package/dist/deploy/railway/run.d.ts +8 -1
  100. package/dist/deploy/railway/run.js +7 -2
  101. package/dist/deploy/runner.d.ts +5 -2
  102. package/dist/deploy/runner.js +9 -3
  103. package/dist/dev-supervisor.d.ts +11 -8
  104. package/dist/dev-supervisor.js +53 -51
  105. package/dist/engines/pi/auth.d.ts +8 -7
  106. package/dist/engines/pi/auth.js +12 -10
  107. package/dist/engines/pi/channel.d.ts +1 -1
  108. package/dist/engines/pi/channel.js +5 -5
  109. package/dist/engines/pi/chat.js +2 -2
  110. package/dist/engines/pi/config.d.ts +6 -46
  111. package/dist/engines/pi/config.js +21 -108
  112. package/dist/engines/pi/create.d.ts +8 -8
  113. package/dist/engines/pi/create.js +13 -12
  114. package/dist/engines/pi/definition.d.ts +7 -26
  115. package/dist/engines/pi/definition.js +8 -54
  116. package/dist/engines/pi/login.d.ts +1 -1
  117. package/dist/engines/pi/models.d.ts +3 -3
  118. package/dist/engines/pi/models.js +1 -1
  119. package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
  120. package/dist/engines/pi/{workspace.js → open.js} +27 -29
  121. package/dist/engines/pi/session-builder.d.ts +2 -2
  122. package/dist/engines/pi/session-builder.js +11 -11
  123. package/dist/engines/pi/tool.js +4 -0
  124. package/dist/env.d.ts +16 -4
  125. package/dist/env.js +43 -5
  126. package/dist/host/node.d.ts +2 -2
  127. package/dist/loader.d.ts +2 -2
  128. package/dist/loader.js +3 -3
  129. package/dist/log.d.ts +1 -1
  130. package/dist/log.js +1 -1
  131. package/dist/paths.d.ts +138 -0
  132. package/dist/paths.js +326 -0
  133. package/dist/pi.d.ts +1 -1
  134. package/dist/pi.js +2 -2
  135. package/dist/runtime.d.ts +7 -5
  136. package/dist/runtime.js +2 -2
  137. package/dist/scaffold/add-channel.d.ts +7 -3
  138. package/dist/scaffold/add-channel.js +55 -29
  139. package/dist/scaffold/init.d.ts +32 -41
  140. package/dist/scaffold/init.js +161 -185
  141. package/dist/scaffold/templates/env.example +15 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +1 -1
  143. package/dist/scaffold/templates/gitignore +14 -6
  144. package/dist/scaffold/templates/persona.md +4 -2
  145. package/dist/scaffold/templates/secrets.gitignore +5 -0
  146. package/dist/scaffold/templates.d.ts +1 -7
  147. package/dist/scaffold/templates.js +3 -25
  148. package/dist/scaffold/vendor-skill.d.ts +2 -2
  149. package/dist/scaffold/vendor-skill.js +13 -13
  150. package/dist/schedule/discover.js +4 -4
  151. package/dist/schedule/scheduler.d.ts +40 -1
  152. package/dist/schedule/scheduler.js +89 -56
  153. package/dist/schedule/state.js +1 -1
  154. package/dist/schedule/wake-alarm.d.ts +47 -0
  155. package/dist/schedule/wake-alarm.js +136 -0
  156. package/dist/schedule/wakeups.d.ts +1 -0
  157. package/dist/schedule/wakeups.js +18 -0
  158. package/dist/tunnel.d.ts +3 -3
  159. package/dist/tunnel.js +7 -7
  160. package/package.json +4 -1
  161. package/dist/channels/feishu/owned-threads.d.ts +0 -7
  162. package/dist/channels/feishu/owned-threads.js +0 -47
  163. package/dist/channels/slack/owned-threads.d.ts +0 -6
  164. package/dist/channels/slack/owned-threads.js +0 -43
  165. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  166. package/dist/scaffold/templates/gitignore.kit +0 -2
  167. package/dist/workspace.d.ts +0 -9
  168. package/dist/workspace.js +0 -45
package/dist/paths.js ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * PLACEMENT: which directory holds the agent, and which directory the agent works ON — plus the
3
+ * machinery paths that follow from it.
4
+ *
5
+ * ONE marker and ONE rule. `fastagent.config.*` declares an agent; the WORKSPACE is the directory you
6
+ * pointed fastagent at. Nothing here reads a directory NAME, so an agent directory can be called
7
+ * anything — and the same tree answers two ways depending on where you aim it: point at the project and
8
+ * the agent directory inside it answers with the project as its workspace; point at the agent directory
9
+ * itself (all a container may have been shipped) and it works on itself. That is not an ambiguity to
10
+ * resolve but the honest answer — an agent alone on a box has no project to work on, and a rule that
11
+ * insisted otherwise would hand it the container root.
12
+ *
13
+ * Engine-neutral by nature (pure fs/path: existence checks and one shallow scan), so it lives here
14
+ * rather than under engines/pi. Not a filing preference: the scaffold, the deploy planners, the dev
15
+ * watcher and env.ts all need these facts, and routing them through the engine would make neutral
16
+ * modules depend on it for something the engine has no say in.
17
+ */
18
+ import { existsSync, readdirSync, statSync } from "node:fs";
19
+ import { access } from "node:fs/promises";
20
+ import { realpath } from "node:fs/promises";
21
+ import { homedir } from "node:os";
22
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
23
+ /**
24
+ * The directory name `init` gives a nested agent (`<workspace>/fastagent/`) unless `--agent-dir` names
25
+ * another. A DEFAULT, not a rule: resolution reads the config marker and never a name, so renaming an
26
+ * agent directory changes nothing about how it resolves. Visible on purpose — the agent directory holds
27
+ * the AUTHOR's content (persona, skills, tool code: code, not tool configuration), so it follows the
28
+ * repo convention for code (a plain directory), while fastagent's own machinery inside it (`.secrets/`,
29
+ * `.state/`) keeps the dot prefix.
30
+ */
31
+ export const DEFAULT_AGENT_DIRNAME = "fastagent";
32
+ /** The user-global machinery home under `$HOME` — hidden, per the dotfile convention for per-user
33
+ * tool homes (`~/.cargo`, `~/.docker`); unrelated to {@link DEFAULT_AGENT_DIRNAME}, which only names
34
+ * what `init` creates.
35
+ * It carries the same shape inside it as an agent dir does (`~/.fastagent/.secrets/auth.json`), so the
36
+ * resolvers below need no special case: `login` outside any agent simply hands them this directory. */
37
+ export const GLOBAL_HOME_DIR = ".fastagent";
38
+ /** The secrets segment inside an agent dir (or the global home): every PATH fastagent resolves —
39
+ * `.env`, `.env.example`, auth.json, the scaffold's write — derives from it, so they cannot drift
40
+ * apart. `FASTAGENT_SECRETS_DIR` relocates the RESOLVED dir ({@link resolveSecretsDir}), never this
41
+ * name. The scaffold's ignore templates are real files the author owns from `init` on, so they spell
42
+ * their rules out as literal text — renaming this constant means editing them too. */
43
+ export const SECRETS_DIRNAME = ".secrets";
44
+ /** The state segment inside an agent dir — same rule and same template caveat as {@link SECRETS_DIRNAME}. */
45
+ export const STATE_DIRNAME = ".state";
46
+ /** The config filenames, in load precedence. ONE source: the loader (below) and `scaffoldAgent`'s
47
+ * already-an-agent refusal both read this, so "is there a config?" can't diverge between them. */
48
+ export const AGENT_CONFIG_NAMES = ["fastagent.config.ts", "fastagent.config.js", "fastagent.config.mjs"];
49
+ /** The definition paths an agent LOADS content from — the surface a second agent must not be scaffolded
50
+ * inside ({@link agentDefinitionOwner}), because the outer agent would read it as its own skills/tools.
51
+ * NOT evidence of an agent: `tools/` and `skills/` are ordinary names half the world's repositories
52
+ * use, and the config is the only marker. */
53
+ const LOADED_SURFACE = ["persona.md", "skills", "tools", "channels", "schedules"];
54
+ function isDir(p) {
55
+ return statSync(p, { throwIfNoEntry: false })?.isDirectory() === true;
56
+ }
57
+ /** THE marker: a directory that declares itself an agent with a `fastagent.config.*`. One file decides
58
+ * it, at every position — which is why an agent directory needs no reserved name and why there is no
59
+ * zero-config agent. */
60
+ function hasConfig(p) {
61
+ return isDir(p) && AGENT_CONFIG_NAMES.some((name) => existsSync(join(p, name)));
62
+ }
63
+ /** The agent directories DIRECTLY inside `dir` — the one-level scan that finds an agent without knowing
64
+ * its name. ONE level: deeper is that directory's own workspace, not this one's agent. Sorted, so a
65
+ * refusal names them in a stable order. A missing `dir` yields none (callers resolve paths that may not
66
+ * exist); a permission fault surfaces. */
67
+ function agentChildren(dir) {
68
+ let entries;
69
+ try {
70
+ entries = readdirSync(dir, { withFileTypes: true });
71
+ }
72
+ catch (e) {
73
+ const code = e.code;
74
+ if (code === "ENOENT" || code === "ENOTDIR")
75
+ return [];
76
+ throw e;
77
+ }
78
+ return entries
79
+ .filter((e) => !e.isFile() && hasConfig(join(dir, e.name)))
80
+ .map((e) => join(dir, e.name))
81
+ .sort();
82
+ }
83
+ /**
84
+ * The agents `dir` resolves over: ITSELF when it holds a config, else the ones directly inside it —
85
+ * never both, because aiming at an agent can only mean that agent. Exported for `init`, which asks the
86
+ * same question this lookup asks: which agents would `dir` resolve over, before and after scaffolding.
87
+ */
88
+ export function agentsAt(dir) {
89
+ const base = resolve(dir);
90
+ return hasConfig(base) ? [base] : agentChildren(base);
91
+ }
92
+ /**
93
+ * WHICH agent is meant, when a directory holds several: an engineer's, a PM's and a content owner's
94
+ * agent can each drive the same repository, so they must be selectable rather than refused.
95
+ *
96
+ * `FASTAGENT_AGENT` reaches this from the REAL environment only. Placement resolves before anything
97
+ * reads the agent's `.env` — it is what tells the loader which agent's `.env` to read — so a value set
98
+ * there is a silent no-op (the same ordering caveat `FASTAGENT_SECRETS_DIR` carries). A shell, an
99
+ * `.envrc`, or the command itself.
100
+ *
101
+ * It is an ASSERTION, not a preference: it names the agent, and a directory without one
102
+ * by that name resolves to nothing — even when exactly one agent is sitting there. Serving a DIFFERENT
103
+ * agent than the one asked for is the silent-wrong-target this codebase refuses everywhere else, and the
104
+ * uniform rule ("it names the agent") beats one that changes meaning with the sibling count. The stated
105
+ * cost: a `FASTAGENT_AGENT` exported in a shell PROFILE refuses in every unrelated directory it travels
106
+ * into. Scope it per-repo (an `.envrc`) or pass it per-command; the refusal names the way out.
107
+ *
108
+ * With nothing asserted, the {@link DEFAULT_AGENT_DIRNAME} breaks the tie. That is the one place a
109
+ * directory NAME carries weight, and deliberately not an identity rule — the config alone says what IS
110
+ * an agent; the name only decides which already-identified one answers. What it buys: adding a second
111
+ * agent to a working `<workspace>/fastagent/` setup does not break the command everyone already types.
112
+ */
113
+ function selectAgent(agents, env) {
114
+ const wanted = env.FASTAGENT_AGENT;
115
+ if (wanted)
116
+ return agents.find((a) => basename(a) === wanted);
117
+ const [only, ...rest] = agents;
118
+ return rest.length === 0 ? only : agents.find((a) => basename(a) === DEFAULT_AGENT_DIRNAME);
119
+ }
120
+ /**
121
+ * Resolve `dir` into its placement, or undefined when nothing selects one agent. The whole rule: `dir`
122
+ * is the WORKSPACE, and the agent is a `fastagent.config.*` holder at it or one level inside
123
+ * ({@link agentsAt}), narrowed by {@link selectAgent} when there are several.
124
+ */
125
+ function findPlacement(dir, env = process.env) {
126
+ const base = resolve(dir);
127
+ const agentDir = selectAgent(agentsAt(base), env);
128
+ return agentDir === undefined ? undefined : { agentDir, workspace: base };
129
+ }
130
+ /**
131
+ * The one-line hint for "you pointed at the agent, but the project around it is what you meant" — or
132
+ * undefined. The workspace being whatever you aimed at is deliberate (a deployed box may hold nothing
133
+ * but the agent), and the cost is that `cd my-agent && fastagent dev` legitimately narrows the agent's
134
+ * WORKSPACE to its own directory: its cwd, its coding tools' root, and deploy's build context. (②
135
+ * context is not affected — that walk climbs ancestors either way.) Resolution must not guess which you
136
+ * wanted, so this is a HINT — and a hint may use the heuristic ("the parent carries an AGENTS.md or a
137
+ * .git") that a rule may not.
138
+ *
139
+ * It suggests a command only after RUNNING the lookup that command would run, because a hint that dead-ends
140
+ * is worse than none: with several agents beside this one, `..` refuses and names them — sending the
141
+ * reader to a refusal whose own advice points back here.
142
+ */
143
+ export function workspaceHint({ agentDir, workspace }, env = process.env) {
144
+ if (agentDir !== workspace)
145
+ return undefined;
146
+ const parent = dirname(agentDir);
147
+ if (parent === agentDir)
148
+ return undefined;
149
+ // Would `..` actually serve THIS agent? A parent holding a config is an agent itself, and one holding
150
+ // siblings resolves to none without a selector — neither is the project view asked about here.
151
+ if (findPlacement(parent, env)?.agentDir !== agentDir)
152
+ return undefined;
153
+ if (!["AGENTS.md", ".git"].some((name) => existsSync(join(parent, name))))
154
+ return undefined;
155
+ return `${parent} looks like a project — point fastagent at it (\`..\`) to have the agent work ON it`;
156
+ }
157
+ /** The agent dir for `dir`, or undefined when there is none — {@link findPlacement} without the pair,
158
+ * and without the throw. `login` is the caller: it is the one command allowed to run outside an agent,
159
+ * so it needs the answer as a value rather than as a refusal. */
160
+ export function findAgentDir(dir) {
161
+ return findPlacement(dir)?.agentDir;
162
+ }
163
+ /** The agent `dir` sits INSIDE (the nearest proper ancestor that IS an agent dir), or undefined.
164
+ * Module-private: the two questions callers actually ask are "where do I `cd` to?"
165
+ * ({@link placementDeadEnd}) and "would a new agent here become part of an existing one's definition?"
166
+ * ({@link agentDefinitionOwner}) — and those answers differ (an agent's `src/` is inside it, but is not
167
+ * part of what it loads), so exporting the raw containment fact would invite conflating them.
168
+ * Placement resolution deliberately never walks up — the answer must not depend on how deep you stand
169
+ * — but "you are inside an agent, just not at its root" is the likeliest reason resolution fails, and
170
+ * both the refusal below and `login`'s global-fallback decision need to tell that case apart. Uses the
171
+ * same marker as resolution (an ancestor holding a config), so it never claims a position it cannot
172
+ * justify. */
173
+ function enclosingAgentDir(dir) {
174
+ let candidate = dirname(resolve(dir));
175
+ for (let prev = ""; candidate !== prev; prev = candidate, candidate = dirname(candidate)) {
176
+ if (hasConfig(candidate))
177
+ return candidate;
178
+ }
179
+ return undefined;
180
+ }
181
+ /**
182
+ * The agent whose DEFINITION contains `dir` — scaffolding there would make the new agent part of the
183
+ * outer one's loaded surface rather than an agent of its own. Narrower than {@link enclosingAgentDir} on
184
+ * purpose: an agent owns only what it LOADS ({@link LOADED_SURFACE}); the rest of its directory is the
185
+ * author's tree, where a second agent (a monorepo package, say) is a legitimate thing to create.
186
+ * `enclosingAgentDir` answers a different question ("where do I `cd` to?"), and for that an agent's
187
+ * `src/` genuinely IS inside it.
188
+ */
189
+ export function agentDefinitionOwner(dir) {
190
+ const base = resolve(dir);
191
+ const agent = enclosingAgentDir(base);
192
+ if (!agent)
193
+ return undefined;
194
+ const [head] = relative(agent, base).split(sep);
195
+ return head && LOADED_SURFACE.includes(head) ? agent : undefined;
196
+ }
197
+ /**
198
+ * Why `dir` is not an agent, when it has its OWN way out — or undefined when it is simply not near one.
199
+ * Two positions qualify: standing INSIDE an agent (its `tools/`, its `src/`), and standing on a
200
+ * directory whose several agents nothing selects between. Both matter because the generic advice ("run
201
+ * `fastagent init`") would not help — the agent already exists, one step away.
202
+ *
203
+ * Exported because `login` is the one command allowed to run outside an agent, and it must tell "truly
204
+ * outside" (→ the global credential) from "a dead end" (→ refuse, like every other command).
205
+ */
206
+ export function placementDeadEnd(dir, env = process.env) {
207
+ const base = resolve(dir);
208
+ const enclosing = enclosingAgentDir(base);
209
+ if (enclosing) {
210
+ return `${base} is inside the agent ${enclosing} but is not its root — \`cd\` there (or to its workspace) and re-run`;
211
+ }
212
+ const agents = agentsAt(base).map((a) => basename(a));
213
+ const listed = `${base} holds ${agents.length} agent${agents.length === 1 ? "" : "s"} (${agents.join(", ")})`;
214
+ // Asserting a name that is not here is a different mistake from asserting none, and it is worth its own
215
+ // message at ANY count: the value sits in the environment (often a shell profile carried in from
216
+ // somewhere else), so echo it back with what is actually here instead of restating the general rule.
217
+ if (env.FASTAGENT_AGENT && agents.length > 0) {
218
+ return (`${listed}, and FASTAGENT_AGENT asserts "${env.FASTAGENT_AGENT}", which is not one of them — set it ` +
219
+ `to one of those, unset it, or scope it to the repository that needs it (an .envrc)`);
220
+ }
221
+ if (agents.length > 1) {
222
+ return (`${listed} and none of them is named "${DEFAULT_AGENT_DIRNAME}" (the default) — pick one with ` +
223
+ `FASTAGENT_AGENT=<name>, or point fastagent at the one you want (it then works on ITSELF)`);
224
+ }
225
+ return undefined;
226
+ }
227
+ /**
228
+ * Resolve a directory into its placement — the ONE owner of the rule ({@link findPlacement}): `dir` is
229
+ * the workspace, and the agent is the single `fastagent.config.*` holder at it or one level inside.
230
+ * Placement is never configured and never detected from surroundings; it is that lookup and the
231
+ * directory you pointed at.
232
+ *
233
+ * Anything else throws (fail visibly). Resolution never walks UP — an agent must not be claimed from
234
+ * arbitrarily deep inside it — but the MESSAGE reads the path, so each dead end gets the exit that fits
235
+ * it ({@link placementDeadEnd}).
236
+ */
237
+ export function resolvePlacement(dir, env = process.env) {
238
+ const placement = findPlacement(dir, env);
239
+ if (!placement) {
240
+ const base = resolve(dir);
241
+ throw new Error(placementDeadEnd(base, env) ??
242
+ `${base} is not a fastagent agent — no fastagent.config.* here, and no directory holding one ` +
243
+ `directly inside; run \`fastagent init\` to scaffold one`);
244
+ }
245
+ return placement;
246
+ }
247
+ /** How to WRITE a path for someone standing in `cwd`: relative when it is inside `cwd`, absolute when
248
+ * it climbs out (a `../../..` is noise), and undefined when it IS `cwd` (nothing to say). ONE policy,
249
+ * shared by `init`'s `cd` step, `add`'s next-steps paths and `fire`'s "looked in" hint — they all answer
250
+ * the same question, which is a placement-PRESENTATION question, not a scaffolding one. */
251
+ export function displayPath(cwd, dir) {
252
+ const rel = relative(cwd, dir);
253
+ if (rel === "")
254
+ return undefined;
255
+ // "Climbs out" is a path-SEGMENT check — rel is ".." or starts with "../" (or "..\" on Windows). A
256
+ // bare startsWith("..") would wrongly flag an in-cwd directory literally named "..agent".
257
+ const escapes = rel === ".." || /^\.\.[/\\]/.test(rel);
258
+ return escapes ? dir : rel;
259
+ }
260
+ /** Does a path exist? Plain fs, no placement in it — it lives here because `paths.ts` is where the
261
+ * neutral path helpers are, and the scaffolder is not a utility home for the CLI and deploy. */
262
+ export async function exists(p) {
263
+ return access(p).then(() => true, () => false);
264
+ }
265
+ /**
266
+ * Resolve a user-supplied path override (a CLI flag or an env var) to an absolute path, expanding a
267
+ * leading `~`/`~/` to the home dir FIRST. Path-valued config from `.env` (or any non-shell source)
268
+ * never gets the shell's `~` expansion, so a bare `resolve("~/x")` would silently create a literal `~`
269
+ * directory — a fail-silently footgun for a secret/state path. Expanding here makes `~` mean home
270
+ * everywhere these knobs are read.
271
+ */
272
+ export function resolveOverridePath(raw) {
273
+ if (!raw)
274
+ return undefined;
275
+ const expanded = raw === "~" ? homedir() : raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw;
276
+ return resolve(expanded);
277
+ }
278
+ /**
279
+ * The resolved state root — the durable machine-state home (sessions/, channels/<kind>/, schedule/,
280
+ * control.json): `FASTAGENT_STATE_DIR` env > `<agentDir>/.state`. Absolute, so channels and the
281
+ * startup report agree regardless of cwd. Definition: mutable runtime state — single lifecycle
282
+ * (precious, survives redeploy), single process; a container points this at its mounted volume.
283
+ * Secrets are NOT here — they live under {@link resolveSecretsDir} (a different deploy lifecycle:
284
+ * secret store vs volume). The finer knob (`FASTAGENT_SESSIONS_DIR`) still overrides its path on top.
285
+ *
286
+ * `FASTAGENT_STATE_DIR` is an OPERATOR override, so a relative value resolves against `process.cwd()`
287
+ * — the CLI convention its sibling knobs share (`resolveOverridePath`), NOT against `dir`. Only the
288
+ * DEFAULT (`<root>/.state`) is dir-anchored.
289
+ */
290
+ export function resolveStateRoot(dir, env = process.env) {
291
+ return resolveOverridePath(env.FASTAGENT_STATE_DIR) ?? join(resolve(dir), STATE_DIRNAME);
292
+ }
293
+ /**
294
+ * The resolved secrets dir — everything fastagent manages that must NEVER leave the machine (the
295
+ * agent's `.env` + auth.json): `FASTAGENT_SECRETS_DIR` env > `<agentDir>/.secrets`. Split from
296
+ * the state root on deploy lifecycle: secrets travel through the host's secret store (env vars / the
297
+ * auth seed), state through a volume. A deployed box sets both env knobs at its volume (e.g.
298
+ * `/data/.secrets`, `/data/.state`) so a seeded-then-ROTATED OAuth credential persists across
299
+ * restarts. The `.env`'s OWN location resolves from the REAL environment — commands locate and load
300
+ * `.env` before anything else, so a `FASTAGENT_SECRETS_DIR` set INSIDE `.env` still relocates
301
+ * auth.json but cannot move the file it is read from (env.ts dotEnvPath).
302
+ */
303
+ export function resolveSecretsDir(dir, env = process.env) {
304
+ return resolveOverridePath(env.FASTAGENT_SECRETS_DIR) ?? join(resolve(dir), SECRETS_DIRNAME);
305
+ }
306
+ /**
307
+ * Guard that `<agentDir>/<name>` resolves INSIDE the agent dir — a symlink that escapes (or an
308
+ * absolute target) is rejected, so discovery/scaffolding never reaches out of the definition directory.
309
+ * A missing target is fine (nothing to guard yet).
310
+ */
311
+ export async function assertInsideAgentDir(agentDir, name) {
312
+ const target = join(agentDir, name);
313
+ const real = await realpath(target).catch((e) => {
314
+ if (e.code === "ENOENT" || e.code === "not_found")
315
+ return undefined;
316
+ throw e;
317
+ });
318
+ if (real === undefined)
319
+ return;
320
+ const root = await realpath(agentDir).catch(() => resolve(agentDir));
321
+ const rel = relative(root, real);
322
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
323
+ throw new Error(`${target} resolves outside the agent dir (${real}) — it must live inside the definition directory; ` +
324
+ `use a real directory or a symlink that stays within it`);
325
+ }
326
+ }
package/dist/pi.d.ts CHANGED
@@ -4,7 +4,7 @@ export type { ReadonlySessionManager, ToolActivation } from "./engines/pi/tool-c
4
4
  export { z } from "zod";
5
5
  export type { AgentTool, ExecutionEnv, Session, SessionTreeEntry, Skill, SkillDiagnostic, } from "@earendil-works/pi-agent-core";
6
6
  export { loadChannels, type ChannelCollision } from "./engines/pi/channel.ts";
7
- export { createPiAgentFromWorkspace, type CreatePiAgentFromWorkspaceOptions, } from "./engines/pi/workspace.ts";
7
+ export { createPiAgentFromDir, type CreatePiAgentFromDirOptions, } from "./engines/pi/open.ts";
8
8
  export type { LoadedDefinition, SkillCollision } from "./engines/pi/definition.ts";
9
9
  export { defineConfig, listModels, resolveModel, type FastagentConfig } from "./engines/pi/config.ts";
10
10
  export { inProcessLease, type Lease, type Release, type SessionObserver } from "./engines/pi/invoke.ts";
package/dist/pi.js CHANGED
@@ -1,9 +1,9 @@
1
- // The pi reference implementation: assembly, workspace discovery, tools, config, models, auth, and state ports.
1
+ // The pi reference implementation: assembly, agent discovery, tools, config, models, auth, and state ports.
2
2
  export { createPiAgent, createPiAgentFromDefinition, } from "./engines/pi/create.js";
3
3
  export { defineTool, loadTools, } from "./engines/pi/tool.js";
4
4
  export { z } from "zod";
5
5
  export { loadChannels } from "./engines/pi/channel.js";
6
- export { createPiAgentFromWorkspace, } from "./engines/pi/workspace.js";
6
+ export { createPiAgentFromDir, } from "./engines/pi/open.js";
7
7
  export { defineConfig, listModels, resolveModel } from "./engines/pi/config.js";
8
8
  export { inProcessLease } from "./engines/pi/invoke.js";
9
9
  export { createPiSessionControl, } from "./engines/pi/session-control.js";
package/dist/runtime.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- export interface WorkspaceRuntime {
2
- /** The JS runtime the workspace targets drives the generated Dockerfile base + install/run commands
1
+ /** How to install and run the AGENT: its package.json + lockfile decide, never the surrounding
2
+ * workspace's (whose toolchain is the agent's runtime concern, not fastagent's). */
3
+ export interface AgentRuntime {
4
+ /** The JS runtime the agent targets — drives the generated Dockerfile base + install/run commands
3
5
  * and the package-manager hints in `init`/`add`. */
4
6
  runtime: "node" | "bun";
5
7
  /** For `runtime: "bun"`, the version from package.json's `packageManager: "bun@x"` (undefined if a bun
@@ -9,14 +11,14 @@ export interface WorkspaceRuntime {
9
11
  hasLockfile: boolean;
10
12
  }
11
13
  /**
12
- * Detect which JS runtime a workspace targets: `bun` when package.json's `packageManager` is `bun@…` OR a
14
+ * Detect which JS runtime an agent targets: `bun` when package.json's `packageManager` is `bun@…` OR a
13
15
  * bun lockfile (bun.lock/bun.lockb) is present, else `node`. `pkg` is the already-parsed package.json (or
14
16
  * `{}` when there is none / it is malformed). One source for the deploy Dockerfile and the CLI hints, so
15
- * they agree on what the workspace is.
17
+ * they agree on what the agent is.
16
18
  */
17
19
  export declare function detectRuntime(dir: string, pkg: {
18
20
  packageManager?: unknown;
19
- }): WorkspaceRuntime;
21
+ }): AgentRuntime;
20
22
  /** Parse `<dir>/package.json`, or `{}` when absent/malformed (a real build surfaces the actual error).
21
23
  * The already-parsed input to {@link detectRuntime} and the deploy dep/lockfile checks. */
22
24
  export declare function readPackageJson(dir: string): Promise<{
package/dist/runtime.js CHANGED
@@ -2,10 +2,10 @@ import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  /**
5
- * Detect which JS runtime a workspace targets: `bun` when package.json's `packageManager` is `bun@…` OR a
5
+ * Detect which JS runtime an agent targets: `bun` when package.json's `packageManager` is `bun@…` OR a
6
6
  * bun lockfile (bun.lock/bun.lockb) is present, else `node`. `pkg` is the already-parsed package.json (or
7
7
  * `{}` when there is none / it is malformed). One source for the deploy Dockerfile and the CLI hints, so
8
- * they agree on what the workspace is.
8
+ * they agree on what the agent is.
9
9
  */
10
10
  export function detectRuntime(dir, pkg) {
11
11
  const pm = typeof pkg.packageManager === "string" ? pkg.packageManager : "";
@@ -35,10 +35,14 @@ export interface DotEnvWriteResult {
35
35
  written: string[];
36
36
  /** Vars already present with a non-empty active value; left untouched and omitted from next steps. */
37
37
  alreadySet: string[];
38
+ /** Set when the secrets dir is an operator-chosen one (`FASTAGENT_SECRETS_DIR`) carrying no
39
+ * `.gitignore`: a secret was just written into a directory fastagent does not own, so the caller
40
+ * states the fact rather than dropping a `*`-ignoring file into someone else's path. */
41
+ unprotectedSecretsDir?: string;
38
42
  }
39
43
  /**
40
- * Append generated channel secrets to the run-root `.env` (never `.env.example`) after the CLI has
41
- * verified that `.env` is gitignored. Existing non-empty values are kept — EXCEPT the names listed in
44
+ * Append generated channel secrets to the agent's `.env` (`.secrets/.env` — never `.env.example`)
45
+ * Existing non-empty values are kept — EXCEPT the names listed in
42
46
  * `overwrite`: those are authoritative (e.g. the credentials of an app `add feishu` JUST minted —
43
47
  * skipping them for a stale value would silently discard a fresh, unrecoverable secret). Manual values
44
48
  * (e.g. TELEGRAM_BOT_TOKEN from BotFather) are added only as commented placeholders, so the file is
@@ -56,7 +60,7 @@ export declare function scaffoldChannel(dir: string, kind: ChannelKind, options?
56
60
  groupBehavior?: GroupBehavior;
57
61
  }): Promise<string>;
58
62
  /**
59
- * Verify the workspace is ready to host a channel: an ESM package.json that declares
63
+ * Verify the AGENT DIR is ready to host a channel: an ESM package.json that declares
60
64
  * `@fastagent-sh/fastagent` (the channel file imports it). `add` checks and guides, never bootstraps — that
61
65
  * is `init`'s job.
62
66
  */
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * `fastagent add <channel>`: drop a `channels/<kind>.ts` adapter-glue file (+ any companion tool, +
3
- * `.env.example` vars) into an existing workspace. `add` checks and guides; it never bootstraps a
4
- * workspace (that is `init`'s job). Each channel's template files live in its own bundle at
5
- * src/channels/<kind>/scaffold/, read here at scaffold time.
3
+ * `.secrets/.env.example` vars) into an existing agent. `add` checks and guides; it never
4
+ * bootstraps an agent (that is `init`'s job). Each channel's template files live in its own bundle
5
+ * at src/channels/<kind>/scaffold/, read here at scaffold time.
6
6
  */
7
7
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
8
- import { join } from "node:path";
8
+ import { dirname, join } from "node:path";
9
9
  import { detectRuntime } from "../runtime.js";
10
- import { assertInsideWorkspace } from "../workspace.js";
11
- import { channelBundleFiles, channelTemplate } from "./templates.js";
12
- import { exists } from "./init.js";
13
- import { parseEnvContent } from "../env.js";
10
+ import { SECRETS_DIRNAME, assertInsideAgentDir, exists } from "../paths.js";
11
+ import { baseTemplate, channelBundleFiles, channelTemplate } from "./templates.js";
12
+ import { dotEnvPath, envExamplePath, parseEnvContent } from "../env.js";
14
13
  const CHANNEL_SCAFFOLDS = {
15
14
  github: {
16
15
  env: [
@@ -21,8 +20,8 @@ const CHANNEL_SCAFFOLDS = {
21
20
  generate: true,
22
21
  },
23
22
  ],
24
- // `{channel}` / `{tools}` are path placeholders the CLI resolves to the real workspace-relative
25
- // location (agentDir-aware) — the CLI holds no channel-private filenames.
23
+ // `{channel}` / `{tools}` are path placeholders the CLI resolves to the real agent-dir-relative
24
+ // location — the CLI holds no channel-private filenames.
26
25
  steps: [
27
26
  "edit {channel} — map events to intents in on()",
28
27
  "add the webhook in your repo (Settings → Webhooks): Payload URL = <public-url>/webhook, content type application/json",
@@ -100,7 +99,7 @@ const CHANNEL_SCAFFOLDS = {
100
99
  },
101
100
  ],
102
101
  steps: [
103
- "before publishing: approve the sensitive im:message.group_msg permission for context-aware groups (the CLI adds it to the app draft when supported); it delivers all group messages so bare managed-thread replies can invoke and other unsummoned discussion can buffer",
102
+ "before publishing: approve the sensitive im:message.group_msg permission for context-aware groups (the CLI adds it to the app draft when supported); it delivers all group messages so bare replies in the agent's own threads can invoke and other unsummoned discussion can buffer. im:message:readonly is requested alongside it so a thread's opening ask can carry the message it quotes; both need the same approval",
104
103
  "PUBLISH the app version in the developer console after permission approval — the switch to webhook mode takes effect on publish (one click, once ever; no API for it)",
105
104
  "edit {channel} — routing policy (the header walks through the console setup, for hand-made apps)",
106
105
  "the event Request URL is auto-registered by `dev --tunnel` / `deploy --run`",
@@ -124,7 +123,7 @@ const CHANNEL_SCAFFOLDS = {
124
123
  ],
125
124
  steps: [
126
125
  "finish the console setup: enable Bot and add the required permissions + im.message.receive_v1 event listed in {channel} (do not publish yet)",
127
- "before publishing: approve the sensitive im:message.group_msg permission for context-aware groups (add it manually if Lark's config API fallback was used); it delivers all group messages so bare managed-thread replies can invoke and other unsummoned discussion can buffer",
126
+ "before publishing: approve the sensitive im:message.group_msg permission for context-aware groups (add it manually if Lark's config API fallback was used); it delivers all group messages so bare replies in the agent's own threads can invoke and other unsummoned discussion can buffer. im:message:readonly is requested alongside it so a thread's opening ask can carry the message it quotes; both need the same approval",
128
127
  "run `fastagent dev --tunnel` and keep it running; if auto-registration reports a config-API 404, manually switch Subscription mode to webhook, set its printed https://…/lark Request URL, save, then create + publish a version",
129
128
  "the agent can push messages from scheduled turns via the scaffolded {tools}/lark-send.ts tool",
130
129
  ],
@@ -136,7 +135,7 @@ const WEBSOCKET_SETUPS = {
136
135
  feishu: {
137
136
  env: CHANNEL_SCAFFOLDS.feishu.env.filter((entry) => ["FEISHU_APP_ID", "FEISHU_APP_SECRET"].includes(entry.name)),
138
137
  steps: [
139
- "before publishing: approve the sensitive im:message.group_msg permission for context-aware groups (the CLI adds it to the app draft when supported); it delivers all group messages so bare managed-thread replies can invoke and other unsummoned discussion can buffer",
138
+ "before publishing: approve the sensitive im:message.group_msg permission for context-aware groups (the CLI adds it to the app draft when supported); it delivers all group messages so bare replies in the agent's own threads can invoke and other unsummoned discussion can buffer. im:message:readonly is requested alongside it so a thread's opening ask can carry the message it quotes; both need the same approval",
140
139
  "PUBLISH the app version in the developer console after permission approval — long-connection event subscriptions become active with the published version",
141
140
  "edit {channel} — routing policy (the scaffold is already set to WebSocket ingress)",
142
141
  "run `fastagent dev` without --tunnel; deployments must keep one process running (no scale-to-zero)",
@@ -146,7 +145,7 @@ const WEBSOCKET_SETUPS = {
146
145
  lark: {
147
146
  env: CHANNEL_SCAFFOLDS.lark.env.filter((entry) => ["LARK_APP_ID", "LARK_APP_SECRET"].includes(entry.name)),
148
147
  steps: [
149
- "before publishing: approve the sensitive im:message.group_msg permission for context-aware groups (add it manually if Lark's config API fallback was used); it delivers all group messages so bare managed-thread replies can invoke and other unsummoned discussion can buffer",
148
+ "before publishing: approve the sensitive im:message.group_msg permission for context-aware groups (add it manually if Lark's config API fallback was used); it delivers all group messages so bare replies in the agent's own threads can invoke and other unsummoned discussion can buffer. im:message:readonly is requested alongside it so a thread's opening ask can carry the message it quotes; both need the same approval",
150
149
  "in Events & Callbacks choose long connection, subscribe im.message.receive_v1, then create + publish a version",
151
150
  "edit {channel} — routing policy (the scaffold is already set to WebSocket ingress)",
152
151
  "run `fastagent dev` without --tunnel; deployments must keep one process running (no scale-to-zero)",
@@ -164,7 +163,7 @@ export function channelSetup(kind, ingress = "webhook", groupBehavior) {
164
163
  return {
165
164
  env: setup.env,
166
165
  steps: setup.steps.map((step) => step.includes("im:message.group_msg")
167
- ? "group behavior: mention-only — do not grant im:message.group_msg; bare managed-thread replies and group context buffering remain disabled"
166
+ ? "group behavior: mention-only — do not grant im:message.group_msg; bare thread replies and group context buffering remain disabled. im:message:readonly is independent of this choice: add it if you want an @mention to carry the message it quotes (without it that quote degrades to a marker)"
168
167
  : step),
169
168
  };
170
169
  }
@@ -174,7 +173,7 @@ export function channelSetup(kind, ingress = "webhook", groupBehavior) {
174
173
  steps: [
175
174
  "Slack Bot Token Scopes: app_mentions:read, assistant:write, chat:write, im:history, files:read, files:write (no channel/group/mpim history scopes)",
176
175
  "enable Agents (agent_view) and token rotation; subscribe app_home_opened, app_context_changed, app_mention, and message.im; set Request URL to <public-url>/slack",
177
- "group behavior: mention-only — bare managed-thread replies and unsummoned group context remain disabled",
176
+ "group behavior: mention-only — bare thread replies and unsummoned group context remain disabled",
178
177
  ...setup.steps.slice(2),
179
178
  ],
180
179
  };
@@ -187,7 +186,7 @@ export function channelSetup(kind, ingress = "webhook", groupBehavior) {
187
186
  * is already present. Placeholders only — no real secret lands in the committable template.
188
187
  */
189
188
  export async function appendChannelEnv(dir, kind, ingress = "webhook") {
190
- const file = join(dir, ".env.example");
189
+ const file = envExamplePath(dir);
191
190
  let current;
192
191
  try {
193
192
  current = await readFile(file, "utf8");
@@ -220,15 +219,42 @@ function mentionsEnvName(content, name) {
220
219
  return content.split("\n").some((line) => new RegExp(`^\\s*#?\\s*${name}\\s*=`).test(line));
221
220
  }
222
221
  /**
223
- * Append generated channel secrets to the run-root `.env` (never `.env.example`) after the CLI has
224
- * verified that `.env` is gitignored. Existing non-empty values are kept — EXCEPT the names listed in
222
+ * Append generated channel secrets to the agent's `.env` (`.secrets/.env` — never `.env.example`)
223
+ * Existing non-empty values are kept — EXCEPT the names listed in
225
224
  * `overwrite`: those are authoritative (e.g. the credentials of an app `add feishu` JUST minted —
226
225
  * skipping them for a stale value would silently discard a fresh, unrecoverable secret). Manual values
227
226
  * (e.g. TELEGRAM_BOT_TOKEN from BotFather) are added only as commented placeholders, so the file is
228
227
  * ready to edit while no fake secret is committed to the user's mental model.
229
228
  */
230
229
  export async function appendChannelDotEnv(dir, kind, generated, overwrite = [], ingress = "webhook") {
231
- const file = join(dir, ".env");
230
+ const file = dotEnvPath(dir);
231
+ const secretsDir = dirname(file);
232
+ await mkdir(secretsDir, { recursive: true });
233
+ // THE one exception to "fastagent has no opinion about git": the directory it writes secrets into
234
+ // carries its own `.gitignore`. `init` writes it, and so does this — the reachable case where it is
235
+ // missing (a hand-made agent) is exactly the one where the next line mints an unrecoverable app
236
+ // secret. `wx`, so a file the author wrote is never touched; the accepted cost is that someone who
237
+ // DELETED it to track secrets deliberately gets it back once. The risk is not symmetric — that is an
238
+ // annoyance; the other way is a published credential.
239
+ //
240
+ // Scoped to the DEFAULT `<agentDir>/.secrets`, which is fastagent's own directory. A dir named by
241
+ // `FASTAGENT_SECRETS_DIR` belongs to the operator, and this template is `*` plus two negations —
242
+ // dropping it there would hide that directory's OTHER contents from their `git add`, which is a
243
+ // bigger harm than the one it prevents, and inflicted on a path they chose deliberately. They get the
244
+ // fact instead, and own the decision.
245
+ const owned = secretsDir === join(dir, SECRETS_DIRNAME);
246
+ let unprotectedSecretsDir;
247
+ if (owned) {
248
+ // Only EEXIST is tolerable (already protected, or a concurrent writer). A permission/disk failure on
249
+ // the file that keeps credentials out of git must surface, not be swallowed.
250
+ await writeFile(join(secretsDir, ".gitignore"), baseTemplate("secrets.gitignore"), { flag: "wx" }).catch((e) => {
251
+ if (e.code !== "EEXIST")
252
+ throw e;
253
+ });
254
+ }
255
+ else if (!(await exists(join(secretsDir, ".gitignore")))) {
256
+ unprotectedSecretsDir = secretsDir;
257
+ }
232
258
  let current = "";
233
259
  try {
234
260
  current = await readFile(file, "utf8");
@@ -291,7 +317,7 @@ export async function appendChannelDotEnv(dir, kind, generated, overwrite = [],
291
317
  await appendFile(file, `${prefix}${marker}\n${lines.join("\n")}\n`);
292
318
  }
293
319
  }
294
- return { written, alreadySet };
320
+ return { written, alreadySet, unprotectedSecretsDir };
295
321
  }
296
322
  /** The path `add <kind>` scaffolds to. */
297
323
  function channelPath(dir, kind) {
@@ -307,8 +333,8 @@ export async function channelExists(dir, kind) {
307
333
  */
308
334
  export async function scaffoldChannel(dir, kind, options = {}) {
309
335
  const channelsDir = join(dir, "channels");
310
- // Don't write through a channels/ symlink that escapes the workspace; an in-workspace one is fine.
311
- await assertInsideWorkspace(dir, "channels");
336
+ // Don't write through a channels/ symlink that escapes the agent dir; one inside it is fine.
337
+ await assertInsideAgentDir(dir, "channels");
312
338
  const file = channelPath(dir, kind);
313
339
  if (await exists(file)) {
314
340
  throw new Error(`${file} already exists — edit it, or remove it to re-scaffold`);
@@ -365,7 +391,7 @@ export async function scaffoldChannel(dir, kind, options = {}) {
365
391
  return file;
366
392
  }
367
393
  /**
368
- * Verify the workspace is ready to host a channel: an ESM package.json that declares
394
+ * Verify the AGENT DIR is ready to host a channel: an ESM package.json that declares
369
395
  * `@fastagent-sh/fastagent` (the channel file imports it). `add` checks and guides, never bootstraps — that
370
396
  * is `init`'s job.
371
397
  */
@@ -377,11 +403,11 @@ export async function assertChannelReady(dir) {
377
403
  }
378
404
  catch (e) {
379
405
  if (e.code === "ENOENT") {
380
- // `dir` is where the kit lives (agentDir when set) "run init" is only the right advice when no
381
- // workspace exists yet; a kit missing its manifest (e.g. a --minimal init) needs the manifest, not init.
382
- throw new Error(`${dir}: no package.json — a channel adapter is code and needs the kit's own manifest. ` +
383
- `Run \`fastagent init\` for a fresh workspace, or add a package.json with @fastagent-sh/fastagent there ` +
384
- `(a --minimal init has none)`);
406
+ // `dir` is the AGENT dir, so `fastagent init` here would nest a second agent inside it the
407
+ // right remedy is the missing manifest (a --minimal init writes none), or init in the workspace.
408
+ throw new Error(`${dir}: no package.json — a channel adapter is code and needs the agent's own manifest. ` +
409
+ `Add a package.json declaring @fastagent-sh/fastagent there (a --minimal init writes none), ` +
410
+ `or run \`fastagent init\` in the workspace for a fresh agent`);
385
411
  }
386
412
  throw e;
387
413
  }