@fastagent-sh/fastagent 0.17.1 → 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 (170) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +12 -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.d.ts +4 -2
  22. package/dist/channels/feishu/feishu-api.js +1 -1
  23. package/dist/channels/feishu/feishu.d.ts +1 -1
  24. package/dist/channels/feishu/feishu.js +45 -15
  25. package/dist/channels/feishu/invoke-turn.d.ts +9 -3
  26. package/dist/channels/feishu/invoke-turn.js +152 -34
  27. package/dist/channels/feishu/parse.js +6 -0
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +16 -111
  34. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  35. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +18 -4
  36. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +23 -8
  37. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  38. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  39. package/dist/channels/kit/state.d.ts +6 -0
  40. package/dist/channels/{state.js → kit/state.js} +4 -24
  41. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  42. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  43. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  44. package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +7 -0
  45. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +4 -1
  46. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  47. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  48. package/dist/channels/lark/lark.d.ts +1 -1
  49. package/dist/channels/serve.d.ts +73 -0
  50. package/dist/channels/serve.js +243 -0
  51. package/dist/channels/slack/bot-auth.js +3 -14
  52. package/dist/channels/slack/context-buffer.d.ts +2 -2
  53. package/dist/channels/slack/context-buffer.js +2 -2
  54. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  55. package/dist/channels/slack/invoke-turn.js +9 -6
  56. package/dist/channels/slack/parse.js +2 -2
  57. package/dist/channels/slack/preview.d.ts +1 -1
  58. package/dist/channels/slack/preview.js +11 -3
  59. package/dist/channels/slack/slack-api.js +1 -1
  60. package/dist/channels/slack/slack.d.ts +1 -1
  61. package/dist/channels/slack/slack.js +8 -12
  62. package/dist/channels/slack/welcomed.js +1 -1
  63. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  64. package/dist/channels/telegram/context-buffer.js +2 -2
  65. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  66. package/dist/channels/telegram/invoke-turn.js +2 -2
  67. package/dist/channels/telegram/parse.js +2 -2
  68. package/dist/channels/telegram/preview.d.ts +1 -1
  69. package/dist/channels/telegram/preview.js +6 -2
  70. package/dist/channels/telegram/telegram.d.ts +1 -1
  71. package/dist/channels/telegram/telegram.js +3 -3
  72. package/dist/channels/telegram/turn-store.d.ts +2 -2
  73. package/dist/channels/telegram/turn-store.js +2 -2
  74. package/dist/cli/commands/add.js +1 -1
  75. package/dist/cli/commands/deploy.js +13 -5
  76. package/dist/cli/commands/dev.js +26 -14
  77. package/dist/cli/commands/fire.js +2 -2
  78. package/dist/cli/commands/info.js +28 -5
  79. package/dist/cli/commands/invoke.js +1 -1
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +64 -95
  82. package/dist/cli/commands/tool.js +5 -7
  83. package/dist/cli/program.js +1 -1
  84. package/dist/cli/serve.d.ts +18 -57
  85. package/dist/cli/serve.js +57 -243
  86. package/dist/cli/shared.d.ts +5 -3
  87. package/dist/cli/shared.js +14 -7
  88. package/dist/collect.d.ts +30 -4
  89. package/dist/collect.js +39 -6
  90. package/dist/core.d.ts +3 -5
  91. package/dist/core.js +7 -5
  92. package/dist/deploy/preflight.d.ts +8 -2
  93. package/dist/deploy/preflight.js +22 -4
  94. package/dist/deploy/secrets.d.ts +3 -0
  95. package/dist/deploy/secrets.js +6 -0
  96. package/dist/dev-supervisor.js +16 -4
  97. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  98. package/dist/engines/pi/agent-session-factory.js +314 -0
  99. package/dist/engines/pi/config.d.ts +4 -4
  100. package/dist/engines/pi/config.js +1 -1
  101. package/dist/engines/pi/create.d.ts +56 -42
  102. package/dist/engines/pi/create.js +150 -75
  103. package/dist/engines/pi/definition.d.ts +41 -7
  104. package/dist/engines/pi/definition.js +115 -11
  105. package/dist/engines/pi/invoke-session.d.ts +35 -0
  106. package/dist/engines/pi/invoke-session.js +357 -0
  107. package/dist/engines/pi/login.js +7 -4
  108. package/dist/engines/pi/models.d.ts +59 -11
  109. package/dist/engines/pi/models.js +64 -9
  110. package/dist/engines/pi/open.d.ts +9 -6
  111. package/dist/engines/pi/open.js +20 -10
  112. package/dist/engines/pi/report.d.ts +0 -4
  113. package/dist/engines/pi/report.js +0 -7
  114. package/dist/engines/pi/service.d.ts +23 -0
  115. package/dist/engines/pi/service.js +19 -0
  116. package/dist/engines/pi/session-builder.js +88 -90
  117. package/dist/engines/pi/session-control.d.ts +11 -9
  118. package/dist/engines/pi/session-control.js +114 -77
  119. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  120. package/dist/engines/pi/session-inheritance.js +242 -0
  121. package/dist/engines/pi/session-settings.d.ts +14 -2
  122. package/dist/engines/pi/session-settings.js +18 -0
  123. package/dist/engines/pi/session-store.d.ts +70 -0
  124. package/dist/engines/pi/session-store.js +313 -0
  125. package/dist/engines/pi/tool-context.d.ts +18 -8
  126. package/dist/engines/pi/tool-context.js +23 -1
  127. package/dist/engines/pi/tool.d.ts +5 -5
  128. package/dist/engines/pi/tool.js +1 -1
  129. package/dist/engines/pi/{invoke.d.ts → turn-kit.d.ts} +46 -45
  130. package/dist/engines/pi/turn-kit.js +188 -0
  131. package/dist/engines/pi/wake-tool.js +1 -1
  132. package/dist/index.d.ts +1 -0
  133. package/dist/index.js +1 -0
  134. package/dist/log.d.ts +3 -0
  135. package/dist/log.js +5 -1
  136. package/dist/node.d.ts +16 -0
  137. package/dist/node.js +20 -0
  138. package/dist/paths.d.ts +13 -0
  139. package/dist/paths.js +16 -0
  140. package/dist/pi.d.ts +15 -8
  141. package/dist/pi.js +7 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  143. package/dist/scaffold/templates/persona.md +2 -2
  144. package/dist/schedule/state.js +4 -9
  145. package/dist/schedule/wakeups.d.ts +1 -1
  146. package/dist/schedule/wakeups.js +1 -1
  147. package/dist/service.d.ts +158 -0
  148. package/dist/service.js +354 -0
  149. package/dist/session-remote.d.ts +1 -1
  150. package/dist/session-remote.js +10 -2
  151. package/dist/session.d.ts +4 -2
  152. package/dist/session.js +1 -1
  153. package/package.json +14 -5
  154. package/dist/channels/state.d.ts +0 -16
  155. package/dist/engines/pi/harness.d.ts +0 -112
  156. package/dist/engines/pi/harness.js +0 -153
  157. package/dist/engines/pi/invoke.js +0 -618
  158. package/dist/engines/pi/read-image.d.ts +0 -4
  159. package/dist/engines/pi/read-image.js +0 -62
  160. package/dist/engines/pi/sessions.d.ts +0 -44
  161. package/dist/engines/pi/sessions.js +0 -182
  162. package/dist/host/node.js +0 -52
  163. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  164. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  165. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  166. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  167. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  168. /package/dist/channels/{text.js → kit/text.js} +0 -0
  169. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  170. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * server-neutral: speaks only the wire protocol `createControlPlane` serves (HTTP JSON + SSE with the
4
4
  * {sessionId, epoch, seq, event} envelope) and re-exposes the SAME `SessionControl` interface, so
