@fastagent-sh/fastagent 0.13.0 → 0.15.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 (238) hide show
  1. package/README.md +5 -5
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +43 -0
  10. package/dist/channels/feishu/context-buffer.js +72 -0
  11. package/dist/channels/feishu/crypto.d.ts +4 -2
  12. package/dist/channels/feishu/crypto.js +4 -2
  13. package/dist/channels/feishu/feishu-api.d.ts +15 -7
  14. package/dist/channels/feishu/feishu-api.js +22 -4
  15. package/dist/channels/feishu/feishu.d.ts +38 -16
  16. package/dist/channels/feishu/feishu.js +286 -151
  17. package/dist/channels/feishu/invoke-turn.d.ts +24 -31
  18. package/dist/channels/feishu/invoke-turn.js +61 -62
  19. package/dist/channels/feishu/model.d.ts +98 -0
  20. package/dist/channels/feishu/model.js +9 -0
  21. package/dist/channels/feishu/normalize.d.ts +23 -0
  22. package/dist/channels/feishu/normalize.js +132 -0
  23. package/dist/channels/feishu/owned-threads.d.ts +7 -0
  24. package/dist/channels/feishu/owned-threads.js +47 -0
  25. package/dist/channels/feishu/parse.d.ts +21 -103
  26. package/dist/channels/feishu/parse.js +35 -145
  27. package/dist/channels/feishu/preview.d.ts +4 -7
  28. package/dist/channels/feishu/preview.js +26 -142
  29. package/dist/channels/feishu/register-app.d.ts +2 -1
  30. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  31. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  32. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  33. package/dist/channels/feishu/setup-mode.js +2 -0
  34. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  35. package/dist/channels/feishu/ws-ingress.js +136 -0
  36. package/dist/channels/github/github.js +8 -6
  37. package/dist/channels/http.d.ts +14 -0
  38. package/dist/channels/http.js +35 -2
  39. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  40. package/dist/channels/invoke-turn-kit.js +87 -0
  41. package/dist/channels/lark/lark.d.ts +4 -2
  42. package/dist/channels/lark/lark.js +4 -1
  43. package/dist/channels/lark/onboard.d.ts +8 -4
  44. package/dist/channels/lark/onboard.js +8 -0
  45. package/dist/channels/lark/scaffold/channel.ts +10 -3
  46. package/dist/channels/preview-kit.d.ts +109 -0
  47. package/dist/channels/preview-kit.js +183 -0
  48. package/dist/channels/seen.d.ts +5 -0
  49. package/dist/channels/seen.js +35 -0
  50. package/dist/channels/slack/bot-auth.d.ts +15 -0
  51. package/dist/channels/slack/bot-auth.js +146 -0
  52. package/dist/channels/slack/config-api.d.ts +60 -0
  53. package/dist/channels/slack/config-api.js +149 -0
  54. package/dist/channels/slack/context-buffer.d.ts +24 -0
  55. package/dist/channels/slack/context-buffer.js +37 -0
  56. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  57. package/dist/channels/slack/invoke-turn.js +63 -0
  58. package/dist/channels/slack/manifest.d.ts +49 -0
  59. package/dist/channels/slack/manifest.js +69 -0
  60. package/dist/channels/slack/model.d.ts +67 -0
  61. package/dist/channels/slack/model.js +2 -0
  62. package/dist/channels/slack/onboard.d.ts +41 -0
  63. package/dist/channels/slack/onboard.js +120 -0
  64. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  65. package/dist/channels/slack/onboarding-state.js +69 -0
  66. package/dist/channels/slack/owned-threads.d.ts +6 -0
  67. package/dist/channels/slack/owned-threads.js +43 -0
  68. package/dist/channels/slack/parse.d.ts +23 -0
  69. package/dist/channels/slack/parse.js +81 -0
  70. package/dist/channels/slack/preview.d.ts +24 -0
  71. package/dist/channels/slack/preview.js +359 -0
  72. package/dist/channels/slack/reaction.d.ts +24 -0
  73. package/dist/channels/slack/reaction.js +62 -0
  74. package/dist/channels/slack/register-webhook.d.ts +10 -0
  75. package/dist/channels/slack/register-webhook.js +49 -0
  76. package/dist/channels/slack/scaffold/channel.ts +33 -0
  77. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  78. package/dist/channels/slack/setup-server.d.ts +17 -0
  79. package/dist/channels/slack/setup-server.js +103 -0
  80. package/dist/channels/slack/slack-api.d.ts +77 -0
  81. package/dist/channels/slack/slack-api.js +415 -0
  82. package/dist/channels/slack/slack.d.ts +58 -0
  83. package/dist/channels/slack/slack.js +451 -0
  84. package/dist/channels/slack/welcomed.d.ts +5 -0
  85. package/dist/channels/slack/welcomed.js +32 -0
  86. package/dist/channels/state.js +3 -3
  87. package/dist/channels/stop-command.d.ts +6 -0
  88. package/dist/channels/stop-command.js +36 -0
  89. package/dist/channels/tasks.d.ts +13 -0
  90. package/dist/channels/tasks.js +10 -0
  91. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  92. package/dist/channels/telegram/context-buffer.js +6 -85
  93. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  94. package/dist/channels/telegram/invoke-turn.js +11 -58
  95. package/dist/channels/telegram/preview.d.ts +4 -7
  96. package/dist/channels/telegram/preview.js +24 -142
  97. package/dist/channels/telegram/telegram.js +23 -9
  98. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  99. package/dist/channels/{feishu/text.js → text.js} +1 -1
  100. package/dist/channels/turn-queue.js +1 -1
  101. package/dist/channels/turn-store.d.ts +1 -1
  102. package/dist/channels/turn-store.js +2 -3
  103. package/dist/cli/add-feishu.d.ts +27 -0
  104. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  105. package/dist/cli/add-slack.d.ts +10 -0
  106. package/dist/cli/add-slack.js +204 -0
  107. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  108. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  109. package/dist/cli/commands/add.d.ts +13 -0
  110. package/dist/cli/commands/add.js +274 -0
  111. package/dist/cli/commands/attach.d.ts +82 -0
  112. package/dist/cli/commands/attach.js +559 -0
  113. package/dist/cli/commands/chat.d.ts +4 -0
  114. package/dist/cli/commands/chat.js +21 -0
  115. package/dist/cli/commands/deploy.d.ts +15 -0
  116. package/dist/cli/commands/deploy.js +394 -0
  117. package/dist/cli/commands/dev.d.ts +11 -0
  118. package/dist/cli/commands/dev.js +82 -0
  119. package/dist/cli/commands/fire.d.ts +7 -0
  120. package/dist/cli/commands/fire.js +45 -0
  121. package/dist/cli/commands/info.d.ts +7 -0
  122. package/dist/cli/commands/info.js +108 -0
  123. package/dist/cli/commands/init.d.ts +8 -0
  124. package/dist/cli/commands/init.js +81 -0
  125. package/dist/cli/commands/invoke.d.ts +7 -0
  126. package/dist/cli/commands/invoke.js +28 -0
  127. package/dist/cli/commands/login.d.ts +6 -0
  128. package/dist/cli/commands/login.js +52 -0
  129. package/dist/cli/commands/models.d.ts +1 -0
  130. package/dist/cli/commands/models.js +15 -0
  131. package/dist/cli/commands/schedule.d.ts +12 -0
  132. package/dist/cli/commands/schedule.js +89 -0
  133. package/dist/cli/commands/start.d.ts +10 -0
  134. package/dist/cli/commands/start.js +97 -0
  135. package/dist/cli/commands/tool.d.ts +1 -0
  136. package/dist/cli/commands/tool.js +38 -0
  137. package/dist/cli/fail.d.ts +17 -0
  138. package/dist/cli/fail.js +32 -0
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +90 -0
  142. package/dist/cli/kernel.js +190 -0
  143. package/dist/cli/models-view.d.ts +21 -0
  144. package/dist/cli/models-view.js +66 -0
  145. package/dist/cli/program.d.ts +11 -0
  146. package/dist/cli/program.js +479 -0
  147. package/dist/cli/serve.d.ts +48 -0
  148. package/dist/cli/serve.js +248 -0
  149. package/dist/cli/shared.d.ts +44 -0
  150. package/dist/cli/shared.js +246 -0
  151. package/dist/cli.js +8 -1329
  152. package/dist/collect.d.ts +14 -3
  153. package/dist/collect.js +24 -0
  154. package/dist/core.d.ts +3 -1
  155. package/dist/core.js +2 -0
  156. package/dist/deploy/docker/plan.d.ts +45 -0
  157. package/dist/deploy/docker/plan.js +141 -0
  158. package/dist/deploy/docker/run.d.ts +40 -0
  159. package/dist/deploy/docker/run.js +126 -0
  160. package/dist/deploy/fly/plan.d.ts +3 -1
  161. package/dist/deploy/fly/plan.js +12 -7
  162. package/dist/deploy/fly/run.d.ts +5 -4
  163. package/dist/deploy/fly/run.js +16 -5
  164. package/dist/deploy/preflight.d.ts +10 -4
  165. package/dist/deploy/preflight.js +57 -12
  166. package/dist/deploy/railway/plan.d.ts +3 -1
  167. package/dist/deploy/railway/plan.js +12 -4
  168. package/dist/deploy/railway/run.d.ts +5 -4
  169. package/dist/deploy/railway/run.js +15 -5
  170. package/dist/deploy/runner.d.ts +6 -2
  171. package/dist/deploy/runner.js +1 -0
  172. package/dist/deploy/secrets.d.ts +2 -1
  173. package/dist/deploy/secrets.js +23 -3
  174. package/dist/dev-supervisor.d.ts +0 -2
  175. package/dist/dev-supervisor.js +7 -3
  176. package/dist/engines/pi/auth.js +160 -46
  177. package/dist/engines/pi/channel.d.ts +22 -16
  178. package/dist/engines/pi/channel.js +90 -60
  179. package/dist/engines/pi/chat.d.ts +4 -16
  180. package/dist/engines/pi/chat.js +8 -188
  181. package/dist/engines/pi/config.d.ts +23 -9
  182. package/dist/engines/pi/config.js +35 -5
  183. package/dist/engines/pi/create.d.ts +36 -7
  184. package/dist/engines/pi/create.js +63 -22
  185. package/dist/engines/pi/harness.d.ts +65 -1
  186. package/dist/engines/pi/harness.js +166 -2
  187. package/dist/engines/pi/invoke.d.ts +56 -3
  188. package/dist/engines/pi/invoke.js +340 -20
  189. package/dist/engines/pi/login.d.ts +11 -0
  190. package/dist/engines/pi/login.js +17 -5
  191. package/dist/engines/pi/models.d.ts +56 -10
  192. package/dist/engines/pi/models.js +61 -23
  193. package/dist/engines/pi/search-tools.d.ts +10 -0
  194. package/dist/engines/pi/search-tools.js +138 -0
  195. package/dist/engines/pi/session-builder.d.ts +16 -0
  196. package/dist/engines/pi/session-builder.js +308 -0
  197. package/dist/engines/pi/session-control.d.ts +50 -0
  198. package/dist/engines/pi/session-control.js +604 -0
  199. package/dist/engines/pi/sessions.d.ts +17 -2
  200. package/dist/engines/pi/sessions.js +9 -0
  201. package/dist/engines/pi/tool-context.d.ts +46 -11
  202. package/dist/engines/pi/tool-context.js +11 -9
  203. package/dist/engines/pi/tool.d.ts +35 -6
  204. package/dist/engines/pi/tool.js +47 -1
  205. package/dist/engines/pi/wake-tool.d.ts +0 -3
  206. package/dist/engines/pi/wake-tool.js +9 -7
  207. package/dist/engines/pi/workspace.d.ts +56 -1
  208. package/dist/engines/pi/workspace.js +75 -16
  209. package/dist/feishu.d.ts +1 -1
  210. package/dist/feishu.js +1 -1
  211. package/dist/host/node.d.ts +23 -6
  212. package/dist/host/node.js +5 -4
  213. package/dist/index.d.ts +1 -0
  214. package/dist/index.js +1 -0
  215. package/dist/lark.d.ts +1 -1
  216. package/dist/lark.js +1 -1
  217. package/dist/observe.js +3 -0
  218. package/dist/pi.d.ts +6 -4
  219. package/dist/pi.js +2 -1
  220. package/dist/scaffold/add-channel.d.ts +19 -6
  221. package/dist/scaffold/add-channel.js +127 -16
  222. package/dist/scaffold/templates/fastagent.config.mjs +5 -3
  223. package/dist/schedule/wakeups.d.ts +0 -3
  224. package/dist/schedule/wakeups.js +1 -1
  225. package/dist/session-remote.d.ts +53 -0
  226. package/dist/session-remote.js +336 -0
  227. package/dist/session.d.ts +265 -0
  228. package/dist/session.js +37 -0
  229. package/dist/slack.d.ts +2 -0
  230. package/dist/slack.js +2 -0
  231. package/dist/tunnel.d.ts +7 -4
  232. package/dist/tunnel.js +21 -10
  233. package/package.json +22 -7
  234. package/dist/channels/feishu/seen.d.ts +0 -5
  235. package/dist/channels/feishu/seen.js +0 -47
  236. package/dist/cli-add-feishu.d.ts +0 -8
  237. package/dist/cli-models.d.ts +0 -11
  238. package/dist/cli-models.js +0 -20
