@fastagent-sh/fastagent 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +14 -4
  2. package/dist/bind.d.ts +34 -0
  3. package/dist/bind.js +74 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +266 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +5 -0
  13. package/dist/channels/context-buffer.js +5 -0
  14. package/dist/channels/feishu/context-buffer.d.ts +13 -8
  15. package/dist/channels/feishu/context-buffer.js +60 -6
  16. package/dist/channels/feishu/feishu-api.js +4 -1
  17. package/dist/channels/feishu/feishu.d.ts +0 -12
  18. package/dist/channels/feishu/feishu.js +143 -54
  19. package/dist/channels/feishu/invoke-turn.js +35 -18
  20. package/dist/channels/feishu/model.d.ts +0 -1
  21. package/dist/channels/feishu/normalize.js +0 -1
  22. package/dist/channels/feishu/parse.d.ts +21 -7
  23. package/dist/channels/feishu/parse.js +24 -7
  24. package/dist/channels/feishu/preview.js +3 -2
  25. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  26. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  27. package/dist/channels/feishu/setup-mode.d.ts +30 -1
  28. package/dist/channels/feishu/setup-mode.js +27 -1
  29. package/dist/channels/github/github.js +8 -1
  30. package/dist/channels/http.js +1 -1
  31. package/dist/channels/lark/scaffold/channel.ts +9 -8
  32. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  33. package/dist/channels/preview-kit.d.ts +7 -1
  34. package/dist/channels/preview-kit.js +3 -2
  35. package/dist/channels/slack/parse.d.ts +16 -1
  36. package/dist/channels/slack/parse.js +46 -3
  37. package/dist/channels/slack/preview.d.ts +1 -2
  38. package/dist/channels/slack/preview.js +68 -24
  39. package/dist/channels/slack/scaffold/channel.ts +5 -5
  40. package/dist/channels/slack/slack-api.d.ts +3 -23
  41. package/dist/channels/slack/slack-api.js +6 -22
  42. package/dist/channels/slack/slack.d.ts +13 -20
  43. package/dist/channels/slack/slack.js +95 -50
  44. package/dist/channels/state.d.ts +11 -4
  45. package/dist/channels/state.js +19 -12
  46. package/dist/channels/tasks.d.ts +0 -6
  47. package/dist/channels/tasks.js +16 -1
  48. package/dist/channels/telegram/parse.d.ts +0 -7
  49. package/dist/channels/telegram/parse.js +4 -2
  50. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  51. package/dist/channels/telegram/telegram.js +1 -1
  52. package/dist/channels/text.d.ts +14 -0
  53. package/dist/channels/text.js +14 -0
  54. package/dist/channels/thread-participants.d.ts +21 -0
  55. package/dist/channels/thread-participants.js +132 -0
  56. package/dist/channels/turn-queue.js +7 -0
  57. package/dist/cli/add-feishu.d.ts +7 -4
  58. package/dist/cli/add-feishu.js +57 -37
  59. package/dist/cli/add-slack.d.ts +2 -1
  60. package/dist/cli/add-slack.js +6 -11
  61. package/dist/cli/commands/add.js +50 -51
  62. package/dist/cli/commands/attach.js +8 -4
  63. package/dist/cli/commands/chat.js +8 -8
  64. package/dist/cli/commands/deploy.d.ts +14 -1
  65. package/dist/cli/commands/deploy.js +330 -78
  66. package/dist/cli/commands/dev.d.ts +1 -0
  67. package/dist/cli/commands/dev.js +38 -25
  68. package/dist/cli/commands/fire.js +15 -16
  69. package/dist/cli/commands/info.js +36 -29
  70. package/dist/cli/commands/init.d.ts +1 -1
  71. package/dist/cli/commands/init.js +65 -53
  72. package/dist/cli/commands/invoke.js +9 -6
  73. package/dist/cli/commands/login.js +35 -21
  74. package/dist/cli/commands/schedule.js +6 -8
  75. package/dist/cli/commands/start.d.ts +1 -0
  76. package/dist/cli/commands/start.js +101 -37
  77. package/dist/cli/commands/tool.js +30 -18
  78. package/dist/cli/fail.d.ts +17 -0
  79. package/dist/cli/fail.js +24 -0
  80. package/dist/cli/program.js +66 -36
  81. package/dist/cli/serve.d.ts +52 -8
  82. package/dist/cli/serve.js +133 -32
  83. package/dist/cli/shared.d.ts +21 -2
  84. package/dist/cli/shared.js +44 -18
  85. package/dist/deploy/agentcore/plan.d.ts +117 -0
  86. package/dist/deploy/agentcore/plan.js +721 -0
  87. package/dist/deploy/agentcore/run.d.ts +73 -0
  88. package/dist/deploy/agentcore/run.js +412 -0
  89. package/dist/deploy/agentcore/zip.d.ts +17 -0
  90. package/dist/deploy/agentcore/zip.js +68 -0
  91. package/dist/deploy/container.d.ts +26 -25
  92. package/dist/deploy/container.js +93 -89
  93. package/dist/deploy/docker/plan.d.ts +1 -1
  94. package/dist/deploy/docker/plan.js +12 -17
  95. package/dist/deploy/fly/plan.d.ts +2 -0
  96. package/dist/deploy/fly/plan.js +27 -19
  97. package/dist/deploy/fly/run.d.ts +12 -1
  98. package/dist/deploy/fly/run.js +36 -2
  99. package/dist/deploy/preflight.d.ts +11 -5
  100. package/dist/deploy/preflight.js +235 -65
  101. package/dist/deploy/railway/plan.d.ts +7 -0
  102. package/dist/deploy/railway/plan.js +41 -16
  103. package/dist/deploy/railway/run.d.ts +8 -1
  104. package/dist/deploy/railway/run.js +7 -2
  105. package/dist/deploy/runner.d.ts +5 -2
  106. package/dist/deploy/runner.js +9 -3
  107. package/dist/dev-supervisor.d.ts +11 -8
  108. package/dist/dev-supervisor.js +53 -51
  109. package/dist/engines/pi/auth.d.ts +8 -7
  110. package/dist/engines/pi/auth.js +12 -10
  111. package/dist/engines/pi/channel.d.ts +1 -1
  112. package/dist/engines/pi/channel.js +5 -5
  113. package/dist/engines/pi/chat.js +2 -2
  114. package/dist/engines/pi/config.d.ts +12 -48
  115. package/dist/engines/pi/config.js +29 -110
  116. package/dist/engines/pi/create.d.ts +32 -24
  117. package/dist/engines/pi/create.js +47 -23
  118. package/dist/engines/pi/definition.d.ts +7 -26
  119. package/dist/engines/pi/definition.js +8 -54
  120. package/dist/engines/pi/harness.d.ts +19 -5
  121. package/dist/engines/pi/harness.js +3 -5
  122. package/dist/engines/pi/login.d.ts +1 -1
  123. package/dist/engines/pi/models.d.ts +3 -3
  124. package/dist/engines/pi/models.js +1 -1
  125. package/dist/engines/pi/{workspace.d.ts → open.d.ts} +31 -25
  126. package/dist/engines/pi/{workspace.js → open.js} +27 -29
  127. package/dist/engines/pi/read-image.d.ts +4 -0
  128. package/dist/engines/pi/read-image.js +62 -0
  129. package/dist/engines/pi/search-tools.d.ts +6 -4
  130. package/dist/engines/pi/search-tools.js +3 -1
  131. package/dist/engines/pi/session-builder.d.ts +2 -2
  132. package/dist/engines/pi/session-builder.js +18 -13
  133. package/dist/engines/pi/tool.d.ts +13 -5
  134. package/dist/engines/pi/tool.js +4 -0
  135. package/dist/engines/pi/wake-tool.d.ts +3 -3
  136. package/dist/env.d.ts +16 -4
  137. package/dist/env.js +43 -5
  138. package/dist/host/node.d.ts +4 -2
  139. package/dist/host/node.js +2 -1
  140. package/dist/loader.d.ts +2 -2
  141. package/dist/loader.js +3 -3
  142. package/dist/log.d.ts +1 -1
  143. package/dist/log.js +1 -1
  144. package/dist/paths.d.ts +138 -0
  145. package/dist/paths.js +326 -0
  146. package/dist/pi.d.ts +2 -2
  147. package/dist/pi.js +2 -2
  148. package/dist/runtime.d.ts +7 -5
  149. package/dist/runtime.js +2 -2
  150. package/dist/scaffold/add-channel.d.ts +7 -3
  151. package/dist/scaffold/add-channel.js +55 -29
  152. package/dist/scaffold/init.d.ts +32 -41
  153. package/dist/scaffold/init.js +161 -185
  154. package/dist/scaffold/templates/env.example +15 -6
  155. package/dist/scaffold/templates/fastagent.config.mjs +1 -1
  156. package/dist/scaffold/templates/gitignore +14 -6
  157. package/dist/scaffold/templates/persona.md +4 -2
  158. package/dist/scaffold/templates/secrets.gitignore +5 -0
  159. package/dist/scaffold/templates.d.ts +1 -7
  160. package/dist/scaffold/templates.js +3 -25
  161. package/dist/scaffold/vendor-skill.d.ts +2 -2
  162. package/dist/scaffold/vendor-skill.js +13 -13
  163. package/dist/schedule/discover.js +4 -4
  164. package/dist/schedule/scheduler.d.ts +40 -1
  165. package/dist/schedule/scheduler.js +89 -56
  166. package/dist/schedule/state.js +1 -1
  167. package/dist/schedule/wake-alarm.d.ts +47 -0
  168. package/dist/schedule/wake-alarm.js +136 -0
  169. package/dist/schedule/wakeups.d.ts +1 -0
  170. package/dist/schedule/wakeups.js +18 -0
  171. package/dist/tunnel.d.ts +3 -3
  172. package/dist/tunnel.js +7 -7
  173. package/package.json +7 -4
  174. package/dist/channels/feishu/owned-threads.d.ts +0 -7
  175. package/dist/channels/feishu/owned-threads.js +0 -47
  176. package/dist/channels/slack/owned-threads.d.ts +0 -6
  177. package/dist/channels/slack/owned-threads.js +0 -43
  178. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  179. package/dist/scaffold/templates/gitignore.kit +0 -2
  180. package/dist/workspace.d.ts +0 -9
  181. package/dist/workspace.js +0 -45