5
5
  * local and remote consumers are isomorphic — client code does not change when the agent moves out
6
6
  * of process.
@@ -202,7 +202,8 @@ export function connectAgent(options) {
202
202
  // (carry it or reject it), never vanish on the wire while the client believes it was sent.
203
203
  const _invokeDriftGuard = {};
204
204
  void _invokeDriftGuard;
205
- // Same guard for Scope: the body carries session only — a new Scope field must force a decision.
205
+ // Same guard for Scope: the body carries session + the lineage extension — a new Scope field must
206
+ // force a decision (carry it or reject it), never vanish on the wire.
206
207
  const _scopeDriftGuard = {};
207
208
  void _scopeDriftGuard;
208
209
  return {
@@ -229,7 +230,14 @@ export function connectAgent(options) {
229
230
  const res = await fetchFn(`${base}/control/invoke`, {
230
231
  method: "POST",
231
232
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
232
- body: JSON.stringify({ session: scope.session, text: prompt.text }),
233
+ body: JSON.stringify({
234
+ session: scope.session,
235
+ text: prompt.text,
236
+ // Lineage rides the wire so a remote thread scope inherits server-side; the server
237
+ // reads it on the session-create path only, same as in-process.
238
+ ...(scope.parentSession !== undefined ? { parentSession: scope.parentSession } : {}),
239
+ ...(scope.branchHints !== undefined ? { branchHints: scope.branchHints } : {}),
240
+ }),
233
241
  signal: abort.signal,
234
242
  });
235
243
  watchdog.disarm(); // headers arrived
package/dist/session.d.ts CHANGED
@@ -91,7 +91,7 @@ export declare const INVALID_COMMAND_CODE = "invalid_command";
91
91
  * before acceptance; retry after the session's first turn exists. */
92
92
  export declare const NO_SUCH_SESSION_CODE = "no_such_session";
93
93
  /** Stable `SessionResult.error.code` for a boundary mutation rejected BEFORE acceptance with
94
- * nothing durable landed — a failed override append, or compact's admission failing (the harness
94
+ * nothing durable landed — a failed override append, or compact's admission failing (binding the session
95
95
  * build, the local preparation). Acceptance sits where the work becomes asynchronous and
96
96
  * expensive: the model call — compact is accept-fast (holding the dispatch open for a full model
97
97
  * call would make acceptance = outcome), and post-acceptance outcomes travel as
@@ -289,7 +289,9 @@ export type CompactionFinishedEvent = SessionEvent<"compaction_finished", {
289
289
  * the next event (message_*, `run_settled`, `compaction_finished`) is the closure, and the
290
290
  * engine's `retry_finished` carries no outcome to forward. */
291
291
  export type RetryScheduledEvent = SessionEvent<"retry_scheduled", {
292
- operation: "compaction" | "branch_summary";
292
+ /** "assistant" is an engine that retries the ANSWER request itself (pi's AgentSession does;
293
+ * pi's own session does; a summarization call is the other two). */
294
+ operation: "assistant" | "compaction" | "branch_summary";
293
295
  attempt: number;
294
296
  maxAttempts: number;
295
297
  delayMs: number;
package/dist/session.js CHANGED
@@ -16,7 +16,7 @@ export const INVALID_COMMAND_CODE = "invalid_command";
16
16
  * before acceptance; retry after the session's first turn exists. */
17
17
  export const NO_SUCH_SESSION_CODE = "no_such_session";
18
18
  /** Stable `SessionResult.error.code` for a boundary mutation rejected BEFORE acceptance with
19
- * nothing durable landed — a failed override append, or compact's admission failing (the harness
19
+ * nothing durable landed — a failed override append, or compact's admission failing (binding the session
20
20
  * build, the local preparation). Acceptance sits where the work becomes asynchronous and
21
21
  * expensive: the model call — compact is accept-fast (holding the dispatch open for a full model
22
22
  * call would make acceptance = outcome), and post-acceptance outcomes travel as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on GitHub, Telegram, Slack, or behind any channel.",
5
5
  "keywords": [
6
6
  "agent",
@@ -43,6 +43,10 @@
43
43
  "types": "./dist/core.d.ts",
44
44
  "default": "./dist/core.js"
45
45
  },
46
+ "./node": {
47
+ "types": "./dist/node.d.ts",
48
+ "default": "./dist/node.js"
49
+ },
46
50
  "./session": {
47
51
  "types": "./dist/session.d.ts",
48
52
  "default": "./dist/session.js"
@@ -101,9 +105,10 @@
101
105
  },
102
106
  "dependencies": {
103
107
  "@clack/prompts": "^1.6.0",
104
- "@earendil-works/pi-agent-core": "^0.83.0",
105
- "@earendil-works/pi-ai": "^0.83.0",
106
- "@earendil-works/pi-coding-agent": "^0.83.0",
108
+ "@earendil-works/pi-agent-core": "^0.84.3",
109
+ "@earendil-works/pi-ai": "^0.84.3",
110
+ "@earendil-works/pi-coding-agent": "^0.84.3",
111
+ "@hono/node-server": "^2.1.1",
107
112
  "@larksuiteoapi/node-sdk": "^1.71.1",
108
113
  "@octokit/webhooks-methods": "^6.0.0",
109
114
  "@octokit/webhooks-types": "^7.6.1",
@@ -118,9 +123,13 @@
118
123
  },
119
124
  "devDependencies": {
120
125
  "@biomejs/biome": "^2.5.2",
126
+ "@types/express": "^5.0.6",
121
127
  "@types/proper-lockfile": "^4.1.4",
128
+ "express": "^5.2.1",
129
+ "fastify": "^5.12.1",
122
130
  "knip": "^6.29.0",
123
131
  "typescript": "^7.0.2",
124
- "vitest": "^4.1.9"
132
+ "vitest": "^4.1.9",
133
+ "yaml": "^2.9.0"
125
134
  }
126
135
  }
@@ -1,16 +0,0 @@
1
- /** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
2
- export declare function ensureStateHome(dir: string): void;
3
- /** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
4
- * caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
5
- export declare function loadStateFile(path: string): unknown;
6
- export declare function saveStateFile(path: string, value: unknown): void;
7
- /**
8
- * Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
9
- * so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
10
- * whose loss changes behaviour needs a migration, not a delete.
11
- *
12
- * Shared because a retired file is usually retired in every channel at once: one best-effort
13
- * semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
14
- * here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
15
- */
16
- export declare function removeRetiredStateFile(stateHome: string, name: string, label: string): void;
@@ -1,112 +0,0 @@
1
- /**
2
- * pi harness wiring: construct one pi `AgentHarness` per session. The agent definition (AGENTS.md +
3
- * skills) is content fed INTO the harness (see definition.ts), not part of it.
4
- *
5
- * Under the stateless design the harness is discarded after each use; continuity comes from
6
- * persisting the session (PiSessionStore) and re-opening it per invoke — pi's prompt() folds the
7
- * historical entries back into context via buildContext().
8
- */
9
- import { AgentHarness } from "@earendil-works/pi-agent-core";
10
- import type { ExecutionEnv, ExecutionToolContext, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
11
- import type { Model, Models } from "@earendil-works/pi-ai";
12
- import { type PiSessionStore } from "./sessions.ts";
13
- import { type MountedTool } from "./tool.ts";
14
- import { type OverrideEntryLike } from "./session-settings.ts";
15
- /**
16
- * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
17
- * tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
18
- * `active_tools_change` entries are full active-set SNAPSHOTS (setActiveTools persists everything
19
- * active at that moment), and reinterpreting a snapshot as activations would keep a tool active in
20
- * old sessions after the author flips it to `deferred` — the session never discovered it. Deltas
21
- * carry only what was actually discovered.
22
- */
23
- export declare const TOOL_ACTIVATION_ENTRY = "fastagent:tool-activation";
24
- /** The session a factory-built harness is bound to — the seam the activation bridge (invoke.ts) uses
25
- * to write {@link TOOL_ACTIVATION_ENTRY} deltas (pi's harness keeps its session private). Absent for
26
- * a harness built outside {@link piHarnessFactory}: activation still works in-turn there, but is not
27
- * recorded — the factory owns persistence. */
28
- type AnyHarness = AgentHarness<any>;
29
- export type PiSession = Awaited<ReturnType<PiSessionStore["openOrCreate"]>>;
30
- export declare function harnessSession(harness: AnyHarness): PiSession | undefined;
31
- /**
32
- * pi's Model with the API-shape generic erased — fastagent only passes models through to the
33
- * harness, so the generic carries no information. One alias keeps the `any` auditable.
34
- */
35
- export type AnyModel = Model<any>;
36
- /** Builds a pi harness bound to the given session — called once per invoke. */
37
- /** The harness fastagent builds: context-typed on {@link ExecutionToolContext}, because that is what
38
- * pi's env-backed default tools read (pi 0.83). Custom tools are context-FREE and stay assignable — a
39
- * four-parameter `execute` satisfies the five-parameter one, so `defineTool` is untouched by this. */
40
- type PiHarness = AgentHarness<ExecutionToolContext>;
41
- export type PiHarnessFactory = (session: string) => PiHarness | Promise<PiHarness>;
42
- export interface PiHarnessFactoryOptions {
43
- /** Session persistence. Continuity = same backing store + same session id. */
44
- sessions: PiSessionStore;
45
- /** Filesystem/process environment for the default coding tools. Handed to the harness as the TURN's
46
- * tool context (pi 0.83), which is how read/bash/edit/write reach the machine at all — so this is the
47
- * ONE seam a sandbox adapter implements, not a knob beside the tools that ignore it. */
48
- env: ExecutionEnv;
49
- /** Provider collection for all model requests; {@link model} must belong to it (same provider id). */
50
- models: Models;
51
- model: AnyModel;
52
- /** Reasoning effort for the model (pi's scale). Unset = fastagent's pinned default ("medium", pi
53
- * TUI parity — see {@link DEFAULT_THINKING_LEVEL}); unsupported levels are clamped by pi per model. */
54
- thinkingLevel?: ThinkingLevel;
55
- tools?: MountedTool[];
56
- /**
57
- * Final assembled prompt, or a SYNC factory re-evaluated per invoke (how L1 serves dynamic
58
- * `instructions` + the skills listing). Distinct from {@link live}, which is the directory rung's
59
- * ASYNC re-read of prompt AND skills as one pair — both are exercised, by different rungs.
60
- */
61
- systemPrompt?: string | (() => string);
62
- /** Skills visible to the model / explicitly invokable (injected as harness resources). */
63
- skills?: Skill[];
64
- /**
65
- * Per-invoke source for the prompt+skills PAIR, re-evaluated on every harness build. When set it
66
- * supersedes {@link systemPrompt}/{@link skills} — one call yields both, so the skills listing
67
- * inside the prompt and the mounted skill resources can never come from two different reads. The
68
- * directory rung (L2) uses it to re-read the definition, so AGENTS.md/skills edits — the author's or
69
- * the agent's own — take effect on the next turn without a process restart. A rejection surfaces
70
- * as that invoke's `failed` event (the factory throw path), never a crash.
71
- */
72
- live?: () => Promise<{
73
- systemPrompt?: string;
74
- skills?: Skill[];
75
- }>;
76
- }
77
- /**
78
- * Retry policy for generated compaction/branch-summary model calls (pi ≥0.81.1, #6901). OPT-IN
79
- * upstream — an undefined policy means no retries — so both compaction paths pass it explicitly:
80
- * the harness config (auto-compaction inside a run) and the manual `compact()` dispatch in
81
- * session-control. Values mirror pi's own app defaults (maxRetries 3, base 2s exponential).
82
- */
83
- export declare const SUMMARIZATION_RETRY_POLICY: {
84
- readonly enabled: true;
85
- readonly maxRetries: 3;
86
- readonly baseDelayMs: 2000;
87
- };
88
- /**
89
- * The serving default for reasoning effort, pinned to what pi's TUI defaults to (its
90
- * DEFAULT_THINKING_LEVEL) — NOT inherited from the bare harness, whose own fallback is "off": an
91
- * author vibes at "medium" in pi and must get "medium" when served (fidelity), and pinning the value
92
- * here means an upstream default change in either place cannot silently alter deployments. Models
93
- * that don't support a level are clamped by pi per model.
94
- */
95
- export declare const DEFAULT_THINKING_LEVEL: ThinkingLevel;
96
- /**
97
- * {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
98
- * being executable with no control-plane command involved (pi appends these entries itself; a
99
- * deployment's configured model can change between restarts). Deduped per session+cause — it would
100
- * otherwise repeat every turn.
101
- */
102
- export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], models: Models, defaults: {
103
- model: AnyModel;
104
- thinkingLevel: ThinkingLevel;
105
- }, sessionId: string): {
106
- model: AnyModel;
107
- thinkingLevel: ThinkingLevel;
108
- };
109
- export declare function resolveHarnessActiveToolNames(recorded: string[] | null, tools: MountedTool[], sessionId: string): string[] | undefined;
110
- /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
111
- export declare function piHarnessFactory(options: PiHarnessFactoryOptions): PiHarnessFactory;
112
- export {};