@fastagent-sh/fastagent 0.18.0 → 0.19.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 (159) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +19 -0
  5. package/dist/atomic-write.js +48 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-service.d.ts +54 -0
  9. package/dist/channels/agentcore-service.js +111 -0
  10. package/dist/channels/agentcore.d.ts +12 -2
  11. package/dist/channels/agentcore.js +6 -4
  12. package/dist/channels/busy.d.ts +1 -1
  13. package/dist/channels/busy.js +1 -1
  14. package/dist/channels/control.d.ts +16 -6
  15. package/dist/channels/control.js +106 -4
  16. package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
  17. package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
  18. package/dist/channels/feishu/card.js +1 -1
  19. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  20. package/dist/channels/feishu/context-buffer.js +4 -4
  21. package/dist/channels/feishu/feishu-api.js +1 -1
  22. package/dist/channels/feishu/feishu.d.ts +1 -1
  23. package/dist/channels/feishu/feishu.js +7 -7
  24. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  25. package/dist/channels/feishu/invoke-turn.js +3 -4
  26. package/dist/channels/feishu/preview.d.ts +1 -1
  27. package/dist/channels/feishu/preview.js +7 -3
  28. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  29. package/dist/channels/github/github.d.ts +1 -1
  30. package/dist/channels/http.d.ts +11 -7
  31. package/dist/channels/http.js +1 -109
  32. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  33. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  34. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  35. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  36. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  37. package/dist/channels/{state.js → kit/state.js} +4 -7
  38. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  39. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  40. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  41. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  42. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  43. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  44. package/dist/channels/lark/lark.d.ts +1 -1
  45. package/dist/channels/serve.d.ts +73 -0
  46. package/dist/channels/serve.js +243 -0
  47. package/dist/channels/slack/bot-auth.js +3 -14
  48. package/dist/channels/slack/context-buffer.d.ts +2 -2
  49. package/dist/channels/slack/context-buffer.js +2 -2
  50. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  51. package/dist/channels/slack/invoke-turn.js +8 -5
  52. package/dist/channels/slack/parse.js +2 -2
  53. package/dist/channels/slack/preview.d.ts +1 -1
  54. package/dist/channels/slack/preview.js +11 -3
  55. package/dist/channels/slack/slack-api.js +1 -1
  56. package/dist/channels/slack/slack.d.ts +1 -1
  57. package/dist/channels/slack/slack.js +8 -8
  58. package/dist/channels/slack/welcomed.js +1 -1
  59. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  60. package/dist/channels/telegram/context-buffer.js +2 -2
  61. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  62. package/dist/channels/telegram/invoke-turn.js +1 -1
  63. package/dist/channels/telegram/parse.js +2 -2
  64. package/dist/channels/telegram/preview.d.ts +1 -1
  65. package/dist/channels/telegram/preview.js +6 -2
  66. package/dist/channels/telegram/telegram.d.ts +1 -1
  67. package/dist/channels/telegram/telegram.js +3 -3
  68. package/dist/channels/telegram/turn-store.d.ts +2 -2
  69. package/dist/channels/telegram/turn-store.js +2 -2
  70. package/dist/cli/commands/add.js +1 -1
  71. package/dist/cli/commands/dev.js +25 -13
  72. package/dist/cli/commands/fire.js +1 -1
  73. package/dist/cli/commands/info.js +7 -4
  74. package/dist/cli/commands/schedule.js +1 -1
  75. package/dist/cli/commands/start.js +63 -94
  76. package/dist/cli/commands/tool.js +5 -7
  77. package/dist/cli/program.js +1 -1
  78. package/dist/cli/serve.d.ts +18 -57
  79. package/dist/cli/serve.js +57 -243
  80. package/dist/cli/shared.d.ts +1 -1
  81. package/dist/cli/shared.js +2 -2
  82. package/dist/core.d.ts +3 -5
  83. package/dist/core.js +7 -5
  84. package/dist/deploy/preflight.js +1 -1
  85. package/dist/dev-supervisor.js +9 -3
  86. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  87. package/dist/engines/pi/agent-session-factory.js +314 -0
  88. package/dist/engines/pi/config.d.ts +4 -4
  89. package/dist/engines/pi/config.js +1 -1
  90. package/dist/engines/pi/create.d.ts +56 -43
  91. package/dist/engines/pi/create.js +141 -71
  92. package/dist/engines/pi/definition.d.ts +41 -7
  93. package/dist/engines/pi/definition.js +115 -11
  94. package/dist/engines/pi/invoke-session.d.ts +22 -19
  95. package/dist/engines/pi/invoke-session.js +286 -100
  96. package/dist/engines/pi/login.js +7 -4
  97. package/dist/engines/pi/models.d.ts +14 -0
  98. package/dist/engines/pi/models.js +9 -1
  99. package/dist/engines/pi/open.d.ts +9 -6
  100. package/dist/engines/pi/open.js +20 -10
  101. package/dist/engines/pi/report.d.ts +0 -4
  102. package/dist/engines/pi/report.js +0 -7
  103. package/dist/engines/pi/service.d.ts +23 -0
  104. package/dist/engines/pi/service.js +19 -0
  105. package/dist/engines/pi/session-builder.js +84 -88
  106. package/dist/engines/pi/session-control.d.ts +10 -9
  107. package/dist/engines/pi/session-control.js +114 -77
  108. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  109. package/dist/engines/pi/session-inheritance.js +242 -0
  110. package/dist/engines/pi/session-settings.d.ts +14 -2
  111. package/dist/engines/pi/session-settings.js +18 -0
  112. package/dist/engines/pi/session-store.d.ts +70 -0
  113. package/dist/engines/pi/session-store.js +313 -0
  114. package/dist/engines/pi/tool-context.d.ts +18 -8
  115. package/dist/engines/pi/tool-context.js +23 -1
  116. package/dist/engines/pi/tool.d.ts +5 -5
  117. package/dist/engines/pi/tool.js +1 -1
  118. package/dist/engines/pi/turn-kit.d.ts +32 -6
  119. package/dist/engines/pi/turn-kit.js +28 -1
  120. package/dist/engines/pi/wake-tool.js +1 -1
  121. package/dist/index.d.ts +1 -0
  122. package/dist/index.js +1 -0
  123. package/dist/log.d.ts +3 -0
  124. package/dist/log.js +5 -1
  125. package/dist/node.d.ts +16 -0
  126. package/dist/node.js +20 -0
  127. package/dist/paths.d.ts +7 -0
  128. package/dist/paths.js +10 -0
  129. package/dist/pi.d.ts +14 -8
  130. package/dist/pi.js +6 -5
  131. package/dist/scaffold/templates/persona.md +2 -2
  132. package/dist/schedule/state.js +4 -9
  133. package/dist/schedule/wakeups.d.ts +1 -1
  134. package/dist/schedule/wakeups.js +1 -1
  135. package/dist/service.d.ts +158 -0
  136. package/dist/service.js +354 -0
  137. package/dist/session-remote.d.ts +1 -1
  138. package/dist/session.d.ts +4 -2
  139. package/dist/session.js +1 -1
  140. package/package.json +14 -5
  141. package/dist/engines/pi/harness.d.ts +0 -115
  142. package/dist/engines/pi/harness.js +0 -154
  143. package/dist/engines/pi/invoke.d.ts +0 -60
  144. package/dist/engines/pi/invoke.js +0 -459
  145. package/dist/engines/pi/read-image.d.ts +0 -4
  146. package/dist/engines/pi/read-image.js +0 -62
  147. package/dist/engines/pi/sessions.d.ts +0 -60
  148. package/dist/engines/pi/sessions.js +0 -464
  149. package/dist/host/node.js +0 -52
  150. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  151. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  152. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  153. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  154. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  155. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  156. /package/dist/channels/{text.js → kit/text.js} +0 -0
  157. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  158. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  159. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
