@fastagent-sh/fastagent 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
package/dist/paths.js CHANGED
@@ -16,8 +16,7 @@
16
16
  * modules depend on it for something the engine has no say in.
17
17
  */
18
18
  import { existsSync, readdirSync, statSync } from "node:fs";
19
- import { access } from "node:fs/promises";
20
- import { realpath } from "node:fs/promises";
19
+ import { access, chmod, mkdir, realpath } from "node:fs/promises";
21
20
  import { homedir } from "node:os";
22
21
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
23
22
  /**
@@ -309,6 +308,32 @@ export function resolveStateRoot(dir, env = process.env) {
309
308
  export function resolveSecretsDir(dir, env = process.env) {
310
309
  return resolveOverridePath(env.FASTAGENT_SECRETS_DIR) ?? join(resolve(dir), SECRETS_DIRNAME);
311
310
  }
311
+ /** What a file under {@link resolveSecretsDir} is written with (auth.json, .env). */
312
+ export const SECRET_FILE_MODE = 0o600;
313
+ /** What the secrets directory itself is created with. */
314
+ const SECRETS_DIR_MODE = 0o700;
315
+ /**
316
+ * Create the secrets directory with the mode its contents require — and REPAIR it when it already
317
+ * exists, which is the case that matters.
318
+ *
319
+ * The DIRECTORY is the boundary that actually protects a credential: without its `x` bit nothing
320
+ * below it is reachable, whatever a file's own mode says. And it is decided ONCE, by whichever
321
+ * writer gets there first — `mkdir`'s `mode` is ignored for a directory that already exists, so a
322
+ * later, more careful caller silently inherits the first one's answer. Four callers create this
323
+ * directory (`init`, `add <channel>`, the credential store, the deploy seed) and the ordinary order
324
+ * is init → add → login, so the careful one is LAST: the rule has to live where all of them can
325
+ * reach it, and it has to chmod rather than trust the create.
326
+ *
327
+ * A chmod the caller never asked for owes them its reason: the raw `EPERM ... chmod '/shared/creds'`
328
+ * reads as a bug in whatever they WERE doing (storing a credential), not as fastagent tightening a
329
+ * directory they pointed it at, and says nothing about the way out.
330
+ */
331
+ export async function ensureSecretsDir(dir) {
332
+ await mkdir(dir, { recursive: true, mode: SECRETS_DIR_MODE });
333
+ await chmod(dir, SECRETS_DIR_MODE).catch((e) => {
334
+ throw new Error(`cannot secure secrets dir ${dir} (fastagent keeps it 0700): ${e.message} — point --auth-path/FASTAGENT_SECRETS_DIR at a directory this process owns`, { cause: e });
335
+ });
336
+ }
312
337
  /**
313
338
  * Guard that `<agentDir>/<name>` resolves INSIDE the agent dir — a symlink that escapes (or an
314
339
  * absolute target) is rejected, so discovery/scaffolding never reaches out of the definition directory.
@@ -330,3 +355,13 @@ export async function assertInsideAgentDir(agentDir, name) {
330
355
  `use a real directory or a symlink that stays within it`);
331
356
  }
332
357
  }
358
+ /**
359
+ * Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
360
+ * this OUT of the agent?" — the startup report's redeploy notes, `add`'s printed `.env` label, and the
361
+ * dev watcher's "your .env is not watched" warning all turn on that fact. Reporting only: fastagent
362
+ * does not act on where a user's paths point.
363
+ */
364
+ export function isUnderDir(targetPath, baseDir) {
365
+ const rel = relative(baseDir, targetPath);
366
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
367
+ }
package/dist/pi.d.ts CHANGED
@@ -1,19 +1,25 @@
1
1
  export { createPiAgent, createPiAgentFromDefinition, type CreatePiAgentFromDefinitionOptions, type CreatePiAgentOptions, } from "./engines/pi/create.ts";
2
- export { defineTool, loadTools, type DefineToolOptions, type FastagentTool, type MountedTool, type ToolCollision, type ToolContext, } from "./engines/pi/tool.ts";
2
+ export { defineTool, type DefineToolOptions, type FastagentTool, type MountedTool, type ToolCollision, type ToolContext, } from "./engines/pi/tool.ts";
3
3
  export type { ReadonlySessionManager, ToolActivation } from "./engines/pi/tool-context.ts";