@@ -1,11 +1,5 @@
1
- /** Read a base workspace template (src/scaffold/templates/<name>). */
1
+ /** Read a base agent template (src/scaffold/templates/<name>). */
2
2
  export declare const baseTemplate: (name: string) => string;
3
- /** The persona template; in the agentDir layout a locator note is appended (anchor-free — no silent-miss
4
- * risk) so the self-iteration guidance ("write skills beside this file") points into the kit, not the
5
- * run root — a skill written to the root `skills/` would never be scanned. */
6
- export declare function personaTemplate(agentDir?: string): string;
7
- /** The config template; when the kit is placed in a subdirectory, `agentDir` is injected as the first key. */
8
- export declare function configTemplate(agentDir?: string): string;
9
3
  /** Read one file from a channel's scaffold bundle (src/channels/<kind>/scaffold/<name>). */
10
4
  export declare const channelTemplate: (kind: string, name: string) => string;
11
5
  /** The .ts files in a channel's bundle: `channel.ts` is the channel adapter; the rest are companion tools. */
@@ -1,36 +1,14 @@
1
1
  /**
2
- * Scaffold template ACCESS (data, not logic): readers for the real files `init`/`add` write into a
3
- * workspace, plus the parametric pieces. Base workspace templates live under ./templates/; each
2
+ * Scaffold template ACCESS (data, not logic): readers for the real files `init`/`add` write into an
3
+ * agent dir, plus the parametric pieces. Base agent templates live under ./templates/; each
4
4
  * channel's bundle lives WITH the channel at ../channels/<kind>/scaffold/ (so a channel owns its
5
5
  * starter kit and could ship as its own package). Both trees are excluded from this package's tsc +
6
6
  * biome (they import the published @fastagent-sh/fastagent, not this source) and copied into dist/ by the build.
7
7
  */
