@fastagent-sh/fastagent 0.14.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 (209) hide show
  1. package/README.md +3 -3
  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 +8 -11
  10. package/dist/channels/feishu/context-buffer.js +6 -67
  11. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  12. package/dist/channels/feishu/feishu-api.js +20 -2
  13. package/dist/channels/feishu/feishu.d.ts +28 -16
  14. package/dist/channels/feishu/feishu.js +210 -188
  15. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  16. package/dist/channels/feishu/invoke-turn.js +10 -62
  17. package/dist/channels/feishu/model.d.ts +2 -1
  18. package/dist/channels/feishu/normalize.d.ts +2 -1
  19. package/dist/channels/feishu/owned-threads.d.ts +2 -6
  20. package/dist/channels/feishu/parse.d.ts +1 -1
  21. package/dist/channels/feishu/preview.d.ts +4 -7
  22. package/dist/channels/feishu/preview.js +24 -140
  23. package/dist/channels/feishu/register-app.d.ts +2 -1
  24. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  25. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  26. package/dist/channels/feishu/setup-mode.js +2 -0
  27. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  28. package/dist/channels/feishu/ws-ingress.js +136 -0
  29. package/dist/channels/github/github.js +8 -6
  30. package/dist/channels/http.d.ts +14 -0
  31. package/dist/channels/http.js +35 -2
  32. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  33. package/dist/channels/invoke-turn-kit.js +87 -0
  34. package/dist/channels/lark/lark.d.ts +4 -2
  35. package/dist/channels/lark/lark.js +4 -1
  36. package/dist/channels/lark/onboard.d.ts +8 -4
  37. package/dist/channels/lark/onboard.js +8 -0
  38. package/dist/channels/preview-kit.d.ts +109 -0
  39. package/dist/channels/preview-kit.js +183 -0
  40. package/dist/channels/seen.d.ts +5 -0
  41. package/dist/channels/seen.js +35 -0
  42. package/dist/channels/slack/bot-auth.d.ts +15 -0
  43. package/dist/channels/slack/bot-auth.js +146 -0
  44. package/dist/channels/slack/config-api.d.ts +60 -0
  45. package/dist/channels/slack/config-api.js +149 -0
  46. package/dist/channels/slack/context-buffer.d.ts +24 -0
  47. package/dist/channels/slack/context-buffer.js +37 -0
  48. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  49. package/dist/channels/slack/invoke-turn.js +63 -0
  50. package/dist/channels/slack/manifest.d.ts +49 -0
  51. package/dist/channels/slack/manifest.js +69 -0
  52. package/dist/channels/slack/model.d.ts +67 -0
  53. package/dist/channels/slack/model.js +2 -0
  54. package/dist/channels/slack/onboard.d.ts +41 -0
  55. package/dist/channels/slack/onboard.js +120 -0
  56. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  57. package/dist/channels/slack/onboarding-state.js +69 -0
  58. package/dist/channels/slack/owned-threads.d.ts +6 -0
  59. package/dist/channels/slack/owned-threads.js +43 -0
  60. package/dist/channels/slack/parse.d.ts +23 -0
  61. package/dist/channels/slack/parse.js +81 -0
  62. package/dist/channels/slack/preview.d.ts +24 -0
  63. package/dist/channels/slack/preview.js +359 -0
  64. package/dist/channels/slack/reaction.d.ts +24 -0
  65. package/dist/channels/slack/reaction.js +62 -0
  66. package/dist/channels/slack/register-webhook.d.ts +10 -0
  67. package/dist/channels/slack/register-webhook.js +49 -0
  68. package/dist/channels/slack/scaffold/channel.ts +33 -0
  69. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  70. package/dist/channels/slack/setup-server.d.ts +17 -0
  71. package/dist/channels/slack/setup-server.js +103 -0
  72. package/dist/channels/slack/slack-api.d.ts +77 -0
  73. package/dist/channels/slack/slack-api.js +415 -0
  74. package/dist/channels/slack/slack.d.ts +58 -0
  75. package/dist/channels/slack/slack.js +451 -0
  76. package/dist/channels/slack/welcomed.d.ts +5 -0
  77. package/dist/channels/slack/welcomed.js +32 -0
  78. package/dist/channels/state.js +3 -3
  79. package/dist/channels/stop-command.d.ts +6 -0
  80. package/dist/channels/stop-command.js +36 -0
  81. package/dist/channels/tasks.d.ts +13 -0
  82. package/dist/channels/tasks.js +10 -0
  83. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  84. package/dist/channels/telegram/context-buffer.js +6 -85
  85. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  86. package/dist/channels/telegram/invoke-turn.js +11 -58
  87. package/dist/channels/telegram/preview.d.ts +4 -7
  88. package/dist/channels/telegram/preview.js +24 -142
  89. package/dist/channels/telegram/telegram.js +23 -9
  90. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  91. package/dist/channels/{feishu/text.js → text.js} +1 -1
  92. package/dist/channels/turn-queue.js +1 -1
  93. package/dist/channels/turn-store.d.ts +1 -1
  94. package/dist/channels/turn-store.js +2 -3
  95. package/dist/cli/add-feishu.d.ts +27 -0
  96. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  97. package/dist/cli/add-slack.d.ts +10 -0
  98. package/dist/cli/add-slack.js +204 -0
  99. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  100. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  101. package/dist/cli/commands/add.d.ts +4 -0
  102. package/dist/cli/commands/add.js +152 -20
  103. package/dist/cli/commands/attach.d.ts +82 -0
  104. package/dist/cli/commands/attach.js +559 -0
  105. package/dist/cli/commands/chat.d.ts +1 -0
  106. package/dist/cli/commands/chat.js +6 -1
  107. package/dist/cli/commands/deploy.d.ts +2 -0
  108. package/dist/cli/commands/deploy.js +78 -22
  109. package/dist/cli/commands/dev.js +11 -5
  110. package/dist/cli/commands/fire.js +3 -3
  111. package/dist/cli/commands/info.js +2 -2
  112. package/dist/cli/commands/invoke.js +2 -3
  113. package/dist/cli/commands/login.js +14 -25
  114. package/dist/cli/commands/models.js +1 -1
  115. package/dist/cli/commands/start.js +15 -8
  116. package/dist/cli/commands/tool.js +2 -1
  117. package/dist/cli/fail.d.ts +0 -2
  118. package/dist/cli/fail.js +1 -1
  119. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  120. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  121. package/dist/cli/kernel.d.ts +3 -2
  122. package/dist/cli/models-view.d.ts +21 -0
  123. package/dist/cli/models-view.js +66 -0
  124. package/dist/cli/program.js +79 -21
  125. package/dist/cli/serve.d.ts +38 -18
  126. package/dist/cli/serve.js +197 -39
  127. package/dist/cli/shared.d.ts +26 -6
  128. package/dist/cli/shared.js +163 -33
  129. package/dist/collect.d.ts +14 -3
  130. package/dist/collect.js +24 -0
  131. package/dist/core.d.ts +3 -1
  132. package/dist/core.js +2 -0
  133. package/dist/deploy/docker/plan.d.ts +2 -2
  134. package/dist/deploy/docker/plan.js +8 -6
  135. package/dist/deploy/fly/plan.d.ts +3 -1
  136. package/dist/deploy/fly/plan.js +12 -7
  137. package/dist/deploy/fly/run.d.ts +5 -4
  138. package/dist/deploy/fly/run.js +16 -5
  139. package/dist/deploy/preflight.d.ts +10 -4
  140. package/dist/deploy/preflight.js +53 -9
  141. package/dist/deploy/railway/plan.d.ts +3 -1
  142. package/dist/deploy/railway/plan.js +12 -4
  143. package/dist/deploy/railway/run.d.ts +5 -4
  144. package/dist/deploy/railway/run.js +15 -5
  145. package/dist/deploy/runner.d.ts +2 -1
  146. package/dist/deploy/secrets.d.ts +2 -1
  147. package/dist/deploy/secrets.js +23 -3
  148. package/dist/dev-supervisor.d.ts +0 -2
  149. package/dist/dev-supervisor.js +7 -3
  150. package/dist/engines/pi/channel.d.ts +22 -16
  151. package/dist/engines/pi/channel.js +90 -60
  152. package/dist/engines/pi/chat.d.ts +4 -16
  153. package/dist/engines/pi/chat.js +8 -261
  154. package/dist/engines/pi/config.d.ts +14 -9
  155. package/dist/engines/pi/config.js +33 -18
  156. package/dist/engines/pi/create.d.ts +22 -2
  157. package/dist/engines/pi/create.js +20 -14
  158. package/dist/engines/pi/harness.d.ts +49 -0
  159. package/dist/engines/pi/harness.js +90 -2
  160. package/dist/engines/pi/invoke.d.ts +55 -2
  161. package/dist/engines/pi/invoke.js +304 -19
  162. package/dist/engines/pi/login.d.ts +11 -0
  163. package/dist/engines/pi/login.js +16 -4
  164. package/dist/engines/pi/models.d.ts +56 -10
  165. package/dist/engines/pi/models.js +61 -23
  166. package/dist/engines/pi/session-builder.d.ts +16 -0
  167. package/dist/engines/pi/session-builder.js +308 -0
  168. package/dist/engines/pi/session-control.d.ts +50 -0
  169. package/dist/engines/pi/session-control.js +604 -0
  170. package/dist/engines/pi/sessions.d.ts +17 -2
  171. package/dist/engines/pi/sessions.js +9 -0
  172. package/dist/engines/pi/tool-context.d.ts +18 -11
  173. package/dist/engines/pi/tool-context.js +3 -9
  174. package/dist/engines/pi/tool.d.ts +6 -8
  175. package/dist/engines/pi/tool.js +6 -1
  176. package/dist/engines/pi/wake-tool.d.ts +0 -3
  177. package/dist/engines/pi/wake-tool.js +9 -7
  178. package/dist/engines/pi/workspace.d.ts +52 -0
  179. package/dist/engines/pi/workspace.js +72 -15
  180. package/dist/feishu.d.ts +1 -1
  181. package/dist/feishu.js +1 -1
  182. package/dist/host/node.d.ts +23 -6
  183. package/dist/host/node.js +5 -4
  184. package/dist/index.d.ts +1 -0
  185. package/dist/index.js +1 -0
  186. package/dist/lark.d.ts +1 -1
  187. package/dist/lark.js +1 -1
  188. package/dist/observe.js +3 -0
  189. package/dist/pi.d.ts +5 -4
  190. package/dist/pi.js +2 -1
  191. package/dist/scaffold/add-channel.d.ts +19 -6
  192. package/dist/scaffold/add-channel.js +126 -17
  193. package/dist/scaffold/templates/fastagent.config.mjs +4 -3
  194. package/dist/schedule/wakeups.d.ts +0 -3
  195. package/dist/schedule/wakeups.js +1 -1
  196. package/dist/session-remote.d.ts +53 -0
  197. package/dist/session-remote.js +336 -0
  198. package/dist/session.d.ts +265 -0
  199. package/dist/session.js +37 -0
  200. package/dist/slack.d.ts +2 -0
  201. package/dist/slack.js +2 -0
  202. package/dist/tunnel.d.ts +7 -4
  203. package/dist/tunnel.js +21 -10
  204. package/package.json +19 -7
  205. package/dist/channels/feishu/seen.d.ts +0 -5
  206. package/dist/channels/feishu/seen.js +0 -47
  207. package/dist/cli-add-feishu.d.ts +0 -8
  208. package/dist/cli-models.d.ts +0 -11
  209. package/dist/cli-models.js +0 -20
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CLI presenter for the model-facing commands: `fastagent models [search]` output and the first-run
3
+ * picker's option list. Output DECISIONS live here, out of the engine config layer (config.ts owns
4
+ * model resolution / listModels), so this process-boundary behavior is unit-testable without
5
+ * spawning the CLI.
6
+ */
7
+ import { providerOf } from "../engines/pi/config.js";
8
+ /** The remedy hint for a non-ready provider, by what picking it actually does: an OAuth flow →
9
+ * "login required"; an interactive key prompt → "API key required"; no flow at all → the env var. */
10
+ function remedy(login) {
11
+ if (login === "oauth")
12
+ return "login required";
13
+ return login === "api_key" ? "API key required" : "API key required — set the provider's env var";
14
+ }
15
+ /**
16
+ * The first-run picker menu: the FULL model catalog, each spec annotated with its provider's auth
17
+ * status — ready first (usable now, with the credential source so "which account pays" is visible at
18
+ * the decision point), then the rest with their remedy. Order within each group preserves `specs`
19
+ * (sorted by the caller). A broken provider (expired/corrupt credential) is annotated, not dropped —
20
+ * fail visibly.
21
+ */
22
+ export function buildModelPickerOptions(specs, statuses) {
23
+ const ready = [];
24
+ const rest = [];
25
+ for (const spec of specs) {
26
+ const status = statuses.get(providerOf(spec));
27
+ if (status?.state === "ready") {
28
+ ready.push({ value: spec, label: spec, hint: status.source ? `ready — ${status.source}` : "ready" });
29
+ }
30
+ else if (status?.state === "broken") {
31
+ // A broken stored credential OWNS the provider (env is consulted only when nothing is stored),
32
+ // so with no login flow the remedy is fixing the store — not the env var (which can't win here).
33
+ rest.push({
34
+ value: spec,
35
+ label: spec,
36
+ hint: status.login === "none"
37
+ ? `stored auth unusable: ${status.message} — fix or remove the stored credential`
38
+ : `${remedy(status.login)} — stored auth unusable: ${status.message}`,
39
+ });
40
+ }
41
+ else if (status) {
42
+ rest.push({ value: spec, label: spec, hint: remedy(status.login) });
43
+ }
44
+ else {
45
+ // Unreachable when `specs` and `statuses` come from the same Models (every listed provider is
46
+ // probed); if a caller ever mixes sources, promise nothing — neutral wording, no login claim.
47
+ rest.push({ value: spec, label: spec, hint: "auth required" });
48
+ }
49
+ }
50
+ return [...ready, ...rest];
51
+ }
52
+ /** The output of `fastagent models [search]`: the spec `lines` to print to stdout (a case-insensitive
53
+ * substring filter; no search → all), and an stderr `error` diagnostic when a search matches nothing. */
54
+ export function formatModelsCommand(specs, search) {
55
+ if (!search)
56
+ return { lines: specs };
57
+ const q = search.toLowerCase();
58
+ const matches = specs.filter((spec) => spec.toLowerCase().includes(q));
59
+ if (matches.length === 0)
60
+ return { lines: matches, error: `no model matches "${search}"` };
61
+ // Rank a PROVIDER-name match (query in the part before "/") above an incidental model-id match, so
62
+ // `models anthropic` leads with anthropic/* rather than burying it under amazon-bedrock/anthropic.*
63
+ // and google-vertex/…-anthropic-… (which only match in the model id). Order within each group is kept.
64
+ const providerMatch = (spec) => providerOf(spec).toLowerCase().includes(q);
65
+ return { lines: [...matches.filter(providerMatch), ...matches.filter((s) => !providerMatch(s))] };
66
+ }
@@ -26,7 +26,7 @@ const PORT = { flags: "--port <n>", description: "HTTP port" };
26
26
  const TUNNEL = {
27
27
  flags: "--tunnel",
28
28
  description: "expose a public HTTPS URL via a Cloudflare quick tunnel (needs cloudflared) and auto-register " +
29
- "webhook channels (telegram, feishu, lark; github prints the URL) — for hosting a bot from your " +
29
+ "webhook channels (telegram, onboarded slack, feishu, lark; github/manual slack print the URL) — for hosting a bot from your " +
30
30
  "own box without deploying (the quick-tunnel URL is ephemeral, not for production)",
31
31
  };
