@fastagent-sh/fastagent 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/README.md +16 -6
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +258 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +35 -0
  13. package/dist/channels/context-buffer.js +103 -0
  14. package/dist/channels/control.d.ts +28 -0
  15. package/dist/channels/control.js +214 -0
  16. package/dist/channels/feishu/card.js +1 -1
  17. package/dist/channels/feishu/context-buffer.d.ts +15 -13
  18. package/dist/channels/feishu/context-buffer.js +62 -69
  19. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  20. package/dist/channels/feishu/feishu-api.js +24 -3
  21. package/dist/channels/feishu/feishu.d.ts +27 -27
  22. package/dist/channels/feishu/feishu.js +318 -207
  23. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  24. package/dist/channels/feishu/invoke-turn.js +44 -79
  25. package/dist/channels/feishu/model.d.ts +2 -2
  26. package/dist/channels/feishu/normalize.d.ts +2 -1
  27. package/dist/channels/feishu/normalize.js +0 -1
  28. package/dist/channels/feishu/parse.d.ts +22 -8
  29. package/dist/channels/feishu/parse.js +24 -7
  30. package/dist/channels/feishu/preview.d.ts +4 -7
  31. package/dist/channels/feishu/preview.js +27 -142
  32. package/dist/channels/feishu/register-app.d.ts +2 -1
  33. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  34. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  35. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  36. package/dist/channels/feishu/setup-mode.d.ts +37 -0
  37. package/dist/channels/feishu/setup-mode.js +28 -0
  38. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  39. package/dist/channels/feishu/ws-ingress.js +136 -0
  40. package/dist/channels/github/github.js +16 -7
  41. package/dist/channels/http.d.ts +14 -0
  42. package/dist/channels/http.js +36 -3
  43. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  44. package/dist/channels/invoke-turn-kit.js +87 -0
  45. package/dist/channels/lark/lark.d.ts +4 -2
  46. package/dist/channels/lark/lark.js +4 -1
  47. package/dist/channels/lark/onboard.d.ts +8 -4
  48. package/dist/channels/lark/onboard.js +8 -0
  49. package/dist/channels/lark/scaffold/channel.ts +9 -8
  50. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  51. package/dist/channels/preview-kit.d.ts +115 -0
  52. package/dist/channels/preview-kit.js +184 -0
  53. package/dist/channels/seen.d.ts +5 -0
  54. package/dist/channels/seen.js +35 -0
  55. package/dist/channels/slack/bot-auth.d.ts +15 -0
  56. package/dist/channels/slack/bot-auth.js +146 -0
  57. package/dist/channels/slack/config-api.d.ts +60 -0
  58. package/dist/channels/slack/config-api.js +149 -0
  59. package/dist/channels/slack/context-buffer.d.ts +24 -0
  60. package/dist/channels/slack/context-buffer.js +37 -0
  61. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  62. package/dist/channels/slack/invoke-turn.js +63 -0
  63. package/dist/channels/slack/manifest.d.ts +49 -0
  64. package/dist/channels/slack/manifest.js +69 -0
  65. package/dist/channels/slack/model.d.ts +67 -0
  66. package/dist/channels/slack/model.js +2 -0
  67. package/dist/channels/slack/onboard.d.ts +41 -0
  68. package/dist/channels/slack/onboard.js +120 -0
  69. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  70. package/dist/channels/slack/onboarding-state.js +69 -0
  71. package/dist/channels/slack/parse.d.ts +38 -0
  72. package/dist/channels/slack/parse.js +124 -0
  73. package/dist/channels/slack/preview.d.ts +23 -0
  74. package/dist/channels/slack/preview.js +403 -0
  75. package/dist/channels/slack/reaction.d.ts +24 -0
  76. package/dist/channels/slack/reaction.js +62 -0
  77. package/dist/channels/slack/register-webhook.d.ts +10 -0
  78. package/dist/channels/slack/register-webhook.js +49 -0
  79. package/dist/channels/slack/scaffold/channel.ts +33 -0
  80. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  81. package/dist/channels/slack/setup-server.d.ts +17 -0
  82. package/dist/channels/slack/setup-server.js +103 -0
  83. package/dist/channels/slack/slack-api.d.ts +57 -0
  84. package/dist/channels/slack/slack-api.js +399 -0
  85. package/dist/channels/slack/slack.d.ts +51 -0
  86. package/dist/channels/slack/slack.js +496 -0
  87. package/dist/channels/slack/welcomed.d.ts +5 -0
  88. package/dist/channels/slack/welcomed.js +32 -0
  89. package/dist/channels/state.d.ts +11 -4
  90. package/dist/channels/state.js +22 -15
  91. package/dist/channels/stop-command.d.ts +6 -0
  92. package/dist/channels/stop-command.js +36 -0
  93. package/dist/channels/tasks.d.ts +7 -0
  94. package/dist/channels/tasks.js +25 -0
  95. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  96. package/dist/channels/telegram/context-buffer.js +6 -85
  97. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  98. package/dist/channels/telegram/invoke-turn.js +11 -58
  99. package/dist/channels/telegram/parse.d.ts +0 -7
  100. package/dist/channels/telegram/parse.js +4 -2
  101. package/dist/channels/telegram/preview.d.ts +4 -7
  102. package/dist/channels/telegram/preview.js +24 -142
  103. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  104. package/dist/channels/telegram/telegram.js +24 -10
  105. package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
  106. package/dist/channels/{feishu/text.js → text.js} +15 -1
  107. package/dist/channels/thread-participants.d.ts +21 -0
  108. package/dist/channels/thread-participants.js +132 -0
  109. package/dist/channels/turn-queue.js +8 -1
  110. package/dist/channels/turn-store.d.ts +1 -1
  111. package/dist/channels/turn-store.js +2 -3
  112. package/dist/cli/add-feishu.d.ts +30 -0
  113. package/dist/cli/add-feishu.js +366 -0
  114. package/dist/cli/add-slack.d.ts +11 -0
  115. package/dist/cli/add-slack.js +199 -0
  116. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  117. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  118. package/dist/cli/commands/add.d.ts +4 -0
  119. package/dist/cli/commands/add.js +191 -60
  120. package/dist/cli/commands/attach.d.ts +82 -0
  121. package/dist/cli/commands/attach.js +563 -0
  122. package/dist/cli/commands/chat.d.ts +1 -0
  123. package/dist/cli/commands/chat.js +12 -7
  124. package/dist/cli/commands/deploy.d.ts +3 -1
  125. package/dist/cli/commands/deploy.js +384 -82
  126. package/dist/cli/commands/dev.js +34 -26
  127. package/dist/cli/commands/fire.js +17 -18
  128. package/dist/cli/commands/info.js +36 -29
  129. package/dist/cli/commands/init.d.ts +1 -1
  130. package/dist/cli/commands/init.js +65 -53
  131. package/dist/cli/commands/invoke.js +11 -9
  132. package/dist/cli/commands/login.js +48 -45
  133. package/dist/cli/commands/models.js +1 -1
  134. package/dist/cli/commands/schedule.js +6 -8
  135. package/dist/cli/commands/start.js +102 -39
  136. package/dist/cli/commands/tool.js +23 -18
  137. package/dist/cli/fail.d.ts +17 -2
  138. package/dist/cli/fail.js +25 -1
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +3 -2
  142. package/dist/cli/models-view.d.ts +21 -0
  143. package/dist/cli/models-view.js +66 -0
  144. package/dist/cli/program.js +135 -56
  145. package/dist/cli/serve.d.ts +60 -20
  146. package/dist/cli/serve.js +252 -47
  147. package/dist/cli/shared.d.ts +40 -7
  148. package/dist/cli/shared.js +180 -38
  149. package/dist/collect.d.ts +14 -3
  150. package/dist/collect.js +24 -0
  151. package/dist/core.d.ts +3 -1
  152. package/dist/core.js +2 -0
  153. package/dist/deploy/agentcore/plan.d.ts +117 -0
  154. package/dist/deploy/agentcore/plan.js +721 -0
  155. package/dist/deploy/agentcore/run.d.ts +73 -0
  156. package/dist/deploy/agentcore/run.js +412 -0
  157. package/dist/deploy/agentcore/zip.d.ts +17 -0
  158. package/dist/deploy/agentcore/zip.js +68 -0
  159. package/dist/deploy/container.d.ts +26 -25
  160. package/dist/deploy/container.js +93 -89
  161. package/dist/deploy/docker/plan.d.ts +3 -3
  162. package/dist/deploy/docker/plan.js +20 -23
  163. package/dist/deploy/fly/plan.d.ts +5 -1
  164. package/dist/deploy/fly/plan.js +39 -26
  165. package/dist/deploy/fly/run.d.ts +17 -5
  166. package/dist/deploy/fly/run.js +52 -7
  167. package/dist/deploy/preflight.d.ts +20 -8
  168. package/dist/deploy/preflight.js +267 -71
  169. package/dist/deploy/railway/plan.d.ts +10 -1
  170. package/dist/deploy/railway/plan.js +53 -20
  171. package/dist/deploy/railway/run.d.ts +13 -5
  172. package/dist/deploy/railway/run.js +22 -7
  173. package/dist/deploy/runner.d.ts +7 -3
  174. package/dist/deploy/runner.js +9 -3
  175. package/dist/deploy/secrets.d.ts +2 -1
  176. package/dist/deploy/secrets.js +23 -3
  177. package/dist/dev-supervisor.d.ts +11 -10
  178. package/dist/dev-supervisor.js +56 -50
  179. package/dist/engines/pi/auth.d.ts +8 -7
  180. package/dist/engines/pi/auth.js +12 -10
  181. package/dist/engines/pi/channel.d.ts +22 -16
  182. package/dist/engines/pi/channel.js +92 -62
  183. package/dist/engines/pi/chat.d.ts +4 -16
  184. package/dist/engines/pi/chat.js +8 -261
  185. package/dist/engines/pi/config.d.ts +20 -55
  186. package/dist/engines/pi/config.js +49 -121
  187. package/dist/engines/pi/create.d.ts +30 -10
  188. package/dist/engines/pi/create.js +33 -26
  189. package/dist/engines/pi/definition.d.ts +7 -26
  190. package/dist/engines/pi/definition.js +8 -54
  191. package/dist/engines/pi/harness.d.ts +49 -0
  192. package/dist/engines/pi/harness.js +90 -2
  193. package/dist/engines/pi/invoke.d.ts +55 -2
  194. package/dist/engines/pi/invoke.js +304 -19
  195. package/dist/engines/pi/login.d.ts +12 -1
  196. package/dist/engines/pi/login.js +16 -4
  197. package/dist/engines/pi/models.d.ts +59 -13
  198. package/dist/engines/pi/models.js +62 -24
  199. package/dist/engines/pi/open.d.ts +114 -0
  200. package/dist/engines/pi/open.js +127 -0
  201. package/dist/engines/pi/session-builder.d.ts +16 -0
  202. package/dist/engines/pi/session-builder.js +308 -0
  203. package/dist/engines/pi/session-control.d.ts +50 -0
  204. package/dist/engines/pi/session-control.js +604 -0
  205. package/dist/engines/pi/sessions.d.ts +17 -2
  206. package/dist/engines/pi/sessions.js +9 -0
  207. package/dist/engines/pi/tool-context.d.ts +18 -11
  208. package/dist/engines/pi/tool-context.js +3 -9
  209. package/dist/engines/pi/tool.d.ts +6 -8
  210. package/dist/engines/pi/tool.js +10 -1
  211. package/dist/engines/pi/wake-tool.d.ts +0 -3
  212. package/dist/engines/pi/wake-tool.js +9 -7
  213. package/dist/env.d.ts +16 -4
  214. package/dist/env.js +43 -5
  215. package/dist/feishu.d.ts +1 -1
  216. package/dist/feishu.js +1 -1
  217. package/dist/host/node.d.ts +25 -8
  218. package/dist/host/node.js +5 -4
  219. package/dist/index.d.ts +1 -0
  220. package/dist/index.js +1 -0
  221. package/dist/lark.d.ts +1 -1
  222. package/dist/lark.js +1 -1
  223. package/dist/loader.d.ts +2 -2
  224. package/dist/loader.js +3 -3
  225. package/dist/log.d.ts +1 -1
  226. package/dist/log.js +1 -1
  227. package/dist/observe.js +3 -0
  228. package/dist/paths.d.ts +138 -0
  229. package/dist/paths.js +326 -0
  230. package/dist/pi.d.ts +6 -5
  231. package/dist/pi.js +4 -3
  232. package/dist/runtime.d.ts +7 -5
  233. package/dist/runtime.js +2 -2
  234. package/dist/scaffold/add-channel.d.ts +26 -9
  235. package/dist/scaffold/add-channel.js +175 -40
  236. package/dist/scaffold/init.d.ts +32 -41
  237. package/dist/scaffold/init.js +161 -185
  238. package/dist/scaffold/templates/env.example +15 -6
  239. package/dist/scaffold/templates/fastagent.config.mjs +5 -4
  240. package/dist/scaffold/templates/gitignore +14 -6
  241. package/dist/scaffold/templates/persona.md +4 -2
  242. package/dist/scaffold/templates/secrets.gitignore +5 -0
  243. package/dist/scaffold/templates.d.ts +1 -7
  244. package/dist/scaffold/templates.js +3 -25
  245. package/dist/scaffold/vendor-skill.d.ts +2 -2
  246. package/dist/scaffold/vendor-skill.js +13 -13
  247. package/dist/schedule/discover.js +4 -4
  248. package/dist/schedule/scheduler.d.ts +40 -1
  249. package/dist/schedule/scheduler.js +89 -56
  250. package/dist/schedule/state.js +1 -1
  251. package/dist/schedule/wake-alarm.d.ts +47 -0
  252. package/dist/schedule/wake-alarm.js +136 -0
  253. package/dist/schedule/wakeups.d.ts +1 -3
  254. package/dist/schedule/wakeups.js +19 -1
  255. package/dist/session-remote.d.ts +53 -0
  256. package/dist/session-remote.js +336 -0
  257. package/dist/session.d.ts +265 -0
  258. package/dist/session.js +37 -0
  259. package/dist/slack.d.ts +2 -0
  260. package/dist/slack.js +2 -0
  261. package/dist/tunnel.d.ts +7 -4
  262. package/dist/tunnel.js +23 -12
  263. package/package.json +22 -7
  264. package/dist/channels/feishu/owned-threads.d.ts +0 -11
  265. package/dist/channels/feishu/owned-threads.js +0 -47
  266. package/dist/channels/feishu/seen.d.ts +0 -5
  267. package/dist/channels/feishu/seen.js +0 -47
  268. package/dist/cli-add-feishu.d.ts +0 -8
  269. package/dist/cli-add-feishu.js +0 -223
  270. package/dist/cli-models.d.ts +0 -11
  271. package/dist/cli-models.js +0 -20
  272. package/dist/engines/pi/workspace.d.ts +0 -56
  273. package/dist/engines/pi/workspace.js +0 -72
  274. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  275. package/dist/scaffold/templates/gitignore.kit +0 -2
  276. package/dist/workspace.d.ts +0 -9
  277. package/dist/workspace.js +0 -45