package/dist/paths.js CHANGED
@@ -330,3 +330,13 @@ export async function assertInsideAgentDir(agentDir, name) {
330
330
  `use a real directory or a symlink that stays within it`);
331
331
  }
332
332
  }
333
+ /**
334
+ * Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
335
+ * this OUT of the agent?" — the startup report's redeploy notes, `add`'s printed `.env` label, and the
336
+ * dev watcher's "your .env is not watched" warning all turn on that fact. Reporting only: fastagent
337
+ * does not act on where a user's paths point.
338
+ */
339
+ export function isUnderDir(targetPath, baseDir) {
340
+ const rel = relative(baseDir, targetPath);
341
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
342
+ }
package/dist/pi.d.ts CHANGED
@@ -1,19 +1,25 @@
1
1
  export { createPiAgent, createPiAgentFromDefinition, type CreatePiAgentFromDefinitionOptions, type CreatePiAgentOptions, } from "./engines/pi/create.ts";
2
- export { defineTool, loadTools, type DefineToolOptions, type FastagentTool, type MountedTool, type ToolCollision, type ToolContext, } from "./engines/pi/tool.ts";
2
+ export { defineTool, type DefineToolOptions, type FastagentTool, type MountedTool, type ToolCollision, type ToolContext, } from "./engines/pi/tool.ts";
3
3
  export type { ReadonlySessionManager, ToolActivation } from "./engines/pi/tool-context.ts";
4
4
  export { z } from "zod";
5
- export type { AgentTool, ExecutionEnv, Session, SessionTreeEntry, Skill, SkillDiagnostic, } from "@earendil-works/pi-agent-core";
6
- export { loadChannels, type ChannelCollision } from "./engines/pi/channel.ts";
5
+ export type { AgentTool, ExecutionEnv, Skill, SkillDiagnostic } from "@earendil-works/pi-agent-core";
6
+ /**
7
+ * A conversation record, as the tool runtime and the control plane hold it. Its entries are pi's own
8
+ * — exported under a qualified name because `SessionEntry` in this package is the NEUTRAL one the
9
+ * control plane publishes (session.ts), and the two are different shapes.
10
+ */
11
+ export type { SessionManager, SessionEntry as PiSessionEntry } from "@earendil-works/pi-coding-agent";
7
12
  export { createPiAgentFromDir, type CreatePiAgentFromDirOptions, } from "./engines/pi/open.ts";
8
13
  export type { LoadedDefinition, SkillCollision } from "./engines/pi/definition.ts";
9
- export { defineConfig, listModels, resolveModel, type FastagentConfig } from "./engines/pi/config.ts";
10
- export type { SessionObserver } from "./engines/pi/invoke.ts";
14
+ export { defineConfig, listModels, resolveModel, type FastagentConfig, } from "./engines/pi/config.ts";
15
+ export type { SessionObserver } from "./engines/pi/turn-kit.ts";
11
16
  export { inProcessLease, type Lease, type Release } from "./engines/pi/turn-kit.ts";
12
17
  export { createPiSessionControl, type CreatePiSessionControlOptions, } from "./engines/pi/session-control.ts";