4
4
  export { z } from "zod";
5
- export type { AgentTool, ExecutionEnv, Session, SessionTreeEntry, Skill, SkillDiagnostic, } from "@earendil-works/pi-agent-core";
6
- export { loadChannels, type ChannelCollision } from "./engines/pi/channel.ts";
5
+ export type { AgentTool, ExecutionEnv, Skill, SkillDiagnostic } from "@earendil-works/pi-agent-core";
6
+ /**
7
+ * A conversation record, as the tool runtime and the control plane hold it. Its entries are pi's own
8
+ * — exported under a qualified name because `SessionEntry` in this package is the NEUTRAL one the
9
+ * control plane publishes (session.ts), and the two are different shapes.
10
+ */
11
+ export type { SessionManager, SessionEntry as PiSessionEntry } from "@earendil-works/pi-coding-agent";
7
12
  export { createPiAgentFromDir, type CreatePiAgentFromDirOptions, } from "./engines/pi/open.ts";
8
13
  export type { LoadedDefinition, SkillCollision } from "./engines/pi/definition.ts";
9
- export { defineConfig, listModels, resolveModel, type FastagentConfig } from "./engines/pi/config.ts";
10
- export type { SessionObserver } from "./engines/pi/invoke.ts";
14
+ export { defineConfig, listModels, resolveModel, type FastagentConfig, } from "./engines/pi/config.ts";
15
+ export type { SessionObserver } from "./engines/pi/turn-kit.ts";
11
16
  export { inProcessLease, type Lease, type Release } from "./engines/pi/turn-kit.ts";
12
17
  export { createPiSessionControl, type CreatePiSessionControlOptions, } from "./engines/pi/session-control.ts";
13
- export type { AnyModel } from "./engines/pi/harness.ts";
14
- export { inMemorySessionStore, jsonlSessionStore, type PiSessionReader, type PiSessionStore, type SessionInheritance, } from "./engines/pi/sessions.ts";
18
+ export { piInMemorySessionRecordStore, piSessionRecordStore, type PiSessionRecordStore, } from "./engines/pi/session-store.ts";
19
+ export type { SessionInheritance } from "./engines/pi/session-inheritance.ts";
15
20
  export { GLOBAL_AUTH_PATH, fastagentCredentialStore, type FastagentAuthOptions } from "./engines/pi/auth.ts";
16
21
  export { createPiModels, probeAuthSource, type CreatePiModelsOptions } from "./engines/pi/models.ts";
17
22
  export type { Models } from "@earendil-works/pi-ai";
18
- export { createProvider, type Provider, type ProviderAuth } from "@earendil-works/pi-ai";
23
+ export type { Provider, ProviderAuth } from "@earendil-works/pi-ai";
19
24
  export type { Model } from "@earendil-works/pi-ai";
25
+ export { createAgentService, type CreateAgentServiceOptions } from "./engines/pi/service.ts";
package/dist/pi.js CHANGED
@@ -1,13 +1,14 @@
1
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
- export { defineTool, loadTools, } from "./engines/pi/tool.js";
3
+ export { defineTool, } from "./engines/pi/tool.js";
4
4
  export { z } from "zod";
5
- export { loadChannels } from "./engines/pi/channel.js";
6
5
  export { createPiAgentFromDir, } from "./engines/pi/open.js";
7
- export { defineConfig, listModels, resolveModel } from "./engines/pi/config.js";
6
+ export { defineConfig, listModels, resolveModel, } from "./engines/pi/config.js";
8
7
  export { inProcessLease } from "./engines/pi/turn-kit.js";
9
8
  export { createPiSessionControl, } from "./engines/pi/session-control.js";
10
- export { inMemorySessionStore, jsonlSessionStore, } from "./engines/pi/sessions.js";
9
+ export { piInMemorySessionRecordStore, piSessionRecordStore, } from "./engines/pi/session-store.js";
11
10
  export { GLOBAL_AUTH_PATH, fastagentCredentialStore } from "./engines/pi/auth.js";