@@ -0,0 +1,248 @@
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.
4
+ */
5
+ import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { controlRoutes } from "../channels/control.js";
8
+ import { INVOKE_EXAMPLE_BODY, createInvokeHandler } from "../channels/http.js";
9
+ import { text } from "../channels/respond.js";
10
+ import { loadChannels } from "../engines/pi/channel.js";
11
+ import { reportModuleLoadFailures } from "../engines/pi/report.js";
12
+ import { parseRouteKey, router, serveNode } from "../host/node.js";
13
+ import { log } from "../log.js";
14
+ import { openExternalUrl } from "../open-url.js";
15
+ import { loadSchedules } from "../schedule/discover.js";
16
+ import { createScheduler } from "../schedule/scheduler.js";
17
+ import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
18
+ import { failStartup } from "./fail.js";
19
+ /**
20
+ * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
21
+ * POST `/invoke` only when neither a route nor a long-connection channel was declared.
22
+ */
23
+ export async function routesFor(workspaceDir, agent, stateRoot, control) {
24
+ const { routes, longConnections, routeChannels, collisions, failures } = await loadChannels(workspaceDir, {
25
+ agent,
26
+ stateRoot,
27
+ control,
28
+ });
29
+ for (const c of collisions) {
30
+ console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
31
+ }
32
+ reportModuleLoadFailures(failures);
33
+ if (failures.length > 0 || collisions.length > 0) {
34
+ throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
35
+ `fix it, or rename an intentionally disabled file to *.disabled`);
36
+ }
37
+ const builtinInvoke = Object.keys(routes).length === 0 && longConnections.length === 0;
38
+ const channels = builtinInvoke ? { "POST /invoke": createInvokeHandler(agent) } : routes;
39
+ const healthCovered = Object.keys(channels).some((key) => {
40
+ const entry = parseRouteKey(key);
41
+ return entry.path === "/health" && (entry.method === undefined || entry.method === "GET");
42
+ });
43
+ let ready = longConnections.length === 0;
44
+ const health = () => (ready ? text("ok\n", 200) : text("starting\n", 503));
45
+ return {
46
+ routes: healthCovered ? channels : { "GET /health": health, ...channels },
47
+ longConnections,
48
+ routeChannels,
49
+ builtinInvoke,
50
+ markReady() {
51
+ ready = true;
52
+ },
53
+ };
54
+ }
55
+ /**
56
+ * Mount the session control plane (`/control/*`) when the workspace enabled it
57
+ * (`config.sessionControl`): merge the bearer-authenticated routes and return an announcer that
58
+ * writes `<stateRoot>/control.json` — `{ url, token }`, 0600 — once the port is known. The file is
59
+ * the LOCAL discovery channel (`fastagent attach`, a local desktop app); filesystem permissions are
60
+ * its trust boundary, and each boot overwrites it with a fresh per-boot token. A user channel
61
+ * colliding on `/control/*` fails startup — the same disposition as a channel-channel collision
62
+ * (routesFor): `sessionControl` is an explicit opt-in, so declaring both is a configuration error,
63
+ * and silently shadowing either side would serve a surface the author didn't write.
64
+ */
65
+ export function mountSessionControl(routes, control, stateRoot, options = {}) {
66
+ if (!control)
67
+ return { routes, announce: () => { } };
68
+ const token = crypto.randomUUID();
69
+ const mounted = controlRoutes(control, { token, agent: options.agent });
70
+ // PATH-level collision, matching the router's semantics (an any-method "/control/dispatch"
71
+ // channel key would dodge an exact-key check yet still shadow the method-qualified control
72
+ // route at match time — the router matches by path first).
73
+ const mountedPaths = new Set(Object.keys(mounted).map((key) => parseRouteKey(key).path));
74
+ const collisions = Object.keys(routes).filter((key) => mountedPaths.has(parseRouteKey(key).path));
75
+ if (collisions.length > 0) {
76
+ throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control plane — ` +
77
+ `rename the channel route or disable sessionControl in fastagent.config`);
78
+ }
79
+ return {
80
+ routes: { ...routes, ...mounted },
81
+ announce: (boundPort) => {
82
+ // The state root normally exists (the opener mkdirs the sessions dir under it), but an
83
+ // external --sessions-dir leaves it uncreated — and announce runs inside serve's listening
84
+ // callback, where a throw is an unhandled rejection, not a one-line startup diagnostic.
85
+ mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
86
+ const path = join(stateRoot, "control.json");
87
+ // Atomic (tmp+rename, the state.ts pattern): attach re-reads this file exactly during the
88
+ // restart window — a torn read would be misdiagnosed as "serve gone".
89
+ const tmp = `${path}.tmp`;
90
+ writeFileSync(tmp, `${JSON.stringify({ url: `http://127.0.0.1:${boundPort}`, token })}\n`, { mode: 0o600 });
91
+ chmodSync(tmp, 0o600); // an existing file keeps its old mode on rewrite — pin it
92
+ renameSync(tmp, path);
93
+ log.info(`[fastagent] session control on /control/* (token in ${path})`);
94
+ // The serve binds ALL interfaces (containers require it), so /control/* is LAN-reachable
95
+ // with the bearer token as the only protection — the tunnel and deploy paths warn loudly,
96
+ // and the LAN path must not be the silent third way past the local trust story.
97
+ log.warn("[fastagent] the port binds all interfaces: /control/* is reachable on your LAN, protected only by " +
98
+ "the bearer token — firewall the port or wrap it for real exposure (docs: design §14)");
99
+ if (options.tunnel) {
100
+ // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC
101
+ // (beyond even the LAN reach the mount already warned about). The operator asked for the tunnel (webhooks), but must not DISCOVER the control
102
+ // plane went public with it — say it loudly.
103
+ log.warn("[fastagent] --tunnel exposes /control/* (steer/abort/set_model) at the public tunnel URL, " +
104
+ "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
105
+ }
106
+ // Best-effort lifecycle end: a clean exit removes the discovery file so a later `attach`
107
+ // fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
108
+ const unlink = () => {
109
+ try {
110
+ rmSync(path, { force: true });
111
+ }
112
+ catch {
113
+ /* the file is advisory — exit must not fail on it */
114
+ }
115
+ };
116
+ // Signal handlers MUST NOT absorb termination: registering any listener disables Node's
117
+ // default kill, so clean up and RE-RAISE. The mechanism: `process.kill` delivery is ASYNC —
118
+ // it lands after the current emit completes, so every listener of this same emit (scheduler
119
+ // stop, tunnel close — regardless of registration order) runs first, and the re-raised
120
+ // signal then hits the default action because each `once` handler is already consumed. When
121
+ // some listener exits the process itself (the tunnel path calls process.exit(0)), the
122
+ // re-raise is harmless redundancy. Without this, the first Ctrl+C would leave the serve
123
+ // alive minus its control.json, and dev's watch restart (SIGTERM → wait for exit → respawn)
124
+ // would hang on a worker that never exits.
125
+ const unlinkAndReraise = (signal) => () => {
126
+ unlink();
127
+ process.kill(process.pid, signal);
128
+ };
129
+ process.once("SIGINT", unlinkAndReraise("SIGINT"));
130
+ process.once("SIGTERM", unlinkAndReraise("SIGTERM"));
131
+ process.once("exit", unlink);
132
+ },
133
+ };
134
+ }
135
+ /**
136
+ * Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
137
+ * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
138
+ * the sole clean-shutdown command.
139
+ */
140
+ export function serve(surface, port, onListening) {
141
+ const hosted = serveNode(router(surface.routes), { port });
142
+ const abort = new AbortController();
143
+ let stopping = false;
144
+ const stop = (exitCode) => {
145
+ if (stopping)
146
+ return;
147
+ stopping = true;
148
+ abort.abort();
149
+ const deadline = setTimeout(() => process.exit(exitCode), 1_000);
150
+ void hosted
151
+ .close()
152
+ .catch(() => { })
153
+ .finally(() => {
154
+ clearTimeout(deadline);
155
+ process.exit(exitCode);
156
+ });
157
+ // Preserve the existing no-drain shutdown contract: stop accepting first, then cut active streams.
158
+ hosted.closeAllConnections();
159
+ };
160
+ process.once("SIGINT", () => stop(0));
161
+ process.once("SIGTERM", () => stop(0));
162
+ hosted.listening.then(async (boundPort) => {
163
+ try {
164
+ const runs = surface.longConnections.map((connection) => {
165
+ const run = connection.connect(abort.signal);
166
+ if (run === null ||
167
+ typeof run !== "object" ||
168
+ typeof run.ready?.then !== "function" ||
169
+ typeof run.closed?.then !== "function") {
170
+ throw new Error(`${connection.name} connect(signal) must return { ready: Promise, closed: Promise }`);
171
+ }
172
+ void run.closed.then(() => {
173
+ if (!abort.signal.aborted)
174
+ failStartup(new Error(`${connection.name} closed unexpectedly`));
175
+ }, (error) => {
176
+ if (!abort.signal.aborted)
177
+ failStartup(new Error(`${connection.name} failed: ${String(error)}`));
178
+ });
179
+ return { connection, run };
180
+ });
181
+ await Promise.all(runs.map(async ({ connection, run }) => {
182
+ await run.ready;
183
+ if (!abort.signal.aborted)
184
+ log.info(`[fastagent] long connection ready: ${connection.name}`);
185
+ }));
186
+ // Shutdown raced startup: a pre-ready abort settles `ready` as cancellation, not readiness —
187
+ // stop() already owns the exit; don't mark ready or report a surface being torn down.
188
+ if (abort.signal.aborted)
189
+ return;
190
+ surface.markReady();
191
+ process.send?.({
192
+ type: "ready",
193
+ port: boundPort,
194
+ routeChannels: surface.routeChannels,
195
+ });
196
+ log.info(`[fastagent] http host on :${boundPort}`);
197
+ log.info(`[fastagent] routes: ${Object.keys(surface.routes).join(", ") || "(none)"}`);
198
+ if (surface.longConnections.length > 0) {
199
+ log.info(`[fastagent] long connections: ${surface.longConnections.map((connection) => connection.name).join(", ")}`);
200
+ }
201
+ if (surface.builtinInvoke) {
202
+ log.info(`[fastagent] try it: curl -s localhost:${boundPort}/invoke -X POST -H 'content-type: application/json' -d '${INVOKE_EXAMPLE_BODY}'`);
203
+ }
204
+ onListening?.(boundPort);
205
+ }
206
+ catch (error) {
207
+ abort.abort();
208
+ const closing = hosted.close().catch(() => { });
209
+ hosted.closeAllConnections();
210
+ await closing;
211
+ failStartup(error);
212
+ }
213
+ }, (error) => {
214
+ if (error.code === "EADDRINUSE")
215
+ failStartup(new Error(`port ${port} is already in use; choose another with --port`));
216
+ failStartup(new Error(`cannot bind http channel on :${port}: ${error.message}`));
217
+ });
218
+ }
219
+ /** Start a Cloudflare tunnel for route channels only. */
220
+ export function maybeTunnel(workspaceDir, routeChannels, boundPort, tunnel, stateRoot) {
221
+ if (!tunnel || process.env.FASTAGENT_DEV_WORKER === "1")
222
+ return;
223
+ void startCloudflareTunnel(boundPort).then((instance) => {
224
+ if (!instance)
225
+ return;
226
+ void announceWebhooks(workspaceDir, instance.url, { openUrl: openExternalUrl, routeChannels, stateRoot });
227
+ const cleanup = () => instance.close();
228
+ process.once("SIGINT", cleanup);
229
+ process.once("SIGTERM", cleanup);
230
+ });
231
+ }
232
+ /**
233
+ * Load and start the workspace's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
234
+ * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals.
235
+ */
236
+ export async function startSchedules(workspaceDir, agent, stateRoot, selfSchedule) {
237
+ const { schedules, failures } = await loadSchedules(workspaceDir).catch(failStartup);
238
+ reportModuleLoadFailures(failures);
239
+ if (schedules.length === 0 && !selfSchedule)
240
+ return;
241
+ const scheduler = createScheduler({ agent, stateRoot, schedules });
242
+ scheduler.start();
243
+ if (schedules.length > 0)
244
+ log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}`);
245
+ const stop = () => scheduler.stop();
246
+ process.once("SIGINT", stop);
247
+ process.once("SIGTERM", stop);
248
+ }
@@ -0,0 +1,44 @@
1
+ import { type LoginIO, type LoginMethod, type LoginResult } from "../engines/pi/login.ts";
2
+ /** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
3
+ export declare function isInteractive(): boolean;
4
+ /**
5
+ * Parse + range-check a port string (CLI flag or env). Empty/whitespace is "not set" → undefined, so
6
+ * the `??` chain falls through instead of binding port 0 (`Number("")` is 0). The exit code follows
7
+ * RESPONSIBILITY, not the layer that discovers the problem: a bad `--port` is a usage error (2), a
8
+ * bad `PORT` env is broken runtime configuration (1).
9
+ */
10
+ export declare function parsePort(value: string | undefined, source: string, from: "flag" | "env"): number | undefined;
11
+ /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
12
+ export declare function reportAuth(modelSpec: string, authPath: string): Promise<void>;
13
+ /**
14
+ * First-run model resolution for every assembly command (dev/start/invoke/fire/chat/deploy): ONE
15
+ * funnel, no dead ends. When no model is set (flag/env/config) and we're on a TTY, show the FULL
16
+ * catalog annotated per provider — ready (with the credential source, so which account pays is
17
+ * visible at the decision point) or login-required — and, when the choice needs auth, run the login
18
+ * flow INLINE instead of exiting with "run `fastagent login` and come back". A no-op when a model is
19
+ * already set; on a non-TTY (CI, a piped stdin), with `--no-input`, on cancel, or on a failed login
20
+ * it stays quiet and lets the caller raise its own clear error (`missing model`, or deploy's
21
+ * model-travel gate). The pick is exported to FASTAGENT_MODEL so a spawned `dev` worker inherits it,
22
+ * and best-effort written back to the config so the next run is quiet.
23
+ */
24
+ export declare function resolveFirstRunModel(workspaceDir: string, options?: {
25
+ model?: string;
26
+ authPath?: string;
27
+ input?: boolean;
28
+ }): Promise<void>;
29
+ /**
30
+ * Interactive login with the api_key quick-fail probe closed into a LOOP: a definitively rejected key
31
+ * (HTTP 401) deletes the bad credential and RE-PROMPTS immediately — the user's hands are on the
32
+ * keyboard NOW; parking the failure for a later `fastagent login` would waste that. The loop exits on
33
+ * a verified/unverifiable key (kept), an OAuth login (completing the flow already proved the
34
+ * credential), or cancel (LoginCancelled propagates to the caller's cancel policy). Used by both the
35
+ * `login` command and the first-run picker's inline login.
36
+ */
37
+ export declare function loginWithKeyCheck(provider: string | undefined, authPath: string, spec?: string, seams?: {
38
+ flow?: (io: LoginIO, options: {
39
+ provider?: string;
40
+ authPath?: string;
41
+ method?: LoginMethod;
42
+ }) => Promise<LoginResult>;
43
+ verify?: (provider: string, authPath: string, spec?: string) => Promise<"ok" | "rejected" | "unknown">;
44
+ }): Promise<LoginResult>;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Helpers shared across command modules: interactivity gates, port parsing, the startup auth report,
3
+ * first-run model resolution, and the login terminal IO. Bodies moved verbatim from cli.ts; the
4
+ * module-scoped flag access (`values.*`) became parameters.
5
+ */
6
+ import { readFile, writeFile } from "node:fs/promises";
7
+ import { relative } from "node:path";
8
+ import { autocomplete, isCancel, log as clackLog, password, select, text as clackText } from "@clack/prompts";
9
+ import { buildModelPickerOptions } from "./models-view.js";
10
+ import { fastagentCredentialStore } from "../engines/pi/auth.js";
11
+ import { isValidPort, listModels, loadConfig, providerOf, resolveAuthPath, resolveModel, resolveModelSpec, resolveStateRoot, rewriteConfigModel, } from "../engines/pi/config.js";
12
+ import { ensureStateRootSelfIgnored, isUnderDir } from "../engines/pi/definition.js";
13
+ import { LoginCancelled, loginFlow } from "../engines/pi/login.js";
14
+ import { createPiModels, probeApiKey, probeAuthSource, providerAuthStatuses } from "../engines/pi/models.js";
15
+ import { formatAuthReport } from "./auth-view.js";
16
+ import { log } from "../log.js";
17
+ import { openExternalUrl } from "../open-url.js";
18
+ import { failStartup, failUsage } from "./fail.js";
19
+ /** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
20
+ export function isInteractive() {
21
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
22
+ }
23
+ /**
24
+ * Parse + range-check a port string (CLI flag or env). Empty/whitespace is "not set" → undefined, so
25
+ * the `??` chain falls through instead of binding port 0 (`Number("")` is 0). The exit code follows
26
+ * RESPONSIBILITY, not the layer that discovers the problem: a bad `--port` is a usage error (2), a
27
+ * bad `PORT` env is broken runtime configuration (1).
28
+ */
29
+ export function parsePort(value, source, from) {
30
+ if (value === undefined)
31
+ return undefined;
32
+ const trimmed = value.trim();
33
+ if (trimmed === "")
34
+ return undefined;
35
+ if (!/^\d+$/.test(trimmed) || !isValidPort(Number(trimmed))) {
36
+ const message = `invalid ${source} "${value}": must be an integer 0-65535`;
37
+ if (from === "flag")
38
+ failUsage(message);
39
+ failStartup(new Error(message));
40
+ }
41
+ return Number(trimmed);
42
+ }
43
+ /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
44
+ export async function reportAuth(modelSpec, authPath) {
45
+ const provider = providerOf(modelSpec);
46
+ const source = await probeAuthSource(createPiModels({ authPath }), modelSpec);
47
+ // Only when nothing satisfies auth do we read the store (refresh-FREE) to tell "nothing stored" from
48
+ // "stored but unusable" — see formatAuthReport for why. store.read warns on a corrupt file itself.
49
+ const stored = source === undefined
50
+ ? await fastagentCredentialStore(authPath)
51
+ .read(provider)
52
+ .catch(() => undefined)
53
+ : undefined;
54
+ const report = formatAuthReport(provider, authPath, source, stored);
55
+ log.info(`[fastagent] ${report.line}`);
56
+ if (report.warn)
57
+ log.warn(`[fastagent] ${report.warn}`);
58
+ }
59
+ /**
60
+ * First-run model resolution for every assembly command (dev/start/invoke/fire/chat/deploy): ONE
61
+ * funnel, no dead ends. When no model is set (flag/env/config) and we're on a TTY, show the FULL
62
+ * catalog annotated per provider — ready (with the credential source, so which account pays is
63
+ * visible at the decision point) or login-required — and, when the choice needs auth, run the login
64
+ * flow INLINE instead of exiting with "run `fastagent login` and come back". A no-op when a model is
65
+ * already set; on a non-TTY (CI, a piped stdin), with `--no-input`, on cancel, or on a failed login
66
+ * it stays quiet and lets the caller raise its own clear error (`missing model`, or deploy's
67
+ * model-travel gate). The pick is exported to FASTAGENT_MODEL so a spawned `dev` worker inherits it,
68
+ * and best-effort written back to the config so the next run is quiet.
69
+ */
70
+ export async function resolveFirstRunModel(workspaceDir, options = {}) {
71
+ const { config, path: configPath } = await loadConfig(workspaceDir).catch(failStartup);
72
+ if (resolveModelSpec(options.model, config))
73
+ return; // already set (flag > FASTAGENT_MODEL > config)
74
+ if (options.input === false)
75
+ return; // --no-input: never prompt (clig) — the opener raises the clear error
76
+ if (!isInteractive())
77
+ return; // CI/deploy: the opener throws the actionable missing-model error
78
+ const authPath = resolveAuthPath(workspaceDir, options.authPath);
79
+ const models = createPiModels({ authPath });
80
+ const chosen = await pickWithCredentials(workspaceDir, models, authPath);
81
+ if (chosen === undefined)
82
+ return; // cancelled (or auth probe failed): the caller raises its clear missing-model error
83
+ process.env.FASTAGENT_MODEL = chosen; // this process + any spawned dev worker inherits it
84
+ await persistModelChoice(workspaceDir, configPath, chosen);
85
+ }
86
+ /**
87
+ * The credential-aware pick: full catalog annotated per provider, then the post-pick auth policy —
88
+ * remedy warnings for providers no login flow can fix (the choice is KEPT: model validity is
89
+ * independent of credentials), or the inline login for the rest. Returns the chosen spec, or
90
+ * undefined when the pick should be discarded (picker cancel, login cancel, a failed auth probe).
91
+ */
92
+ async function pickWithCredentials(workspaceDir, models, authPath) {
93
+ let statuses;
94
+ try {
95
+ statuses = await providerAuthStatuses(models);
96
+ }
97
+ catch (error) {
98
+ // Per-provider auth throws are captured as `broken` INSIDE providerAuthStatuses; reaching here
99
+ // means the enumeration itself failed (getProviders / a provider with no probe-able surface) — a
100
+ // system fault. Surface it; the opener then still raises the clear missing-model error.
101
+ log.warn(`[fastagent] could not probe provider auth: ${error.message}`);
102
+ return undefined;
103
+ }
104
+ const r = await autocomplete({
105
+ message: "Choose a model for this agent",
106
+ options: buildModelPickerOptions(listModels(models), statuses),
107
+ });
108
+ if (isCancel(r))
109
+ return undefined; // cancelled: the caller raises its clear missing-model error
110
+ const chosen = r;
111
+ const provider = providerOf(chosen);
112
+ const status = statuses.get(provider);
113
+ if (status?.state === "ready")
114
+ return chosen; // usable now — nothing to fix
115
+ if (status && status.login === "none") {
116
+ // No login flow exists for this provider — KEEP the choice and name the remedy. The remedy depends
117
+ // on WHY it is not ready: a BROKEN stored credential still owns the provider (env is consulted
118
+ // only when nothing is stored — createPiModels), so "set the env var" would not help there.
119
+ if (status.state === "broken") {
120
+ log.warn(`[fastagent] stored auth for "${provider}" is unusable: ${status.message} — fix or remove it in ${authPath}; invokes fail until then`);
121
+ }
122
+ else {
123
+ log.warn(`[fastagent] "${provider}" has no interactive login — set its API key env var; invokes fail until then`);
124
+ }
125
+ return chosen;
126
+ }
127
+ // Inline login. Same leak guard as `login`: self-ignore the state root BEFORE a credential
128
+ // can land in-tree, so the secret is never untracked-but-committable.
129
+ const stateRoot = resolveStateRoot(workspaceDir);
130
+ if (isUnderDir(authPath, stateRoot))
131
+ await ensureStateRootSelfIgnored(workspaceDir, stateRoot);
132
+ try {
133
+ // Verified against the CHOSEN model — the exact request the agent is about to make; a rejected
134
+ // key re-prompts inside the loop, so reaching here means a usable (or at worst unverifiable) key.
135
+ await loginWithKeyCheck(provider, authPath, chosen);
136
+ console.error(`[fastagent] logged in to ${provider} — saved to ${authPath}`);
137
+ }
138
+ catch (error) {
139
+ if (error instanceof LoginCancelled)
140
+ return undefined; // user backed out — discard the choice, like a picker cancel
141
+ // A FAILED login keeps the choice: the pick persists, the startup auth report names the remedy,
142
+ // and a later `fastagent login` fixes auth without re-picking the model.
143
+ log.warn(`[fastagent] login for "${provider}" failed: ${error.message} — model saved; run \`fastagent login\` to fix auth`);
144
+ }
145
+ return chosen;
146
+ }
147
+ /**
148
+ * Interactive login with the api_key quick-fail probe closed into a LOOP: a definitively rejected key
149
+ * (HTTP 401) deletes the bad credential and RE-PROMPTS immediately — the user's hands are on the
150
+ * keyboard NOW; parking the failure for a later `fastagent login` would waste that. The loop exits on
151
+ * a verified/unverifiable key (kept), an OAuth login (completing the flow already proved the
152
+ * credential), or cancel (LoginCancelled propagates to the caller's cancel policy). Used by both the
153
+ * `login` command and the first-run picker's inline login.
154
+ */
155
+ export async function loginWithKeyCheck(provider, authPath, spec,
156
+ // Test seams: this loop DESTROYS credential state on `rejected`, so its policy (rejected → delete →
157
+ // re-ask ONLY the key) is pinned by a test through fake flow/verify; production callers omit both.
158
+ seams = {}) {
159
+ const flow = seams.flow ?? loginFlow;
160
+ const verify = seams.verify ?? verifyApiKeyLogin;
161
+ const io = terminalLoginIO();
162
+ let method;
163
+ for (;;) {
164
+ const result = await flow(io, { provider, authPath, method });
165
+ if (result.method !== "api_key")
166
+ return result;
167
+ const verdict = await verify(result.provider, authPath, spec);
168
+ if (verdict !== "rejected")
169
+ return result;
170
+ // Retry re-asks ONLY the key: the provider/method choices weren't the mistake, the keystrokes were.
171
+ provider = result.provider;
172
+ method = "api_key";
173
+ }
174
+ }
175
+ /**
176
+ * Quick-fail check after an api_key login (OAuth needs none — completing the flow already proved the
177
+ * credential): probe the stored key with one minimal request against `spec`, or the provider's first
178
+ * model. Policy over {@link probeApiKey}'s verdict: `rejected` (definitive HTTP 401) DELETES the
179
+ * just-stored credential — a mistyped key must not persist as plausible state — and the caller
180
+ * ({@link loginWithKeyCheck}) re-prompts; `unknown` (network, quota, permissions) keeps it and prints
181
+ * the provider's message: the key may still be right, and wrongly destroying a good credential costs
182
+ * more than keeping a doubtful one.
183
+ */
184
+ async function verifyApiKeyLogin(provider, authPath, spec) {
185
+ const models = createPiModels({ authPath });
186
+ const model = spec ? resolveModel(models, spec) : models.getProvider(provider)?.getModels()[0];
187
+ if (!model) {
188
+ console.error(`[fastagent] cannot verify the key: provider "${provider}" lists no models — kept as stored`);
189
+ return "unknown";
190
+ }
191
+ const label = `${model.provider}/${model.id}`;
192
+ console.error(`[fastagent] verifying the key with ${label}…`);
193
+ const probe = await probeApiKey(models, model);
194
+ if (probe.state === "ok") {
195
+ console.error(`[fastagent] key verified — ${label} responded`);
196
+ }
197
+ else if (probe.state === "rejected") {
198
+ await fastagentCredentialStore(authPath).delete(provider);
199
+ console.error(`[fastagent] ${provider} rejected the API key (HTTP 401): ${probe.message} — enter it again (or cancel)`);
200
+ }
201
+ else {
202
+ console.error(`[fastagent] could not verify the key with ${label}: ${probe.message} — kept; invokes surface the provider's error`);
203
+ }
204
+ return probe.state;
205
+ }
206
+ /** Login terminal IO via @clack/prompts: a searchable list once long, a hidden prompt for keys. Shared
207
+ * by the `login` command and the first-run picker's inline login. */
208
+ function terminalLoginIO() {
209
+ return {
210
+ async select(message, options) {
211
+ const r = await (options.length > 7 ? autocomplete : select)({ message, options });
212
+ return isCancel(r) ? undefined : r;
213
+ },
214
+ async prompt(message, opts) {
215
+ const r = opts?.hidden
216
+ ? await password({ message, signal: opts.signal })
217
+ : await clackText({ message, signal: opts?.signal });
218
+ return isCancel(r) ? undefined : r;
219
+ },
220
+ note: (message) => clackLog.info(message),
221
+ openUrl: openExternalUrl,
222
+ };
223
+ }
224
+ /**
225
+ * Best-effort persist the picked model so the next run does not prompt. Rewrites the commented
226
+ * `model:` placeholder the scaffold writes / an existing `model:` line, or re-inserts the line into a
227
+ * scaffold-shaped config (the hand-deleted-to-reset case); anything else (zero-config, a hand-shaped
228
+ * config) is left untouched with a printed hint. Never throws — persistence is a convenience.
229
+ */
230
+ async function persistModelChoice(workspaceDir, configPath, spec) {
231
+ const hint = () => console.error(
232
+ // No "using it for this run" promise: deploy's model-travel gate rightly ignores the un-persisted pick.
233
+ `[fastagent] picked ${spec} — set \`model: ${JSON.stringify(spec)}\` in your config to persist`);
234
+ if (!configPath)
235
+ return hint();
236
+ try {
237
+ const replaced = rewriteConfigModel(await readFile(configPath, "utf8"), spec);
238
+ if (!replaced)
239
+ return hint();
240
+ await writeFile(configPath, replaced);
241
+ console.error(`[fastagent] saved model ${JSON.stringify(spec)} to ${relative(workspaceDir, configPath)}`);
242
+ }
243
+ catch {
244
+ hint();
245
+ }
246
+ }