@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/cli/serve.js CHANGED
@@ -1,61 +1,17 @@
1
1
  /**
2
- * The serving spine shared by `dev` (its worker) and `start`: channel assembly, Node HTTP binding,
3
- * long-connection lifecycle, scheduler lifecycle, and the optional Cloudflare quick tunnel.
2
+ * What `dev` (its worker) and `start` need beyond the service itself: binding a port, the shutdown
3
+ * order, the startup report, and the optional Cloudflare quick tunnel.
4
+ *
5
+ * The ASSEMBLY is not here — it lives in `src/service.ts`, which a public entry may import and this
6
+ * directory may not be (it decides process-level things: `fail.ts` calls `process.exit`).
4
7
  */
5
- import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { createStateSync } from "../channels/agentcore-state.js";
8
- import { agentcoreRoutes, UnknownScheduleError } from "../channels/agentcore.js";
9
- import { activeWork } from "../channels/busy.js";
10
- import { controlRoutes } from "../channels/control.js";
11
- import { INVOKE_EXAMPLE_BODY, createInvokeHandler } from "../channels/http.js";
12
- import { text } from "../channels/respond.js";
13
- import { loadChannels } from "../engines/pi/channel.js";
14
- import { reportModuleLoadFailures } from "../engines/pi/report.js";
8
+ import { INVOKE_EXAMPLE_BODY } from "../channels/http.js";
15
9
  import { answersLocalhost, bindLabel, classifyBind, clientHost } from "../bind.js";
16
- import { parseRouteKey, router, serveNode } from "../host/node.js";
10
+ import { serveNode } from "../channels/serve.js";
17
11
  import { log } from "../log.js";
18
12
  import { openExternalUrl } from "../open-url.js";
19
- import { loadSchedules } from "../schedule/discover.js";
20
- import { createScheduler, fireScheduleOnce } from "../schedule/scheduler.js";
21
13
  import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
22
14
  import { failStartup, failUsage } from "./fail.js";
23
- /**
24
- * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
25
- * POST `/invoke` only when neither a route nor a long-connection channel was declared.
26
- */
27
- export async function routesFor(agentDir, agent, stateRoot, control, options = {}) {
28
- const { routes, longConnections, routeChannels, collisions, failures } = await loadChannels(agentDir, {
29
- agent,
30
- stateRoot,
31
- control,
32
- });
33
- for (const c of collisions) {
34
- console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
35
- }
36
- reportModuleLoadFailures(failures);
37
- if (failures.length > 0 || collisions.length > 0) {
38
- throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
39
- `fix it, or rename an intentionally disabled file to *.disabled`);
40
- }
41
- const builtinInvoke = options.builtinInvoke !== false && Object.keys(routes).length === 0 && longConnections.length === 0;
42
- const channels = builtinInvoke ? { "POST /invoke": createInvokeHandler(agent) } : routes;
43
- const healthCovered = Object.keys(channels).some((key) => {
44
- const entry = parseRouteKey(key);
45
- return entry.path === "/health" && (entry.method === undefined || entry.method === "GET");
46
- });
47
- let ready = longConnections.length === 0;
48
- const health = () => (ready ? text("ok\n", 200) : text("starting\n", 503));
49
- return {
50
- routes: healthCovered ? channels : { "GET /health": health, ...channels },
51
- longConnections,
52
- routeChannels,
53
- builtinInvoke,
54
- markReady() {
55
- ready = true;
56
- },
57
- };
58
- }
59
15
  /**
60
16
  * Mount the session control plane (`/control/*`) when the agent enabled it
61
17
  * (`config.sessionControl`): merge the bearer-authenticated routes and return an announcer that
@@ -66,123 +22,6 @@ export async function routesFor(agentDir, agent, stateRoot, control, options = {
66
22
  * (routesFor): `sessionControl` is an explicit opt-in, so declaring both is a configuration error,
67
23
  * and silently shadowing either side would serve a surface the author didn't write.
68
24
  */