@@ -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,9 +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
- /** The minimum gap between two consecutive fires of a RECURRING wake — stricter than the one-shot floor:
30
- * a recurring runs forever, so a tight cron is a permanent token burner, not a one-time mistake. */
31
- export declare const MIN_RECURRING_GAP_MS: number;
29
+ export declare function setWakeupsSink(sink: ((stateRoot: string, pending: Wakeup[]) => void) | undefined): void;
32
30
  /** The current pending wake-ups (`fastagent schedule list` uses this). */
33
31
  export declare function listWakeups(stateRoot: string): Wakeup[];
34
32
  export type AddWakeupResult = {
@@ -26,7 +26,7 @@ export const MAX_PENDING_WAKEUPS = 20;
26
26
  export const MAX_WAKE_ATTEMPTS = 120;
27
27
  /** The minimum gap between two consecutive fires of a RECURRING wake — stricter than the one-shot floor:
28
28
  * a recurring runs forever, so a tight cron is a permanent token burner, not a one-time mistake. */
29
- export const MIN_RECURRING_GAP_MS = 10 * 60_000; // 10 minutes
29
+ const MIN_RECURRING_GAP_MS = 10 * 60_000; // 10 minutes
30
30
  /** A stored entry is a real Wakeup: the fields are present and `fireAt` is a parseable date. A malformed
31
31
  * one (bad/missing fireAt) would compare NaN <= now = false forever — never due, never cleared, but still
32
32
  * eating the pending quota. So validate at this IO boundary and drop it (warn), like a corrupt file. */
@@ -47,6 +47,18 @@ function isWakeup(e) {
47
47
  (w.cron === undefined ||
48
48
  (typeof w.cron === "string" && cronError(w.cron, typeof w.tz === "string" ? w.tz : undefined) === undefined)));
49
49
  }
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.
57
+ */
58
+ let wakeupsSink;
59
+ export function setWakeupsSink(sink) {
60
+ wakeupsSink = sink;
61
+ }
50
62
  function load(stateRoot) {
51
63
  const v = readScheduleFile(scheduleFile(stateRoot, "wakeups"));
52
64
  if (v === undefined)
@@ -68,6 +80,12 @@ function load(stateRoot) {
68
80
  }
69
81
  function save(stateRoot, wakeups) {
70
82
  writeScheduleFile(scheduleFile(stateRoot, "wakeups"), wakeups);
83
+ try {
84
+ wakeupsSink?.(stateRoot, wakeups);
85
+ }
86
+ catch (e) {
87
+ log.error(`[schedule] wake-alarm sink failed (store write is unaffected): ${String(e)}`);
88
+ }
71
89
  }
72
90
  /** The current pending wake-ups (`fastagent schedule list` uses this). */
73
91
  export function listWakeups(stateRoot) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The remote `SessionControl` — the client half of the Phase 3 transport (design §13). Engine- and
3
+ * server-neutral: speaks only the wire protocol `controlRoutes` serves (HTTP JSON + SSE with the
4
+ * {sessionId, epoch, seq, event} envelope) and re-exposes the SAME `SessionControl` interface, so
5
+ * local and remote consumers are isomorphic — client code does not change when the agent moves out
6
+ * of process.
7
+ *
8
+ * Envelope consumption is internal: a seq gap (loss in transit on this connection) — and any
9
+ * mid-stream transport failure, a server restart included (its connections drop) — THROWS from
10
+ * the events iterator, so the consumer's failure handling and budget own it; only the consumer's
11
+ * own detach reads as a clean end. Recovery is the standard reconnect steps (`entries({ since })`
12
+ * → `state()` → resubscribe), exactly as after any disconnect. The envelope's `epoch` is
13
+ * informational for consumers that correlate ACROSS connections — within one connection it cannot
14
+ * change, so this client does not compare it. Nothing here retries silently: a broken stream is
15
+ * visible as a thrown iteration error, a failed request as a rejected promise.
16
+ */
17
+ import type { Agent } from "./agent.ts";
18
+ import type { SessionControl } from "./session.ts";
19
+ /** A control request the server answered with a non-2xx status. Carries the STRUCTURED status so a
20
+ * consumer distinguishing auth failure (401 — stale token, unrecoverable) from transient transport
21
+ * trouble branches on `status`, never on message prose. */
22
+ export declare class ControlRequestError extends Error {
23
+ readonly status: number;
24
+ constructor(status: number, body: string);
25
+ }
26
+ /** Connection parameters shared by BOTH remote planes (`connectSessionControl` and
27
+ * `connectAgent`) — plane-neutral on purpose: one endpoint, one token, two contracts. */
28
+ export interface RemoteEndpointOptions {
29
+ /** Base URL of the serving process (e.g. `http://127.0.0.1:8787`); `/control/*` is appended. */
30
+ url: string;
31
+ /** The shared bearer secret (`<stateRoot>/control.json` on the serving machine). */
32
+ token: string;
33
+ /** Injectable for tests. Defaults to global fetch. */
34
+ fetchFn?: typeof fetch;
35
+ }
36
+ /**
37
+ * Connect and return a remote `SessionControl`. Async because `capabilities()` is synchronous in
38
+ * the contract: the static declaration is fetched ONCE here and served from memory — which also
39
+ * makes a wrong URL/token fail at connect time, not on first use.
40
+ */
41
+ export declare function connectSessionControl(options: RemoteEndpointOptions): Promise<SessionControl>;
42
+ /**
43
+ * The remote DATA plane: an `Agent` whose `invoke` drives `POST /control/invoke` on a serving
44
+ * process — paired with {@link connectSessionControl}, a client holds a full remote fastagent
45
+ * instance through the same two contracts local code uses. A REAL Agent, failure discipline
46
+ * included: SPEC MUST 2 forbids iteration throws, so every failure — transport (401/refused/
47
+ * dropped mid-stream), protocol, and the images precheck — becomes a terminal `failed` event
48
+ * (`retryable` from the HTTP status where one exists; network trouble is retryable). Breaking out
49
+ * of iteration disconnects the request, which cancels the run (SPEC cancellation semantics travel
50
+ * the wire). The invoke wire is text-only for now: a prompt with images fails visibly instead of
51
+ * silently dropping them (steer/follow_up on the control plane carry full Prompts).
52
+ */
53
+ export declare function connectAgent(options: RemoteEndpointOptions): Agent;