8
8
  import { readFileSync, readdirSync } from "node:fs";
9
9
  import { basename, resolve } from "node:path";
10
- /** Read a base workspace template (src/scaffold/templates/<name>). */
10
+ /** Read a base agent template (src/scaffold/templates/<name>). */
11
11
  export const baseTemplate = (name) => readFileSync(new URL(`./templates/${name}`, import.meta.url), "utf8");
12
- /** The persona template; in the agentDir layout a locator note is appended (anchor-free — no silent-miss
13
- * risk) so the self-iteration guidance ("write skills beside this file") points into the kit, not the
14
- * run root — a skill written to the root `skills/` would never be scanned. */
15
- export function personaTemplate(agentDir) {
16
- const base = baseTemplate("persona.md");
17
- if (!agentDir)
18
- return base;
19
- return `${base}\nNote: your definition lives under \`${agentDir}/\` relative to the workspace root — this file is \`${agentDir}/persona.md\`, and a new skill goes to \`${agentDir}/skills/<name>/SKILL.md\` (a \`skills/\` at the root is not scanned).\n`;
20
- }
21
- /** The config template; when the kit is placed in a subdirectory, `agentDir` is injected as the first key. */
22
- export function configTemplate(agentDir) {
23
- const base = baseTemplate("fastagent.config.mjs");
24
- if (!agentDir)
25
- return base;
26
- const out = base.replace("export default {\n", `export default {\n agentDir: ${JSON.stringify(agentDir)}, // the agent's own surface (persona.md / skills / tools / channels) lives there\n`);
27
- // Fail visibly if the template drifted and the anchor no longer matches — a config that silently
28
- // doesn't point at the kit would assemble an EMPTY agent with no error. Check the OPERATION happened
29
- // (out !== base), not a substring — a commented `agentDir:` example in the template would fool that.
30
- if (out === base)
31
- throw new Error("configTemplate: anchor not found in fastagent.config.mjs template");
32
- return out;
33
- }
34
12
  const channelScaffoldDir = (kind) => new URL(`../channels/${kind}/scaffold/`, import.meta.url);
35
13
  /** Read one file from a channel's scaffold bundle (src/channels/<kind>/scaffold/<name>). */
36
14
  export const channelTemplate = (kind, name) => readFileSync(new URL(name, channelScaffoldDir(kind)), "utf8");
@@ -13,12 +13,12 @@ export interface VendoredSkill {
13
13
  overwritten: boolean;
14
14
  }
15
15
  /**
16
- * Vendor an Agent Skills skill into `<workspace>/skills/<name>/` from a giget ref (github default), a
16
+ * Vendor an Agent Skills skill into `<agent dir>/skills/<name>/` from a giget ref (github default), a
17
17
  * local path, or a bare name (resolved against the local global skill dirs). Copy-in, git-tracked.
18
18
  * Refuses to overwrite unless `options.update` (then a plain git-tracked overwrite, never a merge).
19
19
  * Validates a staging copy with the runtime loader BEFORE replacing, so a bad fetch never destroys an
20
20
  * existing skill.
21
21
  */