13
- export type { AnyModel } from "./engines/pi/harness.ts";
14
- export { inMemorySessionStore, jsonlSessionStore, type PiSessionReader, type PiSessionStore, type SessionInheritance, } from "./engines/pi/sessions.ts";
18
+ export { piInMemorySessionRecordStore, piSessionRecordStore, type PiSessionRecordStore, } from "./engines/pi/session-store.ts";
19
+ export type { SessionInheritance } from "./engines/pi/session-inheritance.ts";
15
20
  export { GLOBAL_AUTH_PATH, fastagentCredentialStore, type FastagentAuthOptions } from "./engines/pi/auth.ts";
16
21
  export { createPiModels, probeAuthSource, type CreatePiModelsOptions } from "./engines/pi/models.ts";
17
22
  export type { Models } from "@earendil-works/pi-ai";
18
- export { createProvider, type Provider, type ProviderAuth } from "@earendil-works/pi-ai";
23
+ export type { Provider, ProviderAuth } from "@earendil-works/pi-ai";
19
24
  export type { Model } from "@earendil-works/pi-ai";
25
+ export { createAgentService, type CreateAgentServiceOptions } from "./engines/pi/service.ts";
package/dist/pi.js CHANGED
@@ -1,13 +1,14 @@
1
1
  // The pi reference implementation: assembly, agent discovery, tools, config, models, auth, and state ports.
2
2
  export { createPiAgent, createPiAgentFromDefinition, } from "./engines/pi/create.js";
3
- export { defineTool, loadTools, } from "./engines/pi/tool.js";
3
+ export { defineTool, } from "./engines/pi/tool.js";
4
4
  export { z } from "zod";
5
- export { loadChannels } from "./engines/pi/channel.js";
6
5
  export { createPiAgentFromDir, } from "./engines/pi/open.js";
7
- export { defineConfig, listModels, resolveModel } from "./engines/pi/config.js";
6
+ export { defineConfig, listModels, resolveModel, } from "./engines/pi/config.js";
8
7
  export { inProcessLease } from "./engines/pi/turn-kit.js";
9
8
  export { createPiSessionControl, } from "./engines/pi/session-control.js";
10
- export { inMemorySessionStore, jsonlSessionStore, } from "./engines/pi/sessions.js";
9
+ export { piInMemorySessionRecordStore, piSessionRecordStore, } from "./engines/pi/session-store.js";
11
10
  export { GLOBAL_AUTH_PATH, fastagentCredentialStore } from "./engines/pi/auth.js";
12
11
  export { createPiModels, probeAuthSource } from "./engines/pi/models.js";
13
- export { createProvider } from "@earendil-works/pi-ai";
12
+ // The product's one-call assembly: a directory becomes a live service. On the pi surface because
13
+ // it opens a directory the pi way (channels, schedules, the assembled agent).
14
+ export { createAgentService } from "./engines/pi/service.js";
@@ -2,9 +2,9 @@
2
2
 
3
3
  You are this workspace's agent. This file is your identity — it overrides the engine's default identity line, and it is re-read every turn along with the rest of your definition (`skills/` — capabilities you load when a task calls for them; `tools/` — code tools your author added, in the same directory as this file). An edit to any of them takes effect on your next message, no restart.
4
4
 
5
- Your definition is this directory: `persona.md`, `skills/`, `tools/`, and the config beside them. Your WORKSPACE is the directory you were started in — the project you work on, and where your `read` / `write` / `edit` / `bash` tools operate. It may be this same directory, or the one containing it; `fastagent info` prints both. If the workspace has an `AGENTS.md`, it is project context — read it to learn the project's conventions.
5
+ Your definition is this directory: `persona.md`, `skills/`, `tools/`, and the config beside them. Your WORKSPACE is the directory you were started in — the project you work on. It may be this same directory, or the one containing it; `fastagent info` prints both. Use only the tools actually listed in your system prompt. If the workspace has an `AGENTS.md`, it is project context — follow it without assuming a file tool is available.
6
6
 
7
- You can improve yourself. When a task reveals something durable — a repeatable process, a standing preference, a hard-won fact — write it into your definition instead of losing it:
7
+ When your mounted tools allow it, you can improve yourself. When a task reveals something durable — a repeatable process, a standing preference, a hard-won fact — write it into your definition instead of losing it:
8
8
 
9
9
  - A repeatable process or capability → a new skill beside this file: `skills/<name>/SKILL.md`. Only the `skills/` next to this file is scanned. Read `skills/writing-great-skills/SKILL.md` first; it is the guide to authoring skills well.
10
10
  - A standing instruction or fact → edit this file.
@@ -4,12 +4,10 @@
4
4
  * - `fires.json` — schedule name → last-fired ISO (durability for the cron catch-up-once);
5
5
  * - `wakeups.json` — the agent's pending self-scheduled one-shot wake-ups (wakeups.ts).
6
6
  * No .gitignore is written here: the agent's own (scaffolded by `init`) excludes `.state/`.
7
- *
8
- * ponytail: this atomic read/write duplicates channels/telegram/state.ts's primitive (both KB-JSON
9
- * tmp+rename). Extract a neutral src/state.ts and have both import it when a third consumer appears.
10
7
  */
11
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
- import { dirname, join } from "node:path";
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { writeFileAtomic } from "../atomic-write.js";
13
11
  import { log } from "../log.js";
14
12
  /** Path of a JSON file under `<stateRoot>/schedule/`. */
15
13
  export function scheduleFile(stateRoot, name) {
@@ -42,10 +40,7 @@ export function readScheduleFile(path) {
42
40
  }
43
41
  }