12
11
  export { createPiModels, probeAuthSource } from "./engines/pi/models.js";
13
- export { createProvider } from "@earendil-works/pi-ai";
12
+ // The product's one-call assembly: a directory becomes a live service. On the pi surface because
13
+ // it opens a directory the pi way (channels, schedules, the assembled agent).
14
+ export { createAgentService } from "./engines/pi/service.js";
@@ -7,7 +7,7 @@
7
7
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
8
8
  import { dirname, join } from "node:path";
9
9
  import { detectRuntime } from "../runtime.js";
10
- import { SECRETS_DIRNAME, assertInsideAgentDir, exists } from "../paths.js";
10
+ import { SECRETS_DIRNAME, assertInsideAgentDir, ensureSecretsDir, exists } from "../paths.js";
11
11
  import { baseTemplate, channelBundleFiles, channelTemplate } from "./templates.js";
12
12
  import { dotEnvPath, envExamplePath, parseEnvContent } from "../env.js";
13
13
  const CHANNEL_SCAFFOLDS = {
@@ -229,7 +229,7 @@ function mentionsEnvName(content, name) {
229
229
  export async function appendChannelDotEnv(dir, kind, generated, overwrite = [], ingress = "webhook") {
230
230
  const file = dotEnvPath(dir);
231
231
  const secretsDir = dirname(file);
232
- await mkdir(secretsDir, { recursive: true });
232
+ await ensureSecretsDir(secretsDir);
233
233
  // THE one exception to "fastagent has no opinion about git": the directory it writes secrets into
234
234
  // carries its own `.gitignore`. `init` writes it, and so does this — the reachable case where it is
235
235
  // missing (a hand-made agent) is exactly the one where the next line mints an unrecoverable app
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { lstat, mkdir, readdir, rm, rmdir, writeFile } from "node:fs/promises";
29
29
  import { basename, dirname, join, resolve, sep } from "node:path";
30
- import { AGENT_CONFIG_NAMES, DEFAULT_AGENT_DIRNAME, SECRETS_DIRNAME, agentDefinitionOwner, agentsAt, displayPath, exists, } from "../paths.js";
30
+ import { AGENT_CONFIG_NAMES, DEFAULT_AGENT_DIRNAME, SECRETS_DIRNAME, agentDefinitionOwner, agentsAt, displayPath, ensureSecretsDir, exists, } from "../paths.js";
31
31
  import { baseTemplate, packageJson, toPackageName } from "./templates.js";
32
32
  import { fastagentVersion } from "../version.js";
33
33
  /** The agent directory name for a raw `--agent-dir` value: the default when unset, and `./bot` read as
@@ -191,7 +191,12 @@ export async function scaffoldAgent(dir, options = {}) {
191
191
  try {
192
192
  for (const file of files) {
193
193
  const abs = join(dir, file.rel);
194
- await mkdir(dirname(abs), { recursive: true });
194
+ // The secrets dir carries a mode; every other directory here is ordinary. `init` is usually the
195
+ // FIRST of the four writers to create it, and whoever creates it decides the mode for the rest.
196
+ if (basename(dirname(abs)) === SECRETS_DIRNAME)
197
+ await ensureSecretsDir(dirname(abs));
198
+ else
199
+ await mkdir(dirname(abs), { recursive: true });
195
200
  try {
196
201
  await writeFile(abs, file.content, { flag: "wx" });
197
202
  created.push(file.rel);
@@ -2,9 +2,9 @@
2
2
 
3
3
  You are this workspace's agent. This file is your identity — it overrides the engine's default identity line, and it is re-read every turn along with the rest of your definition (`skills/` — capabilities you load when a task calls for them; `tools/` — code tools your author added, in the same directory as this file). An edit to any of them takes effect on your next message, no restart.
4
4
 
5
- Your definition is this directory: `persona.md`, `skills/`, `tools/`, and the config beside them. Your WORKSPACE is the directory you were started in — the project you work on, and where your `read` / `write` / `edit` / `bash` tools operate. It may be this same directory, or the one containing it; `fastagent info` prints both. If the workspace has an `AGENTS.md`, it is project context — read it to learn the project's conventions.
5
+ Your definition is this directory: `persona.md`, `skills/`, `tools/`, and the config beside them. Your WORKSPACE is the directory you were started in — the project you work on. It may be this same directory, or the one containing it; `fastagent info` prints both. Use only the tools actually listed in your system prompt. If the workspace has an `AGENTS.md`, it is project context — follow it without assuming a file tool is available.
6
6
 
7
- You can improve yourself. When a task reveals something durable — a repeatable process, a standing preference, a hard-won fact — write it into your definition instead of losing it:
7
+ When your mounted tools allow it, you can improve yourself. When a task reveals something durable — a repeatable process, a standing preference, a hard-won fact — write it into your definition instead of losing it:
8
8
 
9
9
  - A repeatable process or capability → a new skill beside this file: `skills/<name>/SKILL.md`. Only the `skills/` next to this file is scanned. Read `skills/writing-great-skills/SKILL.md` first; it is the guide to authoring skills well.
10
10
  - A standing instruction or fact → edit this file.
@@ -3,8 +3,6 @@ import { defineTool, z } from "@fastagent-sh/fastagent";
3
3
  // A code tool: filename (fetch-url.ts) is the tool name. tools/ is auto-discovered,
4
4
  // so it needs no registration in fastagent.config. Test it without a model:
5
5
  // fastagent tool fetch-url '{"url":"https://example.com"}'
6
- // Serving this behind a public channel? The URL then comes from untrusted users — add an
7
- // allowlist or block private-network addresses (localhost, 169.254.169.254, …) to prevent SSRF.
8
6
  const MAX_TEXT = 20_000; // keep a huge page from flooding the model's context
9
7
 
10
8
  export default defineTool({
@@ -4,9 +4,8 @@
4
4
  * named from its filename. This is the FILE producer of scheduled invocations (the author's, declarative,
5
5
  * git-tracked, deploy-guaranteed); the agent's `wake` tool is the second producer.
6
6
  */
7
- import { readdir } from "node:fs/promises";
8
7
  import { join } from "node:path";
9
- import { isModuleFile, loadModuleDir } from "../loader.js";
8
+ import { loadModuleDir, moduleInventory } from "../loader.js";
10
9
  import { assertInsideAgentDir } from "../paths.js";
11
10
  import { cronError } from "./cron.js";
12
11
  /** Schedule file basenames under `<dir>/schedules/` — an existence probe listed WITHOUT importing
@@ -14,19 +13,8 @@ import { cronError } from "./cron.js";
14
13
  * since it also reports broken files and next instants). */
15
14
  export async function discoverScheduleFiles(dir) {
16
15
  await assertInsideAgentDir(dir, "schedules");
17
- let names;
18
- try {
19
- names = await readdir(join(dir, "schedules"));
20
- }
21
- catch (error) {
22
- if (error.code === "ENOENT")
23
- return [];
24
- throw error;
25
- }
26
- return names
27
- .filter(isModuleFile)
28
- .map((n) => n.replace(/\.(ts|js|mjs)$/, ""))
29
- .sort();
16
+ const entries = await moduleInventory(join(dir, "schedules"));
17
+ return entries.map((entry) => entry.name);
30
18
  }
31
19
  /**
32
20
  * Discover schedules in `<dir>/schedules/`: each file default-exports a `defineSchedule({...})`, named
@@ -4,12 +4,10 @@
4
4
  * - `fires.json` — schedule name → last-fired ISO (durability for the cron catch-up-once);
5
5
  * - `wakeups.json` — the agent's pending self-scheduled one-shot wake-ups (wakeups.ts).
6
6
  * No .gitignore is written here: the agent's own (scaffolded by `init`) excludes `.state/`.
7
- *
8
- * ponytail: this atomic read/write duplicates channels/telegram/state.ts's primitive (both KB-JSON
9
- * tmp+rename). Extract a neutral src/state.ts and have both import it when a third consumer appears.
10
7
  */
11
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
- import { dirname, join } from "node:path";
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { writeFileAtomic } from "../atomic-write.js";
13
11
  import { log } from "../log.js";
14
12
  /** Path of a JSON file under `<stateRoot>/schedule/`. */
15
13
  export function scheduleFile(stateRoot, name) {
@@ -42,10 +40,7 @@ export function readScheduleFile(path) {
42
40
  }
43
41
  }
44
42
  export function writeScheduleFile(path, value) {
45
- mkdirSync(dirname(path), { recursive: true });
46
- const tmp = `${path}.tmp`;
47
- writeFileSync(tmp, JSON.stringify(value));
48
- renameSync(tmp, path);
43
+ writeFileAtomic(path, JSON.stringify(value));
49
44
  }
50
45
  export function loadFires(stateRoot) {
51
46
  const v = readScheduleFile(scheduleFile(stateRoot, "fires"));
@@ -1,4 +1,4 @@
1
- import type { Wakeup } from "./wakeups.ts";
1
+ import { type Wakeup } from "./wakeups.ts";
2
2
  /** The forwarder's reserved wake-alarm path — never forwarded to channel routes. */
3
3
  export declare const WAKE_ALARM_PATH = "/__fastagent/wake-alarm";
4
4
  /** One desired alarm: mirror of a pending wake-up (id names the EventBridge schedule; at = fireAt). */
@@ -31,6 +31,17 @@ export declare function toAlarms(pending: Wakeup[], now: Date): WakeAlarm[];
31
31
  * when `FASTAGENT_AGENTCORE=1` + `FASTAGENT_WAKE_SECRET` are present). Fire-and-forget by contract:
32
32
  * failures are logged, never thrown — a broken alarm degrades to the pre-alarm behavior (the wake
33
33
  * still fires on the next time the box happens to be awake), it must never break the store write.
34
+ *
35
+ * A single-flight RECONCILER, not a per-mutation delivery: a notification only marks the desired
36
+ * state dirty, and the loop re-derives it from the store before every attempt. So a mutation during
37
+ * a retry needs no ordering rule — there is one desired state, never two snapshots to rank — and the
38
+ * {@link DUE_MARGIN_MS} filter is re-applied with a fresh clock, which a captured payload could not
39
+ * do: entries that became due mid-retry would keep being POSTed with a past `at()`, a rejection no
40
+ * number of retries could clear.
41
+ *
42
+ * Assumes ONE sink per process over ONE state root (what `start` builds): the loop keeps the root of
43
+ * the call that started it, and its dirty flag is shared, so a second root would fold into the first
44
+ * one's pass.
34
45
  */
35
46
  export declare function createWakeAlarmSink(options: {
36
47
  secret: string;
@@ -39,9 +50,4 @@ export declare function createWakeAlarmSink(options: {
39
50
  now?: () => Date;
40
51
  /** Injectable retry pause (tests); defaults to exponential-ish backoff off RETRY_BASE_MS. */
41
52
  delay?: (ms: number) => Promise<void>;
42
- }): (stateRoot: string, pending: Wakeup[]) => void;
43
- /**
44
- * One boot-time reconcile: pending wake-ups may exist while their alarms were lost (a deploy
45
- * replaced the forwarder, a sink call failed) — re-mirror the current set once at start.
46
- */
47
- export declare function reconcileWakeAlarms(stateRoot: string, sink: (stateRoot: string, pending: Wakeup[]) => void): void;
53
+ }): (stateRoot: string) => void;
@@ -17,7 +17,8 @@
17
17
  * Function URL at cold start), and the adapter persists it here; every wake write happens inside a
18
18
  * turn, and every ingress turn arrived through an envelope, so the URL is always known by then.
19
19
  *
20
- * Reconciliation is DECLARATIVE (the full pending set travels each time) and deletion is lazy: a
20
+ * Reconciliation is DECLARATIVE (the sink is told only THAT the store changed and reads the full
21
+ * pending set back per attempt, so no retry ever carries a stale view) and deletion is lazy: a
21
22
  * cancelled wake-up's alarm still fires its poke, finds nothing due, and self-deletes — a harmless
22
23
  * wasted wake-up of the box, traded for never needing list/delete choreography.
23
24
  */
@@ -71,66 +72,107 @@ export function toAlarms(pending, now) {
71
72
  * when `FASTAGENT_AGENTCORE=1` + `FASTAGENT_WAKE_SECRET` are present). Fire-and-forget by contract:
72
73
  * failures are logged, never thrown — a broken alarm degrades to the pre-alarm behavior (the wake
73
74
  * still fires on the next time the box happens to be awake), it must never break the store write.
75
+ *
76
+ * A single-flight RECONCILER, not a per-mutation delivery: a notification only marks the desired
77
+ * state dirty, and the loop re-derives it from the store before every attempt. So a mutation during
78
+ * a retry needs no ordering rule — there is one desired state, never two snapshots to rank — and the
79
+ * {@link DUE_MARGIN_MS} filter is re-applied with a fresh clock, which a captured payload could not
80
+ * do: entries that became due mid-retry would keep being POSTed with a past `at()`, a rejection no
81
+ * number of retries could clear.
82
+ *
83
+ * Assumes ONE sink per process over ONE state root (what `start` builds): the loop keeps the root of
84
+ * the call that started it, and its dirty flag is shared, so a second root would fold into the first
85
+ * one's pass.
74
86
  */
75
87
  export function createWakeAlarmSink(options) {
76
88
  const { secret, fetchImpl = fetch, now = () => new Date() } = options;
77
89
  const delay = options.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
78
- // Supersession token: a newer mutation's sync REPLACES an older one still retrying — without it,
79
- // an old in-flight set could win the race and mirror stale state.
80
- let latest;
81
- async function sync(url, body, token) {
90
+ let running = false;
91
+ let dirty = false;
92
+ /** One POST of the CURRENT desired set. Returns false to retry, true when there is nothing left
93
+ * to do (converged, or nothing this loop can act on). */
94
+ async function attemptOnce(stateRoot, attempt) {
95
+ const alarms = toAlarms(listWakeups(stateRoot), now());
96
+ // Nothing future to mirror: converged. Deletion is lazy by design — alarms already mirrored for
97
+ // cancelled wake-ups fire, find nothing, self-delete — so an empty set is never POSTed.
98
+ if (alarms.length === 0)
99
+ return true;
100
+ const url = readWakeAlarmUrl(stateRoot);
101
+ if (!url) {
102
+ // Before the first envelope, or an unreadable state mount (readWakeAlarmUrl folds both into
103
+ // undefined). The wake itself is stored; the next store mutation or boot re-mirrors it.
104
+ log.warn("[schedule] wake alarm skipped — forwarder URL unavailable (not seen yet, or unreadable)");
105
+ return true;
106
+ }
107
+ const body = { secret, alarms };
108
+ try {
109
+ const res = await fetchImpl(`${url.replace(/\/$/, "")}${WAKE_ALARM_PATH}`, {
110
+ method: "POST",
111
+ headers: { "content-type": "application/json" },
112
+ body: JSON.stringify(body),
113
+ signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
114
+ });
115
+ if (res.ok)
116
+ return true;
117
+ log.warn(`[schedule] wake alarm sync attempt ${attempt}/${MAX_SYNC_ATTEMPTS} failed: HTTP ${res.status}`);
118
+ }
119
+ catch (e) {
120
+ log.warn(`[schedule] wake alarm sync attempt ${attempt}/${MAX_SYNC_ATTEMPTS} failed: ${String(e)}`);
121
+ }
122
+ return false;
123
+ }
124
+ async function reconcile(stateRoot) {
82
125
  // Counted as in-flight work: the retry loop is exactly the window where the box must not be
83
126
  // reclaimed — idle away mid-retry and a pending wake has no alarm until the next boot.
84
127
  const workDone = beginWork();
128
+ // Consecutive failures ACROSS passes, not within one. A mid-retry mutation restarts the attempt
129
+ // sequence (the backoff and the log's `n/N` are about the new desired set), but it must not renew
130
+ // the budget: a store mutating faster than the backoff would keep the loop alive forever, and the
131
+ // one loud line saying the forwarder is down — the per-attempt warns are filterable — would never
132
+ // be reached. This counter is the thing that survives to reach it.
133
+ let failures = 0;
85
134
  try {
86
- for (let attempt = 1; attempt <= MAX_SYNC_ATTEMPTS; attempt++) {
87
- if (latest !== token)
88
- return; // superseded by a newer set that sync owns correctness now
89
- try {
90
- const res = await fetchImpl(`${url.replace(/\/$/, "")}${WAKE_ALARM_PATH}`, {
91
- method: "POST",
92
- headers: { "content-type": "application/json" },
93
- body: JSON.stringify(body),
94
- signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
95
- });
96
- if (res.ok)
97
- return;
98
- log.warn(`[schedule] wake alarm sync attempt ${attempt}/${MAX_SYNC_ATTEMPTS} failed: HTTP ${res.status}`);
135
+ while (dirty && failures < MAX_SYNC_ATTEMPTS) {
136
+ dirty = false;
137
+ for (let attempt = 1; attempt <= MAX_SYNC_ATTEMPTS; attempt++) {
138
+ if (await attemptOnce(stateRoot, attempt)) {
139
+ failures = 0;
140
+ break;
141
+ }
142
+ failures++;
143
+ if (attempt === MAX_SYNC_ATTEMPTS || failures >= MAX_SYNC_ATTEMPTS)
144
+ break;
145
+ await delay(RETRY_BASE_MS * attempt);
146
+ // A mutation landed mid-retry: the desired state moved, so this budget is spent on a set
147
+ // that no longer exists. Restart the attempt count against the new one.
148
+ if (dirty)
149
+ break;
99
150
  }
100
- catch (e) {
101
- log.warn(`[schedule] wake alarm sync attempt ${attempt}/${MAX_SYNC_ATTEMPTS} failed: ${String(e)}`);
102
- }
103
- await delay(RETRY_BASE_MS * attempt);
104
151
  }
105
- log.error(`[schedule] wake alarm sync FAILED after ${MAX_SYNC_ATTEMPTS} attempts — pending wake-ups have no ` +
106
- `external alarm until the next store change or boot re-mirrors them`);
152
+ if (failures >= MAX_SYNC_ATTEMPTS) {
153
+ log.error(`[schedule] wake alarm sync FAILED after ${MAX_SYNC_ATTEMPTS} attempts pending wake-ups have no ` +
154
+ `external alarm until the next store change or boot re-mirrors them`);
155
+ }
156
+ }
157
+ catch (e) {
158
+ // The END of the error path: nothing awaits this loop, so an escape is an unhandled rejection
159
+ // that kills the container. `listWakeups` throws by design on an unreadable store (state.ts),
160
+ // exactly the fault a wake alarm cannot fix — report it and leave the store write untouched,
161
+ // which is this sink's whole contract. The next mutation or boot re-runs the mirror.
162
+ log.error(`[schedule] wake alarm reconcile failed (alarms are stale until the next store change): ${String(e)}`);
107
163
  }
108
164
  finally {
165
+ running = false;
109
166
  workDone();
110
167
  }
111
168
  }
112
- return (stateRoot, pending) => {
113
- const url = readWakeAlarmUrl(stateRoot);
114
- if (!url) {
115
- // First-ever boot before any envelope: nothing to call yet. The wake itself is stored; the
116
- // alarm catches up on the next store mutation after an envelope has arrived.
117
- log.warn("[schedule] wake alarm skipped — forwarder URL not seen yet (it arrives with the first envelope)");
169
+ // Single-flight: a save arriving while the loop runs only marks it dirty, so a burst coalesces
170
+ // into one more pass instead of one concurrent loop each.
171
+ return (stateRoot) => {
172
+ dirty = true;
173
+ if (running)
118
174
  return;
119
- }
120
- const alarms = toAlarms(pending, now());
121
- if (alarms.length === 0)
122
- return; // nothing future to mirror (deletion is lazy by design)
123
- const token = Symbol("wake-alarm-sync");
124
- latest = token;
125
- void sync(url, { secret, alarms }, token);
175
+ running = true;
176
+ void reconcile(stateRoot);
126
177
  };
127
178
  }
128
- /**
129
- * One boot-time reconcile: pending wake-ups may exist while their alarms were lost (a deploy
130
- * replaced the forwarder, a sink call failed) — re-mirror the current set once at start.
131
- */
132
- export function reconcileWakeAlarms(stateRoot, sink) {
133
- const pending = listWakeups(stateRoot);
134
- if (pending.length > 0)
135
- sink(stateRoot, pending);
136
- }
@@ -26,7 +26,7 @@ export declare const MAX_PENDING_WAKEUPS = 20;
26
26
  * wake into an active conversation fires in the first gap between the user's turns; this generous
27
27
  * ceiling (~1h) only gives up on a pathologically stuck session (then logs, operator-visible). */
28
28
  export declare const MAX_WAKE_ATTEMPTS = 120;
29
- export declare function setWakeupsSink(sink: ((stateRoot: string, pending: Wakeup[]) => void) | undefined): void;
29
+ export declare function setWakeupsSink(sink: ((stateRoot: string) => void) | undefined): void;
30
30
  /** The current pending wake-ups (`fastagent schedule list` uses this). */
31
31
  export declare function listWakeups(stateRoot: string): Wakeup[];
32
32
  export type AddWakeupResult = {
@@ -73,7 +73,7 @@ export declare function takeFirstDueWakeup(stateRoot: string, now?: Date): Wakeu
73
73
  * already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
74
74
  *
75
75
  * Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
76
- * window (the busy reject yields before any harness IO) where an `unwake` for this id reports "not found"
76
+ * window (the busy reject yields before any engine IO) where an `unwake` for this id reports "not found"
77
77
  * and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
78
78
  * claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
79
79
  */
@@ -48,12 +48,15 @@ function isWakeup(e) {
48
48
  (typeof w.cron === "string" && cronError(w.cron, typeof w.tz === "string" ? w.tz : undefined) === undefined)));
49
49
  }
50
50
  /**
51
- * The wake-ALARM sink: notified after EVERY wakeups-store mutation with the new pending set. The
52
- * AgentCore deployment registers one (schedule/wake-alarm.ts) that mirrors pending wake-ups into
53
- * one-shot EventBridge schedules the external clock that makes `wake` reliable on a host with no
54
- * resident process. Neutral seam: this module knows only "someone wants to observe changes"; a
55
- * resident host registers nothing and behaves exactly as before. The sink is fire-and-forget and
56
- * must own its errors; a throw is caught here so a broken alarm never corrupts a store write.
51
+ * The wake-ALARM sink: notified after EVERY wakeups-store mutation. A notification, not a delivery
52
+ * it carries no set, because the sink reconciles by re-reading the store, and handing it a set
53
+ * captured here would go stale in its retries. The AgentCore deployment registers one
54
+ * (schedule/wake-alarm.ts) that mirrors pending wake-ups into one-shot EventBridge schedules the
55
+ * external clock that makes `wake` reliable on a host with no resident process. Neutral seam: this
56
+ * module knows only "someone wants to observe changes"; a resident host registers nothing and
57
+ * behaves exactly as before. The sink is fire-and-forget and must own its errors. The guard below
58
+ * catches a SYNCHRONOUS throw so a broken alarm never corrupts a store write; an async sink returns
59
+ * before it does any work, so its rejections are ITS to terminate — nothing here can see them.
57
60
  */
58
61
  let wakeupsSink;
59
62
  export function setWakeupsSink(sink) {
@@ -81,7 +84,7 @@ function load(stateRoot) {
81
84
  function save(stateRoot, wakeups) {
82
85
  writeScheduleFile(scheduleFile(stateRoot, "wakeups"), wakeups);
83
86
  try {
84
- wakeupsSink?.(stateRoot, wakeups);
87
+ wakeupsSink?.(stateRoot);
85
88
  }
86
89
  catch (e) {
87
90
  log.error(`[schedule] wake-alarm sink failed (store write is unaffected): ${String(e)}`);
@@ -201,7 +204,7 @@ export function takeFirstDueWakeup(stateRoot, now = new Date()) {
201
204
  * already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
202
205
  *
203
206
  * Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
204
- * window (the busy reject yields before any harness IO) where an `unwake` for this id reports "not found"
207
+ * window (the busy reject yields before any engine IO) where an `unwake` for this id reports "not found"
205
208
  * and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
206
209
  * claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
207
210
  */