69
- export function mountSessionControl(routes, control, stateRoot, options = {}) {
70
- if (!control)
71
- return { routes, announce: () => { } };
72
- const token = crypto.randomUUID();
73
- const mounted = controlRoutes(control, { token, agent: options.agent });
74
- // PATH-level collision, matching the router's semantics (an any-method "/control/dispatch"
75
- // channel key would dodge an exact-key check yet still shadow the method-qualified control
76
- // route at match time — the router matches by path first).
77
- const mountedPaths = new Set(Object.keys(mounted).map((key) => parseRouteKey(key).path));
78
- const collisions = Object.keys(routes).filter((key) => mountedPaths.has(parseRouteKey(key).path));
79
- if (collisions.length > 0) {
80
- throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control plane — ` +
81
- `rename the channel route or disable sessionControl in fastagent.config`);
82
- }
83
- return {
84
- routes: { ...routes, ...mounted },
85
- announce: (boundPort) => {
86
- // The state root normally exists (the opener mkdirs the sessions dir under it), but an
87
- // external --sessions-dir leaves it uncreated — and announce runs inside serve's listening
88
- // callback, where a throw is an unhandled rejection, not a one-line startup diagnostic.
89
- mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
90
- const path = join(stateRoot, "control.json");
91
- // Atomic (tmp+rename, the state.ts pattern): attach re-reads this file exactly during the
92
- // restart window — a torn read would be misdiagnosed as "serve gone".
93
- const tmp = `${path}.tmp`;
94
- writeFileSync(tmp, `${JSON.stringify({ url: `http://${clientHost(options.host)}:${boundPort}`, token })}\n`, {
95
- mode: 0o600,
96
- });
97
- chmodSync(tmp, 0o600); // an existing file keeps its old mode on rewrite — pin it
98
- renameSync(tmp, path);
99
- log.info(`[fastagent] session control on /control/* (token in ${path})`);
100
- // The serve binds ALL interfaces by DEFAULT (containers require it), so /control/* is
101
- // LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths warn
102
- // loudly, and the LAN path must not be the silent third way past the local trust story. A
103
- // loopback bind closes exactly that reach, so it earns silence.
104
- const bind = classifyBind(options.host);
105
- if (bind !== "loopback") {
106
- log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
107
- "/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
108
- "(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
109
- }
110
- if (options.tunnel) {
111
- // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC
112
- // (beyond even the LAN reach the mount already warned about). The operator asked for the tunnel (webhooks), but must not DISCOVER the control
113
- // plane went public with it — say it loudly.
114
- log.warn("[fastagent] --tunnel exposes /control/* (steer/abort/set_model) at the public tunnel URL, " +
115
- "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
116
- }
117
- // Best-effort lifecycle end: a clean exit removes the discovery file so a later `attach`
118
- // fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
119
- const unlink = () => {
120
- try {
121
- rmSync(path, { force: true });
122
- }
123
- catch {
124
- /* the file is advisory — exit must not fail on it */
125
- }
126
- };
127
- // Signal handlers MUST NOT absorb termination: registering any listener disables Node's
128
- // default kill, so clean up and RE-RAISE. The mechanism: `process.kill` delivery is ASYNC —
129
- // it lands after the current emit completes, so every listener of this same emit (scheduler
130
- // stop, tunnel close — regardless of registration order) runs first, and the re-raised
131
- // signal then hits the default action because each `once` handler is already consumed. When
132
- // some listener exits the process itself (the tunnel path calls process.exit(0)), the
133
- // re-raise is harmless redundancy. Without this, the first Ctrl+C would leave the serve
134
- // alive minus its control.json, and dev's watch restart (SIGTERM → wait for exit → respawn)
135
- // would hang on a worker that never exits.
136
- const unlinkAndReraise = (signal) => () => {
137
- unlink();
138
- process.kill(process.pid, signal);
139
- };
140
- process.once("SIGINT", unlinkAndReraise("SIGINT"));
141
- process.once("SIGTERM", unlinkAndReraise("SIGTERM"));
142
- process.once("exit", unlink);
143
- },
144
- };
145
- }
146
- /**
147
- * Mount the AgentCore Runtime adapter (`POST /invocations` + `GET /ping`) over the serving routes —
148
- * the deployed container's ONLY reachable surface (channels/agentcore.ts). Wired by `start` when
149
- * `FASTAGENT_AGENTCORE=1` (set by the generated deploy artifacts, never by hand). A channel colliding
150
- * on either path fails startup, same disposition as the control-plane mount: the adapter's paths are
151
- * the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
152
- */
153
- export function mountAgentcore(routes, options) {
154
- const { agent, stateRoot, schedules, onStateReady, lazyChannels } = options;
155
- const mounted = agentcoreRoutes({
156
- routes: lazyChannels ?? routes,
157
- agent,
158
- stateRoot,
159
- isBusy: () => activeWork() > 0,
160
- // Cross-deploy durability: AgentCore wipes the state mount on every runtime version update, so
161
- // the state root is restored from (and pushed to) an S3 snapshot through presigned URLs the
162
- // forwarder mints per envelope. Always wired on this path — the platform gives no other way to
163
- // keep an agent's memory across a deploy.
164
- stateSync: createStateSync({ stateRoot }),
165
- // What separates a forwarder envelope from any IAM principal's InvokeAgentRuntime call. Absent =
166
- // no forwarder in this topology, so only the public `invoke` kind is servable.
167
- ingressSecret: process.env.FASTAGENT_INGRESS_SECRET,
168
- onStateReady,
169
- fire: schedules.length === 0
170
- ? undefined
171
- : (name, slot) => {
172
- const schedule = schedules.find((s) => s.name === name);
173
- if (!schedule)
174
- throw new UnknownScheduleError(name);
175
- return fireScheduleOnce({ agent, stateRoot, schedule, slot });
176
- },
177
- });
178
- const mountedPaths = new Set(Object.keys(mounted).map((key) => parseRouteKey(key).path));
179
- const collisions = Object.keys(routes).filter((key) => mountedPaths.has(parseRouteKey(key).path));
180
- if (collisions.length > 0) {
181
- throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the AgentCore adapter ` +
182
- `(/invocations, /ping) — rename the channel route`);
183
- }
184
- return { ...routes, ...mounted };
185
- }
186
25
  /**
187
26
  * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
188
27
  * (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
@@ -214,6 +53,20 @@ export function assertTunnelBindable(host, tunnel, source) {
214
53
  * A wildcard bind is every interface, and naming one address there would understate it — but the curl
215
54
  * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
216
55
  */
56
+ /**
57
+ * The "we are serving" report: the supervisor message `dev`'s watcher waits for, the addresses, and
58
+ * what mounted. One function because both commands must say the same thing at the same moment —
59
+ * after readiness, never at socket bind.
60
+ */
61
+ export function reportServing(service, host, boundPort) {
62
+ process.send?.({ type: "ready", port: boundPort, routeChannels: service.channels.routes });
63
+ for (const line of readyAddressLines(host, boundPort, service.channels.builtinInvoke))
64
+ log.info(line);
65
+ log.info(`[fastagent] routes: ${Object.keys(service.routes).join(", ") || "(none)"}`);
66
+ if (service.channels.longConnections.length > 0) {
67
+ log.info(`[fastagent] long connections: ${service.channels.longConnections.join(", ")}`);
68
+ }
69
+ }
217
70
  export function readyAddressLines(host, boundPort, builtinInvoke) {
218
71
  const dial = `${clientHost(host)}:${boundPort}`;
219
72
  const lines = [
@@ -229,76 +82,58 @@ export function readyAddressLines(host, boundPort, builtinInvoke) {
229
82
  * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
230
83
  * the sole clean-shutdown command. `host` unset binds all interfaces.
231
84
  */
232
- export function serve(surface, bind, onListening) {
85
+ /** What the CLI gives a service to stop in, and the hard exit that follows it. The order matters:
86
+ * a forced exit before the service answers would report a clean shutdown over a stuck channel. */
87
+ export const SHUTDOWN_GRACE_MS = 800;
88
+ const FORCED_EXIT_MS = 1_500;
89
+ export function serve(handler, bind, hooks = {}) {
233
90
  const { port, host } = bind;
234
- const hosted = serveNode(router(surface.routes), { port, host });
235
- const abort = new AbortController();
91
+ const hosted = serveNode(handler, { port, host });
236
92
  let stopping = false;
237
93
  const stop = (exitCode) => {
238
94
  if (stopping)
239
95
  return;
240
96
  stopping = true;
241
- abort.abort();
242
- const deadline = setTimeout(() => process.exit(exitCode), 1_000);
243
- void hosted
244
- .close()
245
- .catch(() => { })
246
- .finally(() => {
97
+ // Bounded: shutdown must not hang on a channel that will not close, so the deadline fires
98
+ // regardless. No drain an in-flight turn is cut, which is the existing contract.
99
+ // Later than the service's own close deadline (SHUTDOWN_GRACE_MS below), or the process leaves
100
+ // at 0 before `close()` has said a channel would not stop.
101
+ const deadline = setTimeout(() => {
102
+ log.error(`[fastagent] shutdown did not finish within ${FORCED_EXIT_MS}ms; exiting`);
103
+ process.exit(1);
104
+ }, FORCED_EXIT_MS);
105
+ // Stop accepting FIRST, before anything is awaited. `onShutdown` waits for long connections to
106
+ // close, and a socket still listening through that wait would dispatch new work into channels
107
+ // and a scheduler that are already shutting down.
108
+ const closingServer = hosted.close();
109
+ hosted.closeAllConnections();
110
+ // A cleanup that failed is not a clean exit: `close()` reports a channel that would not stop,
111
+ // and swallowing it here would end the process at 0 over a resource still holding on.
112
+ void Promise.allSettled([closingServer, Promise.resolve(hooks.onShutdown?.())]).then((outcomes) => {
113
+ let code = exitCode;
114
+ for (const outcome of outcomes) {
115
+ if (outcome.status === "rejected") {
116
+ log.error(`[fastagent] shutdown failed: ${String(outcome.reason)}`);
117
+ code = 1;
118
+ }
119
+ }
247
120
  clearTimeout(deadline);
248
- process.exit(exitCode);
121
+ process.exit(code);
249
122
  });
250
- // Preserve the existing no-drain shutdown contract: stop accepting first, then cut active streams.
251
- hosted.closeAllConnections();
252
123
  };
253
124
  process.once("SIGINT", () => stop(0));
254
125
  process.once("SIGTERM", () => stop(0));
255
126
  hosted.listening.then(async (boundPort) => {
256
127
  try {
257
- const runs = surface.longConnections.map((connection) => {
258
- const run = connection.connect(abort.signal);
259
- if (run === null ||
260
- typeof run !== "object" ||
261
- typeof run.ready?.then !== "function" ||
262
- typeof run.closed?.then !== "function") {
263
- throw new Error(`${connection.name} connect(signal) must return { ready: Promise, closed: Promise }`);
264
- }
265
- void run.closed.then(() => {
266
- if (!abort.signal.aborted)
267
- failStartup(new Error(`${connection.name} closed unexpectedly`));
268
- }, (error) => {
269
- if (!abort.signal.aborted)
270
- failStartup(new Error(`${connection.name} failed: ${String(error)}`));
271
- });
272
- return { connection, run };
273
- });
274
- await Promise.all(runs.map(async ({ connection, run }) => {
275
- await run.ready;
276
- if (!abort.signal.aborted)
277
- log.info(`[fastagent] long connection ready: ${connection.name}`);
278
- }));
279
- // Shutdown raced startup: a pre-ready abort settles `ready` as cancellation, not readiness —
280
- // stop() already owns the exit; don't mark ready or report a surface being torn down.
281
- if (abort.signal.aborted)
128
+ // A bound socket is NOT a serving agent: a declared socket-mode channel still has to come
129
+ // up, and reporting ready before it does tells the supervisor (and --tunnel, and the
130
+ // operator) that a surface is live while a channel is dead.
131
+ await hooks.ready;
132
+ if (stopping)
282
133
  return;
283
- surface.markReady();
284
- process.send?.({
285
- type: "ready",
286
- port: boundPort,
287
- routeChannels: surface.routeChannels,
288
- });
289
- for (const line of readyAddressLines(host, boundPort, surface.builtinInvoke))
290
- log.info(line);
291
- log.info(`[fastagent] routes: ${Object.keys(surface.routes).join(", ") || "(none)"}`);
292
- if (surface.longConnections.length > 0) {
293
- log.info(`[fastagent] long connections: ${surface.longConnections.map((connection) => connection.name).join(", ")}`);
294
- }
295
- onListening?.(boundPort);
134
+ hooks.onListening?.(boundPort);
296
135
  }
297
136
  catch (error) {
298
- abort.abort();
299
- const closing = hosted.close().catch(() => { });
300
- hosted.closeAllConnections();
301
- await closing;
302
137
  failStartup(error);
303
138
  }
304
139
  }, (error) => {
@@ -326,24 +161,3 @@ export function maybeTunnel(agentDir, routeChannels, boundPort, tunnel, stateRoo
326
161
  process.once("SIGTERM", cleanup);
327
162
  });
328
163
  }
329
- /**
330
- * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
331
- * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals. Returns the
332
- * loaded schedules so a serving surface that needs them (the AgentCore adapter's fire binding) shares
333
- * ONE load instead of re-discovering. `externalClock` (AgentCore) arms no resident cron timers.
334
- */
335
- export async function startSchedules(agentDir, agent, stateRoot, selfSchedule, options = {}) {
336
- const { schedules, failures } = await loadSchedules(agentDir).catch(failStartup);
337
- reportModuleLoadFailures(failures);
338
- if (schedules.length === 0 && !selfSchedule)
339
- return schedules;
340
- const scheduler = createScheduler({ agent, stateRoot, schedules, externalClock: options.externalClock });
341
- scheduler.start();
342
- if (schedules.length > 0) {
343
- log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}${options.externalClock ? " (external clock — no resident cron timers)" : ""}`);
344
- }
345
- const stop = () => scheduler.stop();
346
- process.once("SIGINT", stop);
347
- process.once("SIGTERM", stop);
348
- return schedules;
349
- }
@@ -2,7 +2,7 @@ import { type LoginIO, type LoginMethod, type LoginResult } from "../engines/pi/
2
2
  /**
3
3
  * The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). Hand-spaced
4
4
  * labels drift out of alignment the moment a longer one appears — which is exactly what happened when
5
- * `workspace:` joined `config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
5
+ * `codingTools:` joined `workspace:`/`config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
6
6
  * stdout DATA (pipeable, its own label set, its own width), not a log line — the shared thing is the
7
7
  * policy (pad, never hand-space), not a constant.
8
8
  */