44
42
  export function writeScheduleFile(path, value) {
45
- mkdirSync(dirname(path), { recursive: true });
46
- const tmp = `${path}.tmp`;
47
- writeFileSync(tmp, JSON.stringify(value));
48
- renameSync(tmp, path);
43
+ writeFileAtomic(path, JSON.stringify(value));
49
44
  }
50
45
  export function loadFires(stateRoot) {
51
46
  const v = readScheduleFile(scheduleFile(stateRoot, "fires"));
@@ -73,7 +73,7 @@ export declare function takeFirstDueWakeup(stateRoot: string, now?: Date): Wakeu
73
73
  * already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
74
74
  *
75
75
  * Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
76
- * window (the busy reject yields before any harness IO) where an `unwake` for this id reports "not found"
76
+ * window (the busy reject yields before any engine IO) where an `unwake` for this id reports "not found"
77
77
  * and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
78
78
  * claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
79
79
  */
@@ -201,7 +201,7 @@ export function takeFirstDueWakeup(stateRoot, now = new Date()) {
201
201
  * already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
202
202
  *
203
203
  * Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
204
- * window (the busy reject yields before any harness IO) where an `unwake` for this id reports "not found"
204
+ * window (the busy reject yields before any engine IO) where an `unwake` for this id reports "not found"
205
205
  * and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
206
206
  * claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
207
207
  */
@@ -0,0 +1,158 @@
1
+ import type { Agent } from "./agent.ts";
2
+ import { type LoadedLongConnectionChannel } from "./channels/discover.ts";
3
+ import type { SessionControl } from "./session.ts";
4
+ import type { ChannelHandler, Routes } from "./channel.ts";
5
+ import { type PrefixMount } from "./channels/serve.ts";
6
+ import type { LoadedSchedule } from "./schedule/schedule.ts";
7
+ export interface ServingSurface {
8
+ routes: Routes;
9
+ /** Prefix-owning handlers mounted beside the routes (the session control plane). */
10
+ mounts?: readonly PrefixMount[];
11
+ longConnections: LoadedLongConnectionChannel[];
12
+ /** Route-channel basenames; the tunnel registers only this subset. */
13
+ routeChannels: string[];
14
+ builtinInvoke: boolean;
15
+ /** Marks the built-in health route ready after every long-connection channel first connects. */
16
+ /** Flip health between 200 and 503. Two-way on purpose: a long connection that dies after coming
17
+ * up leaves the surface serving something it no longer has, and a load balancer should hear it. */
18
+ setReady(value: boolean): void;
19
+ }
20
+ /**
21
+ * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
22
+ * POST `/invoke` only when neither a route nor a long-connection channel was declared.
23
+ */
24
+ export declare function routesFor(agentDir: string, agent: Agent, stateRoot: string, control: SessionControl | undefined, options?: {
25
+ builtinInvoke?: boolean;
26
+ }): Promise<ServingSurface>;
27
+ /**
28
+ * Refuse channel routes the control plane would swallow.
29
+ *
30
+ * `router` refuses this too, and would catch it a moment later; this exists for the sentence, not
31
+ * the check — an author who lands a route under `/control` needs to hear about `sessionControl`,
32
+ * which the host has no way to mention. Both ask {@link pathUnderPrefix}, so there is one rule with
33
+ * two wordings, not two rules.
34
+ *
35
+ * Called from BOTH mount points: here at boot, and again on agentcore's lazy path, whose channels
36
+ * load after this ran against an empty base.
37
+ */
38
+ export declare function assertNoControlPlaneCollision(channelRoutes: Routes, plane: PrefixMount): void;
39
+ export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, stateRoot: string, options?: {
40
+ tunnel?: boolean;
41
+ agent?: Agent;
42
+ host?: string;
43
+ }): {
44
+ routes: Routes;
45
+ mounts: PrefixMount[];
46
+ /** The plane's bearer token and prefix — how an embedder distributes access without a discovery file. */
47
+ control?: {
48
+ token: string;
49
+ prefix: string;
50
+ };
51
+ /** Write the local discovery file; returns its removal. Installs no signal handlers. */
52
+ announce: (boundPort: number) => () => void;
53
+ };
54
+ /**
55
+ * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
56
+ * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
57
+ * loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
58
+ * ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
59
+ */
60
+ export declare function startSchedules(agentDir: string, agent: Agent, stateRoot: string, selfSchedule: boolean, options?: {
61
+ externalClock?: boolean;
62
+ }): Promise<{
63
+ schedules: LoadedSchedule[];
64
+ stop: () => void;
65
+ }>;
66
+ export interface AgentService {
67
+ /** The assembled Fetch handler: channel routes, the control plane, and health. Mount it wherever
68
+ * your host speaks `(Request) => Response`; `nodeListener` bridges it to Node's `(req, res)`. */
69
+ handler: ChannelHandler;
70
+ /** The agent behind it — invoke it directly when you also want a programmatic path. */
71
+ agent: Agent;
72
+ /** The literal routes `handler` was composed from — for a startup line naming what is served.
73
+ * Mounted prefixes are not here: nothing outside the assembly needed them, and a field kept for a
74
+ * hypothetical caller is a field nobody maintains. */
75
+ routes: Routes;
76
+ agentDir: string;
77
+ workspace: string;
78
+ /** What actually mounted, for a startup line: channel files serving routes, long connections, and
79
+ * whether the built-in `POST /invoke` fallback is one of the routes. That last one is a FACT of
80
+ * the assembly, not something to re-infer from a path — a channel may legally author
81
+ * `POST /invoke` with a protocol of its own. */
82
+ channels: {
83
+ routes: string[];
84
+ longConnections: string[];
85
+ builtinInvoke: boolean;
86
+ };
87
+ schedules: readonly LoadedSchedule[];
88
+ /** Settles when every long connection is up — immediately when there are none. REJECTS if one
89
+ * fails to come up, after closing the service: a host must not report itself serving while a
90
+ * declared channel is dead, and health answers 503 until this resolves. */
91
+ ready: Promise<void>;
92
+ /** The control plane's bearer token and prefix, when `sessionControl` is on — how an embedder
93
+ * hands access to a client without a discovery file. */
94
+ control?: {
95
+ token: string;
96
+ prefix: string;
97
+ };
98
+ /** Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the plane,
99
+ * once the port is known. Optional: an embedder mounted inside a larger app has no port of its
100
+ * own to describe and uses {@link AgentService.control} instead.
101
+ *
102
+ * Removed by `close()`. Not by an `exit` handler: installing one is a decision about the whole
103
+ * process, which a mounted library does not get to make. A hard exit therefore leaves the file
104
+ * behind — advisory, overwritten by the next boot, and the client's own error stays honest. */
105
+ announce(boundPort: number): void;
106
+ /** Stop long connections and schedules. Idempotent; also runs when `options.signal` aborts. */
107
+ close(): Promise<void>;
108
+ }
109
+ /** What {@link mountAgentService} needs beyond an opened directory. */
110
+ export interface MountAgentServiceOptions {
111
+ /** Wrap the agent before anything consumes it — every consumer (routes, control plane, schedules)
112
+ * must get the SAME one, which is why this is a hook rather than the caller's own call. `dev`
113
+ * passes `logAgentLoop`. */
114
+ wrapAgent?: (agent: Agent) => Agent;
115
+ /** Passed through to the control plane mount: `--tunnel` widens its warning, `host` names the
116
+ * bind address in the discovery file. */
117
+ control?: {
118
+ tunnel?: boolean;
119
+ host?: string;
120
+ };
121
+ /** Aborting this closes the service, exactly like calling {@link AgentService.close}. */
122
+ signal?: AbortSignal;
123
+ /** Called when a long connection ends on its own — a dropped socket-mode channel, say. The CLI
124
+ * exits; an embedded host may prefer to log. Default: log an error. */
125
+ onChannelClosed?: (name: string, error?: unknown) => void;
126
+ /** How long `close()` waits for a channel to stop before reporting it stuck (default 5s). The CLI
127
+ * shortens it so its own forced exit lands after this answer, not before it. */
128
+ closeTimeoutMs?: number;
129
+ }
130
+ /**
131
+ * What the assembly needs from an opened agent directory — the whole of it. Spelled as its own type
132
+ * rather than an engine's return shape: every field here is either the SPEC contract or a path, so
133
+ * an engine that is not pi can satisfy it without either side knowing about the other.
134
+ */
135
+ export interface MountableAgent {
136
+ agent: Agent;
137
+ /** The definition dir: where channels/, tools/ and schedules/ are read from. */
138
+ agentDir: string;
139
+ /** The agent's cwd. */
140
+ workspace: string;
141
+ /** Where durable state lives (channel state, sessions, schedule fires). */
142
+ stateRoot: string;
143
+ /** Present iff this agent published a control plane. */
144
+ sessionControl?: SessionControl;
145
+ /** Whether the agent schedules its own follow-up turns. REQUIRED, not optional-with-a-default:
146
+ * an engine that forgot it would turn self-scheduling off silently, which is exactly the bug
147
+ * this type was introduced with. */
148
+ selfSchedule: boolean;
149
+ }
150
+ /**
151
+ * The assembly itself, over an already-opened directory: channels, the control plane, schedules and
152
+ * long connections, composed into one handler.
153
+ *
154
+ * {@link createAgentService} is this plus opening the directory. `dev`/`start` open separately — their
155
+ * startup report needs the opened values before anything mounts — and then arrive here, so there is
156
+ * one assembly rather than one per caller.
157
+ */
158
+ export declare function mountAgentService(opened: MountableAgent, options?: MountAgentServiceOptions): Promise<AgentService>;
@@ -0,0 +1,354 @@
1
+ /**
2
+ * The product, as one call: an agent directory becomes a live service.
3
+ *
4
+ * That phrase is the promise on the README, and until this existed only the CLI could keep it. The
5
+ * assembly parts live here too — `routesFor`, `mountSessionControl`, `startSchedules` — because a
6
+ * public entry may not reach into `cli/`: that directory decides process-level things (`fail.ts`
7
+ * calls `process.exit`) which a library mounted inside someone's app does not get to decide.
8
+ * Everything else was parts: assemble the agent, discover channels, mount the control plane, start
9
+ * schedules, open long connections, compose a router. An embedder had to know that list and get its
10
+ * order right, and getting it wrong is silent: a plane that 404s while advertising itself, a
11
+ * schedule that never fires.
12
+ *
13
+ * So the assembly lives here, and `dev`/`start` are callers. AgentCore is the one exception, and a
14
+ * substantive one: its channels load lazily after a state-snapshot restore, so it cannot use an
15
+ * assembly that discovers them eagerly (cli/commands/start.ts says so at the branch).
16
+ */
17
+ import { mkdirSync, rmSync } from "node:fs";
18
+ import { writeFileAtomic } from "./atomic-write.js";
19
+ import { join } from "node:path";
20
+ import { classifyBind, clientHost } from "./bind.js";
21
+ import { createControlPlane } from "./channels/control.js";
22
+ import { createInvokeHandler } from "./channels/http.js";
23
+ import { text } from "./channels/respond.js";
24
+ import { parseRouteKey, pathUnderPrefix } from "./channels/serve.js";
25
+ import { loadChannels } from "./channels/discover.js";
26
+ import { loadSchedules } from "./schedule/discover.js";
27
+ import { createScheduler } from "./schedule/scheduler.js";
28
+ import { router } from "./channels/serve.js";
29
+ import { log, reportModuleLoadFailures } from "./log.js";
30
+ /** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
31
+ * abort signal must not hang a caller's teardown — or, during a failed start, keep the original
32
+ * error from arriving. The CLI passes a shorter one: its own forced exit must come AFTER this, or
33
+ * the process leaves at 0 before the failure is known. */
34
+ const CLOSE_DEADLINE_MS = 5_000;
35
+ /** Settle when every connection has closed, or when the deadline passes. Reports the ones that did
36
+ * NOT settle — named individually, so a single stuck channel is not reported as all of them. */
37
+ async function closeWithin(runs, names, deadlineMs) {
38
+ const pending = new Set(runs.map((_, i) => i));
39
+ const failures = [];
40
+ const tracked = runs.map((run, i) => run.closed.then(() => {
41
+ pending.delete(i);
42
+ }, (error) => {
43
+ pending.delete(i);
44
+ failures.push(error);
45
+ }));
46
+ let timer;
47
+ try {
48
+ await Promise.race([
49
+ Promise.all(tracked),
50
+ // NOT unref'd: this timer is the thing being awaited, and an unref'd one lets the loop go
51
+ // idle with nothing left to advance it. Cleared below so a prompt close does not hold the
52
+ // process for the rest of the deadline.
53
+ new Promise((resolve) => {
54
+ timer = setTimeout(resolve, deadlineMs);
55
+ }),
56
+ ]);
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ return { stuck: [...pending].map((i) => names[i] ?? "channel"), failures };
62
+ }
63
+ /**
64
+ * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
65
+ * POST `/invoke` only when neither a route nor a long-connection channel was declared.
66
+ */
67
+ export async function routesFor(agentDir, agent, stateRoot, control, options = {}) {
68
+ const { routes, longConnections, routeChannels, collisions, failures } = await loadChannels(agentDir, {
69
+ agent,
70
+ stateRoot,
71
+ control,
72
+ });
73
+ for (const c of collisions) {
74
+ console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
75
+ }
76
+ reportModuleLoadFailures(failures);
77
+ if (failures.length > 0 || collisions.length > 0) {
78
+ throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
79
+ `fix it, or rename an intentionally disabled file to *.disabled`);
80
+ }
81
+ const builtinInvoke = options.builtinInvoke !== false && Object.keys(routes).length === 0 && longConnections.length === 0;
82
+ const channels = builtinInvoke ? { "POST /invoke": createInvokeHandler(agent) } : routes;
83
+ const healthCovered = Object.keys(channels).some((key) => {
84
+ const entry = parseRouteKey(key);
85
+ return entry.path === "/health" && (entry.method === undefined || entry.method === "GET");
86
+ });
87
+ let ready = longConnections.length === 0;
88
+ const health = () => (ready ? text("ok\n", 200) : text("starting\n", 503));
89
+ return {
90
+ routes: healthCovered ? channels : { "GET /health": health, ...channels },
91
+ longConnections,
92
+ routeChannels,
93
+ builtinInvoke,
94
+ setReady(value) {
95
+ ready = value;
96
+ },
97
+ };
98
+ }
99
+ /**
100
+ * Refuse channel routes the control plane would swallow.
101
+ *
102
+ * `router` refuses this too, and would catch it a moment later; this exists for the sentence, not
103
+ * the check — an author who lands a route under `/control` needs to hear about `sessionControl`,
104
+ * which the host has no way to mention. Both ask {@link pathUnderPrefix}, so there is one rule with
105
+ * two wordings, not two rules.
106
+ *
107
+ * Called from BOTH mount points: here at boot, and again on agentcore's lazy path, whose channels
108
+ * load after this ran against an empty base.
109
+ */
110
+ export function assertNoControlPlaneCollision(channelRoutes, plane) {
111
+ const collisions = Object.keys(channelRoutes).filter((key) => pathUnderPrefix(parseRouteKey(key).path, plane.prefix));
112
+ if (collisions.length > 0) {
113
+ throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control plane — ` +
114
+ `rename the channel route or disable sessionControl in fastagent.config`);
115
+ }
116
+ }
117
+ export function mountSessionControl(routes, control, stateRoot, options = {}) {
118
+ if (!control)
119
+ return { routes, mounts: [], announce: () => () => { } };
120
+ const token = crypto.randomUUID();
121
+ const plane = createControlPlane(control, { token, agent: options.agent });
122
+ assertNoControlPlaneCollision(routes, plane);
123
+ return {
124
+ routes,
125
+ mounts: [plane],
126
+ control: { token, prefix: plane.prefix },
127
+ // Writes the discovery file and hands back its removal. It installs NO signal handlers: a
128
+ // library mounted inside someone's app must not change how that app exits — the CLI wires the
129
+ // returned cleanup into its own shutdown, an embedder into `close()`.
130
+ announce: (boundPort) => {
131
+ mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
132
+ const path = join(stateRoot, "control.json");
133
+ const url = `http://${clientHost(options.host)}:${boundPort}`;
134
+ writeFileAtomic(path, `${JSON.stringify({ url, token })}\n`, 0o600);
135
+ log.info(`[fastagent] session control on /control/* (token in ${path})`);
136
+ // LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths
137
+ // warn loudly, and the LAN path must not be the silent third way past the local trust story.
138
+ // A loopback bind closes exactly that reach, so it earns silence.
139
+ const bind = classifyBind(options.host);
140
+ if (bind !== "loopback") {
141
+ log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
142
+ "/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
143
+ "(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
144
+ }
145
+ if (options.tunnel) {
146
+ // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
147
+ log.warn("[fastagent] --tunnel exposes /control/* (steer/abort/set_model) at the public tunnel URL, " +
148
+ "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
149
+ }
150
+ // Removed on shutdown so a stale file cannot point a client at a dead port: `attach` then
151
+ // fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
152
+ return () => {
153
+ try {
154
+ rmSync(path, { force: true });
155
+ }
156
+ catch {
157
+ /* the file is advisory — shutdown must not fail on it */
158
+ }
159
+ };
160
+ },
161
+ };
162
+ }
163
+ /**
164
+ * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
165
+ * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
166
+ * loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
167
+ * ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
168
+ */
169
+ export async function startSchedules(agentDir, agent, stateRoot, selfSchedule, options = {}) {
170
+ // Thrown, not exited on: this runs inside an embedder's app as well as the CLI, and a library
171
+ // that calls process.exit takes a decision (degrade? retry? stop?) that belongs to its host. The
172
+ // CLI catches at its own boundary.
173
+ const { schedules, failures } = await loadSchedules(agentDir);
174
+ reportModuleLoadFailures(failures);
175
+ if (schedules.length === 0 && !selfSchedule)
176
+ return { schedules, stop: () => { } };
177
+ const scheduler = createScheduler({ agent, stateRoot, schedules, externalClock: options.externalClock });
178
+ scheduler.start();
179
+ if (schedules.length > 0) {
180
+ log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}${options.externalClock ? " (external clock — no resident cron timers)" : ""}`);
181
+ }
182
+ // Returned rather than bound to process signals here: this runs inside an embedder's app as well
183
+ // as the CLI, and a library that installs SIGINT handlers is deciding something that is not its
184
+ // to decide. `runStart`/`runDev` wire it to their own shutdown.
185
+ return { schedules, stop: () => scheduler.stop() };
186
+ }
187
+ /**
188
+ * The assembly itself, over an already-opened directory: channels, the control plane, schedules and
189
+ * long connections, composed into one handler.
190
+ *
191
+ * {@link createAgentService} is this plus opening the directory. `dev`/`start` open separately — their
192
+ * startup report needs the opened values before anything mounts — and then arrive here, so there is
193
+ * one assembly rather than one per caller.
194
+ */
195
+ export async function mountAgentService(opened, options = {}) {
196
+ const { agentDir, workspace, stateRoot, sessionControl } = opened;
197
+ // Wrapped BEFORE anything consumes it: routes, the control plane and schedules must all drive the
198
+ // same agent, so this is a hook rather than something a caller applies afterwards.
199
+ const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
200
+ const closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_DEADLINE_MS;
201
+ const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: true });
202
+ const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
203
+ agent,
204
+ ...(options.control?.tunnel !== undefined ? { tunnel: options.control.tunnel } : {}),
205
+ ...(options.control?.host !== undefined ? { host: options.control.host } : {}),
206
+ });
207
+ // Composed BEFORE anything starts. `router` re-validates what `loadChannels` and the control
208
+ // mount already checked, so on THIS path it should not fail — but "should not" is not an ordering
209
+ // guarantee, and a throw after the scheduler ticks and channels dial would leave both running
210
+ // with no service for the caller to close. Free to order correctly; expensive to discover later.
211
+ const handler = router(withControl.routes, withControl.mounts);
212
+ const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule);
213
+ const abort = new AbortController();
214
+ let unannounce;
215
+ // A connection that drops while others are still dialling must not be undone by their later
216
+ // readiness: the service is missing a declared channel from that moment on, whatever else arrives.
217
+ let dropped = false;
218
+ const onClosed = options.onChannelClosed ??
219
+ ((name, error) => log.error(`[fastagent] long connection ${name} ${error === undefined ? "closed" : `failed: ${String(error)}`}`));
220
+ const runs = [];
221
+ // A FUNCTION declaration, not a const: `close()` detaches this listener, and a rollback can call
222
+ // `close()` before this point is reached — a `const` would be in its temporal dead zone there, so
223
+ // the cleanup would throw a ReferenceError and silently skip everything after it.
224
+ //
225
+ // Detached because a caller that closes services itself while holding one long-lived signal would
226
+ // otherwise accumulate listeners, each pinning a whole service through its closure. The signal
227
+ // path has no caller awaiting the promise, so a failure to stop is reported rather than left as an
228
+ // unhandled rejection — in an embedded library, potentially the host's exit.
229
+ function onAbort() {
230
+ void close().catch((error) => log.error(`[fastagent] service close failed: ${String(error)}`));
231
+ }
232
+ let closing;
233
+ const close = () => {
234
+ // Awaits the connections rather than only signalling them: `close()` promises they are stopped,
235
+ // and a caller tearing down a test or a request-scoped service needs that to be true on return.
236
+ closing ??= (async () => {
237
+ abort.abort();
238
+ scheduled.stop();
239
+ options.signal?.removeEventListener("abort", onAbort);
240
+ unannounce?.(); // a stale discovery file would point a client at a dead port
241
+ // A failure to stop is the caller's to know about — swallowing it would let `close()` report
242
+ // success over a channel still holding on. Bounded, because a channel that ignores its abort
243
+ // signal must not hang the teardown either.
244
+ const { stuck, failures } = await closeWithin(runs, routed.longConnections.map((c) => c.name), closeTimeoutMs);
245
+ if (stuck.length > 0) {
246
+ throw new Error(`long connection(s) did not stop within ${closeTimeoutMs}ms: ${stuck.join(", ")}`);
247
+ }
248
+ if (failures.length > 0) {
249
+ throw failures.length === 1 ? failures[0] : new AggregateError(failures, "long connections failed to close");
250
+ }
251
+ })();
252
+ return closing;
253
+ };
254
+ // Rollback IS close(), plus keeping the original error: a failure to clean up is the aftermath,
255
+ // and replacing the reason the caller needs with it hides the actual cause.
256
+ const rollback = async (error) => {
257
+ await close().catch((closeError) => log.error(`[fastagent] cleanup after a failed start also failed: ${String(closeError)}`));
258
+ throw error;
259
+ };
260
+ // Rolled back on failure: a connection that throws while the ones before it are open, and the
261
+ // scheduler already ticking, would otherwise leave both running behind a rejected open().
262
+ for (const connection of routed.longConnections) {
263
+ let run;
264
+ try {
265
+ run = connection.connect(abort.signal);
266
+ }
267
+ catch (error) {
268
+ return rollback(error);
269
+ }
270
+ if (typeof run?.ready?.then !== "function" || typeof run?.closed?.then !== "function") {
271
+ return rollback(new Error(`${connection.name} connect(signal) must return { ready: Promise, closed: Promise }`));
272
+ }
273
+ void run.closed.then(() => {
274
+ if (abort.signal.aborted)
275
+ return;
276
+ // A channel that dies leaves the service serving something it no longer has.
277
+ dropped = true;
278
+ routed.setReady(false);
279
+ onClosed(connection.name);
280
+ }, (error) => {
281
+ if (abort.signal.aborted)
282
+ return;
283
+ dropped = true;
284
+ routed.setReady(false);
285
+ onClosed(connection.name, error);
286
+ });
287
+ runs.push(run);
288
+ }
289
+ if (options.signal?.aborted)
290
+ await close();
291
+ else
292
+ options.signal?.addEventListener("abort", onAbort, { once: true });
293
+ // Health answers 503 until EVERY long connection is up, so a load balancer does not route into a
294
+ // service whose socket-mode channels are still dialling. An abort before that settles `ready` as
295
+ // cancellation, not readiness — a service being torn down must not report itself healthy.
296
+ const ready = (async () => {
297
+ try {
298
+ await Promise.all(runs.map(async (run, i) => {
299
+ const name = routed.longConnections[i]?.name ?? "channel";
300
+ // Raced against `closed`, because the contract puts a terminal failure THERE: a channel
301
+ // that dies dialling may leave `ready` pending forever, and waiting on it alone hangs
302
+ // startup with no diagnosis.
303
+ await Promise.race([
304
+ run.ready,
305
+ run.closed.then(() => Promise.reject(new Error(`${name} closed before it was ready`)), (error) => Promise.reject(new Error(`${name} failed before it was ready: ${String(error)}`))),
306
+ ]);
307
+ if (!abort.signal.aborted)
308
+ log.info(`[fastagent] long connection ready: ${name}`);
309
+ }));
310
+ }
311
+ catch (error) {
312
+ // A connection that cannot come up is a startup failure, not a degraded service: tear the rest
313
+ // down before rejecting, so nothing is left running behind a caller that saw an error. A
314
+ // cleanup that ALSO fails is logged, never rethrown — it would replace the reason the caller
315
+ // actually needs with the aftermath of it.
316
+ await close().catch((closeError) => log.error(`[fastagent] cleanup after a failed start also failed: ${String(closeError)}`));
317
+ throw error;
318
+ }
319
+ // A `ready` that settles because the service was CLOSED is cancellation, not readiness — the
320
+ // contract lets a connection resolve it on abort. Returning normally would tell a caller its
321
+ // channels are up while the service is shut and health says 503.
322
+ if (abort.signal.aborted)
323
+ throw new Error("service closed before it became ready");
324
+ // A drop DURING startup fails it. `dropped` is only reachable here from the startup window —
325
+ // after this line `ready` has settled — and resolving while health is permanently 503 would
326
+ // hand the caller two contradictory answers about the same surface.
327
+ if (dropped) {
328
+ await close();
329
+ throw new Error("a long connection closed before startup completed");
330
+ }
331
+ routed.setReady(true);
332
+ })();
333
+ // Observed here so a rejection is never unhandled; every caller still sees it through `ready`.
334
+ ready.catch(() => { });
335
+ return {
336
+ handler,
337
+ agent,
338
+ routes: withControl.routes,
339
+ agentDir,
340
+ workspace,
341
+ channels: {
342
+ routes: routed.routeChannels,
343
+ longConnections: routed.longConnections.map((c) => c.name),
344
+ builtinInvoke: routed.builtinInvoke,
345
+ },
346
+ schedules: scheduled.schedules,
347
+ ready,
348
+ ...(withControl.control ? { control: withControl.control } : {}),
349
+ announce: (boundPort) => {
350
+ unannounce = withControl.announce(boundPort);
351
+ },
352
+ close,
353
+ };
354
+ }