32
32
  const init = {
@@ -77,7 +77,7 @@ const dev = {
77
77
  ],
78
78
  examples: [
79
79
  { cmd: "fastagent dev" },
80
- { cmd: "fastagent dev --tunnel", note: "public URL + auto-registered webhooks" },
80
+ { cmd: "fastagent dev --tunnel", note: "public URL + registered/guided webhooks" },
81
81
  ],
82
82
  run: async (args, f) => (await import("./commands/dev.js")).runDev(args[0], {
83
83
  port: f.port,
@@ -88,16 +88,38 @@ const dev = {
88
88
  input: f.input !== false,
89
89
  }),
90
90
  };
91
+ const attach = {
92
+ name: "attach",
93
+ summary: "watch a session's live events from a running serve and steer it",
94
+ description: "Attach to a session served by a running dev/start with `sessionControl: true` in the config: " +
95
+ "stream its live events (text, tools, run boundaries), steer the active run by typing — or, with " +
96
+ "no run active, start one (detaching cancels a run YOU started) — /abort to stop a run. Discovers " +
97
+ "the local endpoint from <stateRoot>/control.json; --url/--token reach a remote serve. The same " +
98
+ "wire protocol a Web panel or desktop app uses.",
99
+ args: [{ name: "<session>", description: "the session id to attach to" }, DIR_ARG],
100
+ flags: [
101
+ { flags: "--url <url>", description: "control endpoint (skip control.json discovery)" },
102
+ { flags: "--token <token>", description: "bearer token for --url" },
103
+ ],
104
+ examples: [{ cmd: "fastagent attach tg-chat-42" }],
105
+ run: async (args, f) => (await import("./commands/attach.js")).runAttach(args[0], args[1], {
106
+ url: f.url,
107
+ token: f.token,
108
+ }),
109
+ };
91
110
  const chat = {
92
111
  name: "chat",
93
112
  summary: "open the SAME assembled agent in pi's interactive TUI",
94
113
  description: "Open the SAME assembled agent in pi's interactive TUI (the real harness, not a crude REPL) — to " +
95
- "try it locally before serving. Same model/tool/skill resolution as dev; pi handles login, " +
96
- "sessions, and /resume natively.",
114
+ "try it locally before serving. Same model/tool/skill/auth resolution as dev; pi handles " +
115
+ "rendering, sessions, and /resume natively (its /login writes to the same fastagent auth file).",
97
116
  args: [DIR_ARG],
98
- flags: [MODEL],
117
+ flags: [MODEL, AUTH_PATH],
99
118
  examples: [{ cmd: "fastagent chat" }],
100
- run: async (args, f) => (await import("./commands/chat.js")).runChat(args[0], { model: f.model }),
119
+ run: async (args, f) => (await import("./commands/chat.js")).runChat(args[0], {
120
+ model: f.model,
121
+ authPath: f.authPath,
122
+ }),
101
123
  };
102
124
  const info = {
103
125
  name: "info",
@@ -211,37 +233,70 @@ const start = {
211
233
  };
212
234
  /** The retired app-creation flag — parsed so it can explain itself, hidden from help. */
213
235
  const CREATE_APP = { flags: "--create-app", description: "(retired)", hidden: true };
236
+ const INGRESS = {
237
+ flags: "--ingress <mode>",
238
+ description: "Feishu/Lark ingress: websocket or webhook (interactive when omitted)",
239
+ };
240
+ const GROUP_BEHAVIOR = {
241
+ flags: "--group-behavior <behavior>",
242
+ description: "Slack/Feishu/Lark groups: context (recommended) or mentions (least privilege)",
243
+ };
244
+ const NO_ONBOARD = {
245
+ flags: "--no-onboard",
246
+ description: "Slack: scaffold only; skip internal-app creation/OAuth",
247
+ };
248
+ const REPLACE_CONFIG = {
249
+ flags: "--replace-config",
250
+ description: "Slack: replace the local App Configuration token pair (repairs automatic dev/deploy Request URL " +
251
+ "updates after the tokens expire or are revoked; runs on the machine that onboarded the app)",
252
+ };
214
253
  const channelSub = (kind, summary, description, notes) => ({
215
254
  name: kind,
216
255
  summary,
217
256
  description,
218
257
  args: [DIR_ARG],
219
- flags: [CREATE_APP],
258
+ flags: [
259
+ CREATE_APP,
260
+ ...(kind === "feishu" || kind === "lark"
261
+ ? [INGRESS, GROUP_BEHAVIOR]
262
+ : kind === "slack"
263
+ ? [GROUP_BEHAVIOR, NO_ONBOARD, REPLACE_CONFIG]
264
+ : []),
265
+ ],
220
266
  examples: [{ cmd: `fastagent add ${kind}` }],
221
267
  ...(notes ? { notes } : {}),
222
- run: async (args, f) => (await import("./commands/add.js")).runAddChannel(kind, args[0], { createApp: f.createApp === true }),
268
+ run: async (args, f) => (await import("./commands/add.js")).runAddChannel(kind, args[0], {
269
+ createApp: f.createApp === true,
270
+ ingress: f.ingress,
271
+ groupBehavior: f.groupBehavior,
272
+ onboard: f.onboard !== false,
273
+ replaceConfig: f.replaceConfig === true,
274
+ }),
223
275
  });
224
276
  const add = {
225
277
  name: "add",
226
- summary: "connect a channel (github, telegram, feishu, lark) or vendor a skill",
227
- description: "Scaffold channels/<kind>.ts — third-party adapter glue with the policy to edit (github maps " +
228
- "events in on(); telegram/feishu/lark route in the optional route()) — or vendor an Agent Skills " +
278
+ summary: "connect a channel (github, telegram, slack, feishu, lark) or vendor a skill",
279
+ description: "Scaffold channels/<kind>.ts — first-party adapter glue with the policy to edit (github maps " +
280
+ "events in on(); telegram/slack/feishu/lark route in the optional route()) — or vendor an Agent Skills " +
229
281
  "skill into skills/<name>/.",
230
282
  subcommands: [
231
283
  channelSub("github", "scaffold the GitHub webhook channel (issues/PRs → agent turns)", "Scaffold channels/github.ts — webhook adapter glue that maps repository events (issues, PRs, " +
232
284
  "comments) to agent turns in its on() policy."),
233
285
  channelSub("telegram", "scaffold the Telegram bot channel (durable turns, live preview)", "Scaffold channels/telegram.ts — the Telegram bot channel with durable turns, a live-preview " +
234
286
  "message pump, and an optional route() policy."),
235
- channelSub("feishu", "scaffold the Feishu channel AND create/configure the platform app", "Scaffold channels/feishu.ts AND create/configure the Feishu platform app (scan-to-create), " +
236
- "writing credentials to .env.", "Feishu (open.feishu.cn) is the canonical implementation. `add feishu` also CREATES + " +
237
- 'configures the platform app (confirm a link in the app the platform\'s "scan to create" ' +
238
- "flow; one version-publish action remains) and writes credentials to .env; a persisted " +
239
- "ID/Secret pair resumes missing-Token setup instead of creating another app."),
240
- channelSub("lark", "scaffold the Lark (international) channel with guided credential setup", "Scaffold channels/lark.ts the Lark international profile over the Feishu engine — and guide " +
241
- "credential setup against the intl developer console.", "Lark international (open.larksuite.com) is Feishu's compatibility profile with degraded " +
242
- "control-plane setup: opens the intl developer console only for a new/partial pair, validates " +
243
- "App ID/Secret, then probes webhook-mode + Token automation; an explicit config-route 404 " +
244
- "falls back to a hidden Token prompt + manual mode/URL setup."),
287
+ channelSub("slack", "scaffold the Slack Events API channel (files, threads, context, live preview)", "Choose group visibility, scaffold channels/slack.ts plus slack-send.ts, create a single-workspace " +
288
+ "internal Slack app from a manifest, and install it through OAuth. The channel provides signed " +
289
+ "Events API ingress, durable turns, files, managed threads, context, and an edited live preview.", "Automated onboarding requires Slack App Configuration access + refresh tokens and a temporary " +
290
+ "cloudflared tunnel. They stay in owner-readable local state and are never deployed; --no-onboard " +
291
+ "keeps the explicit manual/scaffold-only path."),
292
+ channelSub("feishu", "scaffold the Feishu channel AND create/configure the platform app", "Choose WebSocket or webhook, scaffold channels/feishu.ts, and create/configure the Feishu app " +
293
+ "through scan-to-create, writing the matching credentials to .env.", "Feishu (open.feishu.cn) is the canonical implementation. WebSocket needs only App ID/Secret and " +
294
+ "no public URL; webhook additionally captures the Verification Token through a temporary tunnel. " +
295
+ "Context-aware groups (recommended) request admin approval for im:message.group_msg before publish."),
296
+ channelSub("lark", "scaffold the Lark (international) channel with guided credential setup", "Choose WebSocket or webhook, scaffold channels/lark.ts, and guide credential setup against the " +
297
+ "international developer console.", "Lark international (open.larksuite.com) is Feishu's compatibility profile. WebSocket stops after " +
298
+ "App ID/Secret validation; webhook and recommended context-aware group setup probe config " +
299
+ "automation and fall back to explicit manual steps on the international config-route 404."),
245
300
  {
246
301
  name: "skill",
247
302
  summary: "vendor an Agent Skills skill into skills/<name>/ (copied in, git-tracked)",
@@ -306,6 +361,7 @@ const deploy = {
306
361
  },
307
362
  MODEL,
308
363
  AUTH_PATH,
364
+ NO_INPUT,
309
365
  ],
310
366
  examples: [
311
367
  { cmd: "fastagent deploy fly --run", note: "provision + deploy + webhooks" },
@@ -324,6 +380,7 @@ const deploy = {
324
380
  intoLinked: f.intoLinked === true,
325
381
  model: f.model,
326
382
  authPath: f.authPath,
383
+ input: f.input !== false,
327
384
  }),
328
385
  };
329
386
  const schedule = {
@@ -395,6 +452,7 @@ export const specs = [
395
452
  schedule,
396
453
  dev,
397
454
  chat,
455
+ attach,
398
456
  start,
399
457
  add,
400
458
  deploy,
@@ -1,28 +1,48 @@
1
- /**
2
- * The serving spine shared by `dev` (its worker) and `start`: route assembly from discovered
3
- * channels/, the Node host binding, the scheduler lifecycle, and the optional Cloudflare quick
4
- * tunnel. Bodies moved verbatim from cli.ts; `values.tunnel` became a parameter.
5
- */
6
1
  import type { Agent } from "../agent.ts";
2
+ import { type LoadedLongConnectionChannel } from "../engines/pi/channel.ts";
7
3
  import { type Routes } from "../host/node.ts";
4
+ import type { SessionControl } from "../session.ts";
5
+ export interface ServingSurface {
6
+ routes: Routes;
7
+ longConnections: LoadedLongConnectionChannel[];
8
+ /** Route-channel basenames; the tunnel registers only this subset. */
9
+ routeChannels: string[];
10
+ builtinInvoke: boolean;
11
+ /** Marks the built-in health route ready after every long-connection channel first connects. */
12
+ markReady(): void;
13
+ }
14
+ /**
15
+ * The surface this deployment serves: default `GET /health` plus discovered channels, or the default
16
+ * POST `/invoke` only when neither a route nor a long-connection channel was declared.
17
+ */
18
+ export declare function routesFor(workspaceDir: string, agent: Agent, stateRoot: string, control?: SessionControl): Promise<ServingSurface>;
8
19
  /**
9
- * The routes this deployment serves: a default `GET /health` plus the workspace's discovered
10
- * `channels/` or the default invoke channel at POST /invoke when none are declared.
20
+ * Mount the session control plane (`/control/*`) when the workspace enabled it
21
+ * (`config.sessionControl`): merge the bearer-authenticated routes and return an announcer that
22
+ * writes `<stateRoot>/control.json` — `{ url, token }`, 0600 — once the port is known. The file is
23
+ * the LOCAL discovery channel (`fastagent attach`, a local desktop app); filesystem permissions are
24
+ * its trust boundary, and each boot overwrites it with a fresh per-boot token. A user channel
25
+ * colliding on `/control/*` fails startup — the same disposition as a channel-channel collision
26
+ * (routesFor): `sessionControl` is an explicit opt-in, so declaring both is a configuration error,
27
+ * and silently shadowing either side would serve a surface the author didn't write.
11
28
  */
12
- export declare function routesFor(workspaceDir: string, agent: Agent, stateRoot: string): Promise<Routes>;
13
- /** Serve `routes` via the Node host. serveNode owns binding; the CLI owns policy (errors, ready signal, log). */
14
- export declare function serve(routes: Routes, port: number, onListening?: (boundPort: number) => void): void;
29
+ export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, stateRoot: string, options?: {
30
+ tunnel?: boolean;
31
+ agent?: Agent;
32
+ }): {
33
+ routes: Routes;
34
+ announce: (boundPort: number) => void;
35
+ };
15
36
  /**
16
- * Start a Cloudflare tunnel + announce/register webhooks once the server is bound unless this is a
17
- * watch-supervisor worker, where the supervisor owns the long-lived tunnel so the public URL survives
18
- * reloads.
37
+ * Bind HTTP, open long-connection channels, and report ready only when both forms are usable. Each
38
+ * adapter owns reconnects; a terminal close rejects `closed` and fails the process visibly. Abort is
39
+ * the sole clean-shutdown command.
19
40
  */
20
- export declare function maybeTunnel(workspaceDir: string, boundPort: number, tunnel: boolean): void;
41
+ export declare function serve(surface: ServingSurface, port: number, onListening?: (boundPort: number) => void): void;
42
+ /** Start a Cloudflare tunnel for route channels only. */
43
+ export declare function maybeTunnel(workspaceDir: string, routeChannels: string[], boundPort: number, tunnel: boolean, stateRoot?: string): void;
21
44
  /**
22
45
  * Load and start the workspace's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
23
- * there are static schedules OR `selfSchedule` is on (the scheduler also polls the agent's self-scheduled
24
- * wake-ups, which the built-in `wake` tool creates only when opted in). Shares the SAME (trace-wrapped)
25
- * agent the routes serve, so a scheduled turn is observed like any other. Best-effort stop on exit; dev's
26
- * watch restart re-reads schedules with the worker (schedules are a code input). Single-process.
46
+ * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals.
27
47
  */
28
48
  export declare function startSchedules(workspaceDir: string, agent: Agent, stateRoot: string, selfSchedule: boolean): Promise<void>;
package/dist/cli/serve.js CHANGED
@@ -1,4 +1,11 @@
1
- import { createInvokeHandler } from "../channels/http.js";
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";
2
9
  import { text } from "../channels/respond.js";
3
10
  import { loadChannels } from "../engines/pi/channel.js";
4
11
  import { reportModuleLoadFailures } from "../engines/pi/report.js";
@@ -10,11 +17,15 @@ import { createScheduler } from "../schedule/scheduler.js";
10
17
  import { announceWebhooks, startCloudflareTunnel } from "../tunnel.js";
11
18
  import { failStartup } from "./fail.js";
12
19
  /**
13
- * The routes this deployment serves: a default `GET /health` plus the workspace's discovered
14
- * `channels/` or the default invoke channel at POST /invoke when none are declared.
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.
15
22
  */
16
- export async function routesFor(workspaceDir, agent, stateRoot) {
17
- const { routes, collisions, failures } = await loadChannels(workspaceDir, { agent, stateRoot });
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
+ });
18
29
  for (const c of collisions) {
19
30
  console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
20
31
  }
@@ -23,61 +34,208 @@ export async function routesFor(workspaceDir, agent, stateRoot) {
23
34
  throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
24
35
  `fix it, or rename an intentionally disabled file to *.disabled`);
25
36
  }
26
- const channels = Object.keys(routes).length > 0 ? routes : { "POST /invoke": createInvokeHandler(agent) };
27
- // Add a default GET /health unless a channel already covers it (overlap, not exact-key: an
28
- // any-method `/health` also handles GET, so the built-in steps aside).
29
- const healthCovered = Object.keys(channels).some((k) => {
30
- const e = parseRouteKey(k);
31
- return e.path === "/health" && (e.method === undefined || e.method === "GET");
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");
32
42
  });
33
- return healthCovered ? channels : { "GET /health": () => text("ok\n", 200), ...channels };
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
+ };
34
134
  }