@@ -19,12 +19,12 @@ import { failStartup, failUsage } from "./fail.js";
19
19
  /**
20
20
  * The padded label writer for the STARTUP report (`dev`/`start`, stderr via the log level). Hand-spaced
21
21
  * labels drift out of alignment the moment a longer one appears — which is exactly what happened when
22
- * `workspace:` joined `config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
22
+ * `codingTools:` joined `workspace:`/`config:`/`model:`/`state:`. `info` keeps its own writer on purpose: its report is
23
23
  * stdout DATA (pipeable, its own label set, its own width), not a log line — the shared thing is the
24
24
  * policy (pad, never hand-space), not a constant.
25
25
  */
26
26
  export function reportLine(label, value) {
27
- log.info(`[fastagent] ${`${label}:`.padEnd(11)}${value}`);
27
+ log.info(`[fastagent] ${`${label}:`.padEnd(13)}${value}`);
28
28
  }
29
29
  /** The workspace hint under the `agent:`/`workspace:` pair, when there is one ({@link workspaceHint}):
30
30
  * you pointed at the agent, and the project around it is probably what you meant. A hint, so it renders
package/dist/core.d.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  export type { Agent, AgentEvent, ImageRef, Json, Prompt, Scope } from "./agent.ts";
2
2
  export { collect, AgentFailure, type CollectResult } from "./collect.ts";
3
3
  export type { ModuleLoadFailure } from "./loader.ts";
4
- export { createInvokeHandler, nodeListener } from "./channels/http.ts";
5
- export { controlRoutes, type ControlRoutesOptions, type WireEvent } from "./channels/control.ts";
4
+ export { createInvokeHandler } from "./channels/http.ts";
5
+ export type { WireEvent } from "./channels/control.ts";
6
6
  export { ControlRequestError, connectAgent, connectSessionControl, type RemoteEndpointOptions, } from "./session-remote.ts";
7
7
  export { readBodyCapped } from "./channels/body.ts";
8
8
  export { text, textHeaders } from "./channels/respond.ts";
9
- export { type ChannelContext, type ChannelHandler, type ChannelModule, type LongConnection, type LongConnectionChannelModule, type Routes, router, serveNode, } from "./host/node.ts";
9
+ export type { ChannelContext, ChannelHandler, ChannelModule, LongConnection, LongConnectionChannelModule, Routes, } from "./channel.ts";
10
10
  export { defineSchedule, type LoadedSchedule, type Schedule } from "./schedule/schedule.ts";
11
- export { discoverScheduleFiles, loadSchedules } from "./schedule/discover.ts";
12
- export { createScheduler, scheduleSession, type Scheduler, type SchedulerOptions } from "./schedule/scheduler.ts";
package/dist/core.js CHANGED
@@ -1,10 +1,12 @@
1
1
  export { collect, AgentFailure } from "./collect.js";
2
- export { createInvokeHandler, nodeListener } from "./channels/http.js";
3
- export { controlRoutes } from "./channels/control.js";
2
+ export { createInvokeHandler } from "./channels/http.js";
4
3
  export { ControlRequestError, connectAgent, connectSessionControl, } from "./session-remote.js";
5
4
  export { readBodyCapped } from "./channels/body.js";
6
5
  export { text, textHeaders } from "./channels/respond.js";
7
- export { router, serveNode, } from "./host/node.js";
6
+ // Mounting only. Composing a route table (`router`) and owning a prefix (`PrefixMount`) are how
7
+ // `createAgentService` assembles a service — not something a caller has to reproduce.
8
+ // Binding to a Node server lives at `/node`: it is the only runtime-specific piece here, and the
9
+ // only one that costs a third-party package.
10
+ // `defineSchedule` is what a `schedules/*.ts` file is written against. Discovering those files and
11
+ // running the clock is what `createAgentService` does with them — parts a caller does not reproduce.
8
12
  export { defineSchedule } from "./schedule/schedule.js";
9
- export { discoverScheduleFiles, loadSchedules } from "./schedule/discover.js";
10
- export { createScheduler, scheduleSession } from "./schedule/scheduler.js";
@@ -15,7 +15,7 @@ import ignore from "ignore";
15
15
  import { classifyBind } from "../bind.js";
16
16
  import { resolveAuthPath } from "../engines/pi/config.js";
17
17
  import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
18
- import { inspectChannels } from "../engines/pi/channel.js";
18
+ import { inspectChannels } from "../channels/discover.js";
19
19
  import { discoverScheduleFiles } from "../schedule/discover.js";
20
20
  import { createPiModelRuntime, modelCredentialCarry, probeAuthSource } from "../engines/pi/models.js";
21
21
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
@@ -14,14 +14,20 @@ import { spawn } from "node:child_process";
14
14
  import { relative, sep } from "node:path";
15
15
  import { watch as watchTree } from "chokidar";
16
16
  import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot } from "./paths.js";
17
- import { isUnderDir } from "./engines/pi/definition.js";
17
+ import { isUnderDir } from "./paths.js";
18
18
  import { dotEnvPath } from "./env.js";
19
19
  import { log } from "./log.js";
20
20
  import { installProxyFetch } from "./proxy.js";
21
21
  import { openExternalUrl } from "./open-url.js";
22
22
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
23
23
  /** What the dev watcher restarts on (agent-dir-relative): the process-bound code inputs only. */