22
- export declare function vendorSkill(workspaceDir: string, source: string, options?: {
22
+ export declare function vendorSkill(agentDir: string, source: string, options?: {
23
23
  update?: boolean;
24
24
  }): Promise<VendoredSkill>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `fastagent add skill <source>`: vendor an Agent Skills skill into `<workspace>/skills/<name>/` —
2
+ * `fastagent add skill <source>`: vendor an Agent Skills skill into `<agent dir>/skills/<name>/` —
3
3
  * copy-in, git-tracked, never a runtime registry. Source is a giget ref (github default), a local
4
4
  * path, or a bare name (resolved against the local global skill dirs as an add-time copy source only).
5
5
  * Fetch → staging → validate → rollback-protected replace, so a bad fetch never destroys an existing
@@ -11,19 +11,19 @@ import { randomUUID } from "node:crypto";
11
11
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
12
12
  import { homedir } from "node:os";
13
13
  import { loadAgentDefinition } from "../engines/pi/definition.js";
14
- import { assertInsideWorkspace } from "../workspace.js";
14
+ import { assertInsideAgentDir } from "../paths.js";
15
15
  /** Derive the destination skill name from a source ref: the last path segment, sans `#ref`. */
16
16
  function skillNameFromSource(source) {
17
17
  const noRef = source.split("#")[0] ?? source;
18
18
  return basename(noRef.replace(/\/+$/, ""));
19
19
  }
20
20
  /**
21
- * The skills/ path must be safe to write through: never follow a symlink that escapes the workspace
21
+ * The skills/ path must be safe to write through: never follow a symlink that escapes the agent dir
22
22
  * (mkdir would), and reject a plain file with one clear message.
23
23
  */
24
- async function assertSkillsDirUsable(workspaceDir) {
25
- await assertInsideWorkspace(workspaceDir, "skills");
26
- const skillsDir = join(workspaceDir, "skills");
24
+ async function assertSkillsDirUsable(agentDir) {
25
+ await assertInsideAgentDir(agentDir, "skills");
26
+ const skillsDir = join(agentDir, "skills");
27
27
  const st = await stat(skillsDir).catch(() => undefined);
28
28
  if (st && !st.isDirectory()) {
29
29
  throw new Error(`${skillsDir} exists and is not a directory — remove it (skills must be a directory)`);
@@ -47,19 +47,19 @@ function findGlobalSkillSource(name) {
47
47
  return undefined;
48
48
  }
49
49
  /**
50
- * Vendor an Agent Skills skill into `<workspace>/skills/<name>/` from a giget ref (github default), a
50
+ * Vendor an Agent Skills skill into `<agent dir>/skills/<name>/` from a giget ref (github default), a
51
51
  * local path, or a bare name (resolved against the local global skill dirs). Copy-in, git-tracked.
52
52
  * Refuses to overwrite unless `options.update` (then a plain git-tracked overwrite, never a merge).
53
53
  * Validates a staging copy with the runtime loader BEFORE replacing, so a bad fetch never destroys an
54
54
  * existing skill.
55
55
  */
56
- export async function vendorSkill(workspaceDir, source, options = {}) {
56
+ export async function vendorSkill(agentDir, source, options = {}) {
57
57
  const name = skillNameFromSource(source);
58
58
  if (name === "" || name === "." || name === "..") {
59
59
  throw new Error(`cannot derive a skill name from "${source}" — point at a skill directory (…/skills/<name>)`);
60
60
  }
61
- const skillsDir = join(workspaceDir, "skills");
62
- await assertSkillsDirUsable(workspaceDir);
61
+ const skillsDir = join(agentDir, "skills");
62
+ await assertSkillsDirUsable(agentDir);
63
63
  const dest = join(skillsDir, name);
64
64
  // A process can die between moving the old skill aside and installing staging. Never guess that an
65
65
  // arbitrary hidden directory is ours or delete it: stop with the exact backup path for manual restore.
@@ -143,15 +143,15 @@ export async function vendorSkill(workspaceDir, source, options = {}) {
143
143
  await rm(previous, { recursive: true, force: true });
144
144
  // Report via the runtime loader, matching THIS skill by EXACT directory (a substring match would
145
145
  // prefix-pollute a sibling `<name>-x` and break on Windows path separators).
146
- const def = await loadAgentDefinition(workspaceDir);
146
+ const def = await loadAgentDefinition(agentDir);
147
147
  const rel = join("skills", name);
148
- const skill = def.skills.find((sk) => relative(workspaceDir, dirname(sk.filePath)) === rel);
148
+ const skill = def.skills.find((sk) => relative(agentDir, dirname(sk.filePath)) === rel);
149
149
  return {
150
150
  name: skill?.name ?? name,
151
151
  description: skill?.description,
152
152
  dest: rel,
153
153
  hasScripts: existsSync(join(dest, "scripts")),
154
- diagnostics: def.diagnostics.filter((d) => d.path !== undefined && relative(workspaceDir, dirname(d.path)) === rel),
154
+ diagnostics: def.diagnostics.filter((d) => d.path !== undefined && relative(agentDir, dirname(d.path)) === rel),
155
155
  overwritten,
156
156
  };
157
157
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Schedule discovery (the N axis, clock form): a workspace declares its time-triggers by dropping files
2
+ * Schedule discovery (the N axis, clock form): an agent declares its time-triggers by dropping files
3
3
  * in `schedules/`, mirroring `tools/` and `channels/`. Each file default-exports `defineSchedule({...})`,
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.
@@ -7,13 +7,13 @@
7
7
  import { readdir } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { isModuleFile, loadModuleDir } from "../loader.js";
10
- import { assertInsideWorkspace } from "../workspace.js";
10
+ import { assertInsideAgentDir } from "../paths.js";
11
11
  import { cronError } from "./cron.js";
12
12
  /** Schedule file basenames under `<dir>/schedules/` — an existence probe listed WITHOUT importing
13
13
  * (deploy pre-flight's time-trigger detection; `fastagent info` uses {@link loadSchedules} instead,
14
14
  * since it also reports broken files and next instants). */
15
15
  export async function discoverScheduleFiles(dir) {
16
- await assertInsideWorkspace(dir, "schedules");
16
+ await assertInsideAgentDir(dir, "schedules");
17
17
  let names;
18
18
  try {
19
19
  names = await readdir(join(dir, "schedules"));
@@ -36,7 +36,7 @@ export async function discoverScheduleFiles(dir) {
36
36
  * reports the rest.
37
37
  */
38
38
  export async function loadSchedules(dir) {
39
- await assertInsideWorkspace(dir, "schedules");
39
+ await assertInsideAgentDir(dir, "schedules");
40
40
  const { modules, failures } = await loadModuleDir(join(dir, "schedules"));
41
41
  const byName = new Map();
42
42
  for (const { name, label, file, mod } of modules) {
@@ -32,5 +32,44 @@ export interface SchedulerOptions {
32
32
  schedules: LoadedSchedule[];
33
33
  /** Injectable clock for tests; defaults to the wall clock. */
34
34
  now?: () => Date;
35
+ /** External-clock mode (the AgentCore deployment): cron slots are DELIVERED by an external
36
+ * scheduler through the serving surface ({@link fireScheduleOnce} with a `slot`), so `start()`
37
+ * arms NO cron timers and does NO boot catch-up — delivery, including for instants that passed
38
+ * while this process was down, is the external clock's job; the adapter's slot-idempotent claim
39
+ * is the duplicate guard. The wake-up poll still runs (degraded: it only fires while the
40
+ * process happens to be awake — the deploy path warns about this). */
41
+ externalClock?: boolean;
35
42
  }
36
- export declare function createScheduler({ agent, stateRoot, schedules, now }: SchedulerOptions): Scheduler;
43
+ /** One schedule fire's outcome, for the external caller (the AgentCore adapter returns it to the
44
+ * triggering clock's logs). The resident scheduler ignores it beyond completion. */
45
+ export interface ScheduleFireOutcome {
46
+ fired: boolean;
47
+ /** Set when a slot-keyed fire was skipped because that slot (or a later one) was already claimed. */
48
+ skippedReason?: string;
49
+ failed?: string;
50
+ ms: number;
51
+ }
52
+ /**
53
+ * Fire ONE schedule's turn: claim (persist lastFired BEFORE invoking, so a crash mid-turn does not
54
+ * re-fire on restart), run, audit. Shared by the resident scheduler's timers and the external-clock
55
+ * serving surface (the AgentCore adapter).
56
+ *
57
+ * `slot` is the external clock's idempotency key — the cron instant this fire is FOR. External
58
+ * delivery (EventBridge-style) is at-least-once, so a duplicate slot must not double-fire: when
59
+ * lastFired ≥ slot the fire is SKIPPED (at-most-once per slot, same trade as the resident claim —
60
+ * "a digest late once" beats "twice"). The resident scheduler omits `slot`: its timers fire each
61
+ * slot exactly once, so the unconditional claim is already correct.
62
+ *
63
+ * A state fault while claiming (loadFires/saveFires — both before the invoke, so nothing ran)
64
+ * THROWS: the resident path catches it at its single skip+audit boundary ({@link createScheduler}'s
65
+ * fireThenReArm — skipping rather than firing unclaimed also avoids an infinite catch-up loop on
66
+ * restart), and the external path lets it surface as a failed request (visible in the clock's logs).
67
+ */
68
+ export declare function fireScheduleOnce(opts: {
69
+ agent: Agent;
70
+ stateRoot: string;
71
+ schedule: LoadedSchedule;
72
+ slot?: Date;
73
+ now?: () => Date;
74
+ }): Promise<ScheduleFireOutcome>;
75
+ export declare function createScheduler({ agent, stateRoot, schedules, now, externalClock, }: SchedulerOptions): Scheduler;
@@ -13,6 +13,7 @@
13
13
  * "a digest late once" beats "twice"). Strict at-least-once (a per-turn WAL) is a later tier.
14
14
  */
15
15
  import { SESSION_BUSY_CODE } from "../agent.js";
16
+ import { beginWork } from "../channels/busy.js";
16
17
  import { log } from "../log.js";
17
18
  import { appendRun } from "./audit.js";
18
19
  import { nextRun } from "./cron.js";
@@ -29,63 +30,89 @@ const MAX_WAIT_MS = 6 * 60 * 60 * 1000; // 6h
29
30
  // How often to poll the agent's self-scheduled wake-ups (wakeups.ts). A wake fires within this of its
30
31
  // due time — fine for "wake me in N minutes"; cheap (reads a small JSON, writes only when one is due).
31
32
  const WAKEUP_POLL_MS = 30 * 1000;
32
- export function createScheduler({ agent, stateRoot, schedules, now = () => new Date() }) {
33
- const timers = new Map();
34
- let wakeupTimer;
35
- let stopped = false;
36
- /** Drive ONE turn (a cron fire or a wake-up) and log its outcome. Total never throws (its callers are
37
- * void-scheduled). Output is the agent's tools' job; this only fires and logs. Returns the turn's audit
38
- * material `failed` (details, if it failed), the accumulated `reply` text, `ms` — plus `busy`: whether
39
- * it failed specifically because the session was BUSY (the turn never started) — the ONLY replay-safe
40
- * reason to re-fire a wake-up; every other outcome is terminal (side effects may have run). */
41
- async function runTurn(label, session, prompt) {
42
- const startedAt = Date.now();
43
- log.info(`[schedule] ${label} firing (session=${session})`);
44
- try {
45
- let failed;
46
- let busy = false;
47
- let reply = "";
48
- for await (const e of agent.invoke({ session }, { text: prompt })) {
49
- if (e.type === "text")
50
- reply += e.delta;
51
- if (e.type === "failed") {
52
- failed = e.details;
53
- busy = e.code === SESSION_BUSY_CODE; // structured (SPEC §8), not a details-text match
54
- }
33
+ /** Drive ONE turn (a cron fire or a wake-up) and log its outcome. Total — never throws (its callers are
34
+ * void-scheduled). Output is the agent's tools' job; this only fires and logs. Returns the turn's audit
35
+ * material — `failed` (details, if it failed), the accumulated `reply` text, `ms` — plus `busy`: whether
36
+ * it failed specifically because the session was BUSY (the turn never started) — the ONLY replay-safe
37
+ * reason to re-fire a wake-up; every other outcome is terminal (side effects may have run). Module-level
38
+ * (not a scheduler closure) so {@link fireScheduleOnce} the external-clock fire path shares it. */
39
+ async function runTurn(agent, label, session, prompt) {
40
+ const startedAt = Date.now();
41
+ log.info(`[schedule] ${label} firing (session=${session})`);
42
+ try {
43
+ let failed;
44
+ let busy = false;
45
+ let reply = "";
46
+ for await (const e of agent.invoke({ session }, { text: prompt })) {
47
+ if (e.type === "text")
48
+ reply += e.delta;
49
+ if (e.type === "failed") {
50
+ failed = e.details;
51
+ busy = e.code === SESSION_BUSY_CODE; // structured (SPEC §8), not a details-text match
55
52
  }
56
- if (failed)
57
- log.error(`[schedule] ${label} failed (${Date.now() - startedAt}ms): ${failed}`);
58
- else
59
- log.info(`[schedule] ${label} completed (${Date.now() - startedAt}ms)`);
60
- return { busy: failed !== undefined && busy, failed, reply, ms: Date.now() - startedAt };
61
- }
62
- catch (e) {
63
- // invoke shouldn't throw (SPEC MUST 2 turns failures into events), but stay total regardless. A throw
64
- // is not the busy case, so don't defer on it.
65
- log.error(`[schedule] ${label} errored (${Date.now() - startedAt}ms): ${String(e)}`);
66
- return { busy: false, failed: String(e), reply: "", ms: Date.now() - startedAt };
67
53
  }
54
+ if (failed)
55
+ log.error(`[schedule] ${label} failed (${Date.now() - startedAt}ms): ${failed}`);
56
+ else
57
+ log.info(`[schedule] ${label} completed (${Date.now() - startedAt}ms)`);
58
+ return { busy: failed !== undefined && busy, failed, reply, ms: Date.now() - startedAt };
68
59
  }
69
- /** Fire one schedule's turn: claim the slot (persist lastFired BEFORE invoking) so a crash mid-turn
70
- * does not re-fire this slot on restart, then run the turn. A state fault while claiming (loadFires or
71
- * saveFires both before the invoke, so nothing ran) THROWS to the single skip+audit boundary in
72
- * {@link fireThenReArm}; skipping rather than firing unclaimed also avoids an infinite catch-up loop
73
- * on restart. */
60
+ catch (e) {
61
+ // invoke shouldn't throw (SPEC MUST 2 turns failures into events), but stay total regardless. A throw
62
+ // is not the busy case, so don't defer on it.
63
+ log.error(`[schedule] ${label} errored (${Date.now() - startedAt}ms): ${String(e)}`);
64
+ return { busy: false, failed: String(e), reply: "", ms: Date.now() - startedAt };
65
+ }
66
+ }
67
+ /**
68
+ * Fire ONE schedule's turn: claim (persist lastFired BEFORE invoking, so a crash mid-turn does not
69
+ * re-fire on restart), run, audit. Shared by the resident scheduler's timers and the external-clock
70
+ * serving surface (the AgentCore adapter).
71
+ *
72
+ * `slot` is the external clock's idempotency key — the cron instant this fire is FOR. External
73
+ * delivery (EventBridge-style) is at-least-once, so a duplicate slot must not double-fire: when
74
+ * lastFired ≥ slot the fire is SKIPPED (at-most-once per slot, same trade as the resident claim —
75
+ * "a digest late once" beats "twice"). The resident scheduler omits `slot`: its timers fire each
76
+ * slot exactly once, so the unconditional claim is already correct.
77
+ *
78
+ * A state fault while claiming (loadFires/saveFires — both before the invoke, so nothing ran)
79
+ * THROWS: the resident path catches it at its single skip+audit boundary ({@link createScheduler}'s
80
+ * fireThenReArm — skipping rather than firing unclaimed also avoids an infinite catch-up loop on
81
+ * restart), and the external path lets it surface as a failed request (visible in the clock's logs).
82
+ */
83
+ export async function fireScheduleOnce(opts) {
84
+ const { agent, stateRoot, schedule: s, slot, now = () => new Date() } = opts;
85
+ const fires = loadFires(stateRoot);
86
+ const last = fires[s.name];
87
+ if (slot && last && new Date(last).getTime() >= slot.getTime()) {
88
+ const reason = `slot ${slot.toISOString()} already fired (lastFired=${last})`;
89
+ log.info(`[schedule] ${s.name}: skipping — ${reason}`);
90
+ return { fired: false, skippedReason: reason, ms: 0 };
91
+ }
92
+ // An external delivery claims the cron instant it represents, not its (possibly much later)
93
+ // delivery time. Otherwise a delayed fire can make the next distinct slot look like a duplicate.
94
+ fires[s.name] = (slot ?? now()).toISOString();
95
+ saveFires(stateRoot, fires);
96
+ const firedAt = now().toISOString();
97
+ const r = await runTurn(agent, s.name, scheduleSession(s.name), s.prompt);
98
+ appendRun(stateRoot, {
99
+ name: s.name,
100
+ session: scheduleSession(s.name),
101
+ firedAt,
102
+ ms: r.ms,
103
+ outcome: r.failed ? "failed" : "completed",
104
+ reply: r.failed ? undefined : r.reply,
105
+ error: r.failed,
106
+ });
107
+ return { fired: true, failed: r.failed, ms: r.ms };
108
+ }
109
+ export function createScheduler({ agent, stateRoot, schedules, now = () => new Date(), externalClock = false, }) {
110
+ const timers = new Map();
111
+ let wakeupTimer;
112
+ let stopped = false;
113
+ /** The resident fire path — the shared claim+run+audit, without a slot (see {@link fireScheduleOnce}). */
74
114
  async function fire(s) {
75
- const fires = loadFires(stateRoot);
76
- fires[s.name] = now().toISOString();
77
- saveFires(stateRoot, fires);
78
- const firedAt = now().toISOString();
79
- const r = await runTurn(s.name, scheduleSession(s.name), s.prompt);
80
- appendRun(stateRoot, {
81
- name: s.name,
82
- session: scheduleSession(s.name),
83
- firedAt,
84
- ms: r.ms,
85
- outcome: r.failed ? "failed" : "completed",
86
- reply: r.failed ? undefined : r.reply,
87
- error: r.failed,
88
- });
115
+ await fireScheduleOnce({ agent, stateRoot, schedule: s, now });
89
116
  }
90
117
  /**
91
118
  * The woken turn's prompt arrives ENVELOPED: without it the model sees its own instruction as a bare
@@ -118,7 +145,10 @@ export function createScheduler({ agent, stateRoot, schedules, now = () => new D
118
145
  break;
119
146
  const label = `wake ${w.id.slice(0, 8)}`;
120
147
  const firedAt = now().toISOString();
121
- const r = await runTurn(label, w.session, wakeEnvelope(w));
148
+ // A wake turn runs in the BACKGROUND (no open request tracks it) — count it as in-flight work
149
+ // (busy.ts) so a serving surface that must not idle mid-turn (the AgentCore /ping) sees it.
150
+ const workDone = beginWork();
151
+ const r = await runTurn(agent, label, w.session, wakeEnvelope(w)).finally(workDone);
122
152
  // Busy handling differs by kind (busy = the turn never started — replay-safe; every other outcome is
123
153
  // terminal for this occurrence, since a turn that DID start may have run side effects). ONE-SHOT: defer (bounded) — it has no "next time", dropping it
124
154
  // would lose it forever. RECURRING: the claim already ADVANCED the entry to the next instant (see
@@ -215,9 +245,12 @@ export function createScheduler({ agent, stateRoot, schedules, now = () => new D
215
245
  return {
216
246
  start() {
217
247
  stopped = false;
218
- const fires = loadFires(stateRoot);
248
+ // External-clock mode: no cron timers, no boot catch-up — slot delivery (including instants
249
+ // that passed while this process was down) belongs to the external clock; only the wake-up
250
+ // pump below runs. See SchedulerOptions.externalClock.
251
+ const fires = externalClock ? {} : loadFires(stateRoot);
219
252
  const current = now();
220
- for (const s of schedules) {
253
+ for (const s of externalClock ? [] : schedules) {
221
254
  // Anchor on the last fire (catch-up basis), or `now` on a first-ever run so a brand-new schedule
222
255
  // never back-fires before the process first booted.
223
256
  const lastFired = fires[s.name];
@@ -3,7 +3,7 @@
3
3
  * files, atomic (tmp+rename) so a crash never leaves a torn file:
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
- * The root state dir already self-ignores (`.fastagent/.gitignore`), so no per-dir .gitignore.
6
+ * No .gitignore is written here: the agent's own (scaffolded by `init`) excludes `.state/`.
7
7
  *
8
8
  * ponytail: this atomic read/write duplicates channels/telegram/state.ts's primitive (both KB-JSON
9
9
  * tmp+rename). Extract a neutral src/state.ts and have both import it when a third consumer appears.
@@ -0,0 +1,47 @@
1
+ import type { Wakeup } from "./wakeups.ts";
2
+ /** The forwarder's reserved wake-alarm path — never forwarded to channel routes. */
3
+ export declare const WAKE_ALARM_PATH = "/__fastagent/wake-alarm";
4
+ /** One desired alarm: mirror of a pending wake-up (id names the EventBridge schedule; at = fireAt). */
5
+ export interface WakeAlarm {
6
+ id: string;
7
+ at: string;
8
+ }
9
+ /** The wire shape the sink POSTs to {@link WAKE_ALARM_PATH} (the forwarder validates `secret`). */
10
+ export interface WakeAlarmRequest {
11
+ secret: string;
12
+ alarms: WakeAlarm[];
13
+ }
14
+ /**
15
+ * Persist the forwarder URL the adapter saw in an envelope (write-if-changed — envelopes arrive on
16
+ * every turn, the file should not churn). Durable under <stateRoot>/schedule/ so a freshly booted
17
+ * container whose FIRST action is a wake fire (recurring advance → save → sink) knows the URL
18
+ * before any envelope of its own has arrived.
19
+ */
20
+ export declare function rememberWakeAlarmUrl(stateRoot: string, url: string): void;
21
+ /** The persisted forwarder URL, or undefined before the first envelope ever seen. */
22
+ export declare function readWakeAlarmUrl(stateRoot: string): string | undefined;
23
+ /** How many times one alarm sync is retried before giving up (each store mutation and every boot
24
+ * restart the cycle, and the pending store is the durable desired state — so "give up" means "until
25
+ * the next mirror", never "lost"). */
26
+ export declare const MAX_SYNC_ATTEMPTS = 5;
27
+ /** Pending wake-ups → the desired alarm set, minus already-due entries (see {@link DUE_MARGIN_MS}). */
28
+ export declare function toAlarms(pending: Wakeup[], now: Date): WakeAlarm[];
29
+ /**
30
+ * Build the wakeups sink for an AgentCore deployment (registered via `setWakeupsSink` by `start`
31
+ * when `FASTAGENT_AGENTCORE=1` + `FASTAGENT_WAKE_SECRET` are present). Fire-and-forget by contract:
32
+ * failures are logged, never thrown — a broken alarm degrades to the pre-alarm behavior (the wake
33
+ * still fires on the next time the box happens to be awake), it must never break the store write.
34
+ */
35
+ export declare function createWakeAlarmSink(options: {
36
+ secret: string;
37
+ fetchImpl?: typeof fetch;
38
+ /** Injectable clock (tests); defaults to the wall clock. */
39
+ now?: () => Date;
40
+ /** Injectable retry pause (tests); defaults to exponential-ish backoff off RETRY_BASE_MS. */
41
+ 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;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Wake ALARMS for the AgentCore deployment: the piece that makes the agent's self-scheduled
3
+ * wake-ups (`wake`) reliable on a host with NO resident process. The mechanism, end to end:
4
+ *
5
+ * wake written → wakeups store save → the SINK here → POST the full pending set to the
6
+ * forwarder's reserved path → the forwarder (which has the AWS SDK + an IAM role) mirrors each
7
+ * pending wake-up into a ONE-SHOT EventBridge schedule (`at(fireAt)`, self-deleting) → at the
8
+ * instant, EventBridge pokes the forwarder → InvokeAgentRuntime wakes the container → the boot /
9
+ * 30s wake pump finds the due entry and fires it (the existing overdue catch-up — no new fire
10
+ * path). A RECURRING wake re-arms itself: its claim advances `fireAt` in the store, which is a
11
+ * save, which re-runs this sink, which re-upserts its alarm for the next occurrence.
12
+ *
13
+ * The container itself never calls AWS (no SDK dependency, no SigV4, no credential chain): it only
14
+ * POSTs to its own deployment's public forwarder URL, authenticated by a shared secret
15
+ * (`FASTAGENT_WAKE_SECRET`, a CloudFormation NoEcho parameter both sides receive). The URL is not
16
+ * baked anywhere — the forwarder INJECTS it into every envelope it forwards (it resolves its own
17
+ * Function URL at cold start), and the adapter persists it here; every wake write happens inside a
18
+ * turn, and every ingress turn arrived through an envelope, so the URL is always known by then.
19
+ *
20
+ * Reconciliation is DECLARATIVE (the full pending set travels each time) and deletion is lazy: a
21
+ * cancelled wake-up's alarm still fires its poke, finds nothing due, and self-deletes — a harmless
22
+ * wasted wake-up of the box, traded for never needing list/delete choreography.
23
+ */
24
+ import { readFileSync } from "node:fs";
25
+ import { beginWork } from "../channels/busy.js";
26
+ import { log } from "../log.js";
27
+ import { scheduleFile, writeScheduleFile } from "./state.js";
28
+ import { listWakeups } from "./wakeups.js";
29
+ /** The forwarder's reserved wake-alarm path — never forwarded to channel routes. */
30
+ export const WAKE_ALARM_PATH = "/__fastagent/wake-alarm";
31
+ const URL_FILE = "wake-alarm-url";
32
+ /**
33
+ * Persist the forwarder URL the adapter saw in an envelope (write-if-changed — envelopes arrive on
34
+ * every turn, the file should not churn). Durable under <stateRoot>/schedule/ so a freshly booted
35
+ * container whose FIRST action is a wake fire (recurring advance → save → sink) knows the URL
36
+ * before any envelope of its own has arrived.
37
+ */
38
+ export function rememberWakeAlarmUrl(stateRoot, url) {
39
+ if (readWakeAlarmUrl(stateRoot) === url)
40
+ return;
41
+ writeScheduleFile(scheduleFile(stateRoot, URL_FILE), { url });
42
+ }
43
+ /** The persisted forwarder URL, or undefined before the first envelope ever seen. */
44
+ export function readWakeAlarmUrl(stateRoot) {
45
+ try {
46
+ const v = JSON.parse(readFileSync(scheduleFile(stateRoot, URL_FILE), "utf8"));
47
+ return typeof v.url === "string" ? v.url : undefined;
48
+ }
49
+ catch {
50
+ return undefined; // absent/corrupt — the next envelope rewrites it
51
+ }
52
+ }
53
+ /** How many times one alarm sync is retried before giving up (each store mutation and every boot
54
+ * restart the cycle, and the pending store is the durable desired state — so "give up" means "until
55
+ * the next mirror", never "lost"). */
56
+ export const MAX_SYNC_ATTEMPTS = 5;
57
+ const RETRY_BASE_MS = 2_000;
58
+ const SYNC_TIMEOUT_MS = 10_000;
59
+ /** Alarms due within this margin are NOT mirrored: the box is awake handling them right now (that is
60
+ * how their fireAt got written/claimed), and a past `at()` would only fail at the Scheduler API —
61
+ * filtering them client-side keeps every forwarder failure a REAL one worth retrying. */
62
+ const DUE_MARGIN_MS = 5_000;
63
+ /** Pending wake-ups → the desired alarm set, minus already-due entries (see {@link DUE_MARGIN_MS}). */
64
+ export function toAlarms(pending, now) {
65
+ return pending
66
+ .filter((w) => Date.parse(w.fireAt) > now.getTime() + DUE_MARGIN_MS)
67
+ .map((w) => ({ id: w.id, at: w.fireAt }));
68
+ }
69
+ /**
70
+ * Build the wakeups sink for an AgentCore deployment (registered via `setWakeupsSink` by `start`
71
+ * when `FASTAGENT_AGENTCORE=1` + `FASTAGENT_WAKE_SECRET` are present). Fire-and-forget by contract:
72
+ * failures are logged, never thrown — a broken alarm degrades to the pre-alarm behavior (the wake
73
+ * still fires on the next time the box happens to be awake), it must never break the store write.
74
+ */
75
+ export function createWakeAlarmSink(options) {
76
+ const { secret, fetchImpl = fetch, now = () => new Date() } = options;
77
+ 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) {
82
+ // Counted as in-flight work: the retry loop is exactly the window where the box must not be
83
+ // reclaimed — idle away mid-retry and a pending wake has no alarm until the next boot.
84
+ const workDone = beginWork();
85
+ 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}`);
99
+ }
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
+ }
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`);
107
+ }
108
+ finally {
109
+ workDone();
110
+ }
111
+ }
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)");
118
+ 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);
126
+ };
127
+ }
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,6 +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
30
  /** The current pending wake-ups (`fastagent schedule list` uses this). */
30
31
  export declare function listWakeups(stateRoot: string): Wakeup[];
31
32
  export type AddWakeupResult = {