35
- /** Serve `routes` via the Node host. serveNode owns binding; the CLI owns policy (errors, ready signal, log). */
36
- export function serve(routes, port, onListening) {
37
- serveNode(router(routes), { port }).listening.then((boundPort) => {
38
- process.send?.({ type: "ready", port: boundPort }); // tell the dev supervisor we bound + on which port
39
- log.info(`[fastagent] http channel on :${boundPort}`);
40
- log.info(`[fastagent] routes: ${Object.keys(routes).join(", ") || "(none)"}`);
41
- onListening?.(boundPort);
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
+ }
42
213
  }, (error) => {
43
214
  if (error.code === "EADDRINUSE")
44
215
  failStartup(new Error(`port ${port} is already in use; choose another with --port`));
45
216
  failStartup(new Error(`cannot bind http channel on :${port}: ${error.message}`));
46
217
  });
47
218
  }
48
- /**
49
- * Start a Cloudflare tunnel + announce/register webhooks once the server is bound — unless this is a
50
- * watch-supervisor worker, where the supervisor owns the long-lived tunnel so the public URL survives
51
- * reloads.
52
- */
53
- export function maybeTunnel(workspaceDir, boundPort, tunnel) {
219
+ /** Start a Cloudflare tunnel for route channels only. */
220
+ export function maybeTunnel(workspaceDir, routeChannels, boundPort, tunnel, stateRoot) {
54
221
  if (!tunnel || process.env.FASTAGENT_DEV_WORKER === "1")
55
222
  return;
56
- void startCloudflareTunnel(boundPort).then((t) => {
57
- if (!t)
223
+ void startCloudflareTunnel(boundPort).then((instance) => {
224
+ if (!instance)
58
225
  return;
59
- void announceWebhooks(workspaceDir, t.url, { openUrl: openExternalUrl });
60
- // Single-process (start / --no-watch): close the tunnel on exit (watch mode's supervisor owns its own).
61
- const cleanup = () => {
62
- t.close();
63
- process.exit(0);
64
- };
226
+ void announceWebhooks(workspaceDir, instance.url, { openUrl: openExternalUrl, routeChannels, stateRoot });
227
+ const cleanup = () => instance.close();
65
228
  process.once("SIGINT", cleanup);
66
229
  process.once("SIGTERM", cleanup);
67
230
  });
68
231
  }
69
232
  /**
70
233
  * Load and start the workspace's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
71
- * there are static schedules OR `selfSchedule` is on (the scheduler also polls the agent's self-scheduled
72
- * wake-ups, which the built-in `wake` tool creates only when opted in). Shares the SAME (trace-wrapped)
73
- * agent the routes serve, so a scheduled turn is observed like any other. Best-effort stop on exit; dev's
74
- * watch restart re-reads schedules with the worker (schedules are a code input). Single-process.
234
+ * there are static schedules OR `selfSchedule` is on. Best-effort stop on process signals.
75
235
  */
76
236
  export async function startSchedules(workspaceDir, agent, stateRoot, selfSchedule) {
77
237
  const { schedules, failures } = await loadSchedules(workspaceDir).catch(failStartup);
78
238
  reportModuleLoadFailures(failures);
79
- // Nothing to run when there are neither static `schedules/` nor self-scheduling (the `wake` tool, and
80
- // thus any wake-up to poll, is mounted only when config.selfSchedule is on) — skip the poller entirely.
81
239
  if (schedules.length === 0 && !selfSchedule)
82
240
  return;
83
241
  const scheduler = createScheduler({ agent, stateRoot, schedules });
@@ -1,3 +1,4 @@
1
+ import { type LoginIO, type LoginMethod, type LoginResult } from "../engines/pi/login.ts";
1
2
  /** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
2
3
  export declare function isInteractive(): boolean;
3
4
  /**
@@ -10,15 +11,34 @@ export declare function parsePort(value: string | undefined, source: string, fro
10
11
  /** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
11
12
  export declare function reportAuth(modelSpec: string, authPath: string): Promise<void>;
12
13
  /**
13
- * First-run model resolution for the serving commands. When no model is set (flag/env/config), and
14
- * we're on a TTY, pick one from the providers the user is logged into and persist the choice. A no-op
15
- * when a model is already set; on a non-TTY (CI/deploy), or with `--no-input`, or with nothing
16
- * configured it stays silent and lets the opener raise its clear "missing model" error. The pick is
17
- * exported to FASTAGENT_MODEL so a spawned `dev` worker inherits it, and best-effort written back to
18
- * the config so the next run is quiet.
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.
19
23
  */
20
24
  export declare function resolveFirstRunModel(workspaceDir: string, options?: {
21
25
  model?: string;
22
26
  authPath?: string;
23
27
  input?: boolean;
24
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>;