24
- const WATCHED_HINT = "tools/, channels/, schedules/, package.json, fastagent.config.*, models.json, .secrets/.env";
24
+ /**
25
+ * The agent-dir directories loaded ONCE per worker: a restart is their only re-read. One list, so
26
+ * the watcher and the line printed at startup cannot disagree — a directory watched but unannounced
27
+ * looks broken, and one announced but unwatched silently strands the author mid-edit.
28
+ */
29
+ const CODE_INPUT_DIRS = ["tools", "channels", "schedules", "extensions"];
30
+ const WATCHED_HINT = `${CODE_INPUT_DIRS.map((dir) => `${dir}/`).join(", ")}, package.json, fastagent.config.*, models.json, .secrets/.env`;
25
31
  /**
26
32
  * chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. When
27
33
  * the agent sits INSIDE the workspace that means the surrounding tree never triggers a restart at all;
@@ -56,7 +62,7 @@ export function devWatchIgnored(root, envFile) {
56
62
  if (rel === AGENT_MODELS_FILE)
57
63
  return false;
58
64
  const segments = rel.split(sep);
59
- if (segments[0] === "tools" || segments[0] === "channels" || segments[0] === "schedules")
65
+ if (CODE_INPUT_DIRS.includes(segments[0]))
60
66
  return false;
61
67
  // The `.env` restarts too (credentials are process-bound). Keep it AND its ancestor directories
62
68
  // un-pruned so chokidar can descend to it; every sibling inside them (auth.json, .env.example)
@@ -0,0 +1,104 @@
1
+ import type { ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { Model } from "@earendil-works/pi-ai";
3
+ import { type AgentSessionServices, type CreateAgentSessionServicesOptions, type ModelRuntime } from "@earendil-works/pi-coding-agent";
4
+ import type { PiAgentSessionFactory } from "./invoke-session.ts";
5
+ import type { PiSessionRecordStore } from "./session-store.ts";
6
+ import { type MountedTool } from "./tool.ts";
7
+ /** pi's Model with the API-shape generic erased; fastagent only passes models through. */
8
+ type AnyModel = Model<any>;
9
+ export interface PiAgentSessionFactoryOptions {
10
+ /** Where conversations live. Continuity = same store + same session id. */
11
+ sessions: PiSessionRecordStore;
12
+ /**
13
+ * The model to run and the hub that authenticates it, resolved on FIRST USE and kept.
14
+ *
15
+ * A thunk because building a `ModelRuntime` is async while assembling an agent is not: the L1
16
+ * surface hands back an `Agent` synchronously, so the credential read that a runtime performs
17
+ * belongs on the first turn rather than in the caller's constructor. The two travel together
18
+ * because a model must be resolved against the runtime that holds its provider's auth.
19
+ */
20
+ engine: () => Promise<{
21
+ modelRuntime: ModelRuntime;
22
+ model: AnyModel;
23
+ }>;
24
+ thinkingLevel?: ThinkingLevel;
25
+ tools?: MountedTool[];
26
+ /** Final assembled prompt, or a factory re-evaluated per turn. */
27
+ systemPrompt?: string | (() => string);
28
+ skills?: Skill[];
29
+ /** Per-turn source for the prompt+skills PAIR; supersedes the two above. This is what keeps
30
+ * "the directory is the agent, LIVE" true on a shared `services`: the ResourceLoader is built
31
+ * once, but what it serves is re-read here. */
32
+ live?: () => Promise<{
33
+ systemPrompt?: string;
34
+ skills?: Skill[];
35
+ }>;
36
+ /** The agent's working directory — what fastagent-defined tools see as `cwd`. */
37
+ cwd: string;
38
+ /**
39
+ * Where pi looks for ITS settings (retry budget, compaction thresholds, default thinking level).
40
+ *
41
+ * Deliberately NOT pi's machine-global `~/.pi/agent`: a served agent must behave the same on the
42
+ * author's laptop and in a container, and reading the operator's personal pi configuration is the
43
+ * artifact losing to the machine. Point it at a definition-scoped path; a missing directory simply
44
+ * means pi's own defaults, which is the intended baseline.
45
+ */
46
+ agentDir?: string;
47
+ /**
48
+ * The definition's own extension entry points, for ANNOUNCING that serving does not run them.
49
+ *
50
+ * pi's extension machinery is built for one process serving one session: `bindCore()` copies the
51
+ * session's actions into a runtime the pi source itself calls "the shared runtime", extension
52
+ * modules are cached per assembly, and `session_start`/`session_shutdown` are a matched pair.
53
+ * Serving breaks every one of those assumptions — concurrent turns for unrelated conversations —
54
+ * and the failure is silent cross-talk: with two turns in flight, an extension calling
55
+ * `pi.sendMessage()` can deliver into the other conversation.
56
+ *
57
+ * Loading them anyway would be a correctness bug dressed as a feature, so serving does not, and
58
+ * warns when a definition ships some. `chat` runs them fully: one session, one runtime, which is
59
+ * exactly the shape pi is built for.
60
+ *
61
+ * Isolating them per session is mechanically possible — pi's uncached loader path builds a fresh
62
+ * module (jiti with `moduleCache: false`) and takes the runtime as an argument — but that function
63
+ * is not exported and the deep path is blocked by the package's `exports`. Reopening this needs
64
+ * that entry point upstream, not a workaround here.
65
+ */
66
+ extensionPaths?: string[];
67
+ /** Built-ins omitted by an explicit lower-level tool list. */
68
+ excludedToolNames?: readonly string[];
69
+ /** Filesystem/process environment: definition loading, and the turn context for tools that read one. */
70
+ env: ExecutionEnv;
71
+ }
72
+ /**
73
+ * Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and
74
+ * carries on with the rest — sound for a TUI that shows them, silent for a server that never looks.
75
+ * A definition running without the extension it ships is exactly the "quietly missing" failure this
76
+ * exists to remove. CHAT calls it, once per built services — serving does not load extensions at
77
+ * all, and announces that instead (see PiAgentSessionFactoryOptions.extensionPaths).
78
+ */
79
+ export declare function reportExtensionErrors(services: AgentSessionServices): void;
80
+ /** What pi is allowed to discover, minus the parts each assembly fills in itself. */
81
+ type DefinitionLoaderOptions = NonNullable<CreateAgentSessionServicesOptions["resourceLoaderOptions"]>;
82
+ /**
83
+ * The resource posture a fastagent definition asks pi for — ONE definition of it, for both
84
+ * assemblies. Serving (`piAgentSessionFactory`) and chat (`buildAgentSessionRuntime`) build
85
+ * different sessions on top, but what pi is allowed to DISCOVER is not one of the differences:
86
+ * everything comes from the definition, nothing from the machine that happens to be running it.
87
+ *
88
+ * Two copies of this drifted once already: `additionalExtensionPaths` was added to both, and only
89
+ * one of them also passed the resulting tool names through pi's `tools` allowlist — so extensions
90
+ * worked when served and vanished in chat. A difference between the two has to be visible AS a
91
+ * difference, which is what the parameters are for: serving reads a prompt and skills that change
92
+ * per turn and passes NO extension paths (it does not run them — see
93
+ * {@link PiAgentSessionFactoryOptions.extensionPaths}); chat reads a fixed assembly and passes its
94
+ * own. Both are arguments now, rather than two files that happen to disagree.
95
+ */
96
+ export declare function definitionResourceLoaderOptions(source: {
97
+ systemPrompt: () => string | undefined;
98
+ skills: () => Skill[];
99
+ /** Omitted by serving, which does not run them. */
100
+ extensionPaths?: readonly string[];
101
+ }): DefinitionLoaderOptions;
102
+ /** Open-or-create the record, then bind a fresh session to it. One call per invoke. */
103
+ export declare function piAgentSessionFactory(options: PiAgentSessionFactoryOptions): PiAgentSessionFactory;
104
+ export {};