@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
@@ -4,8 +4,10 @@
4
4
  * it into the harness alongside the selected `model`; the two must come from the same collection so
5
5
  * the model's provider auth is in scope.
6
6
  */
7
- import { type Models, type Provider } from "@earendil-works/pi-ai";
7
+ import { type Api, type Model, type Models, type Provider } from "@earendil-works/pi-ai";
8
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
8
9
  import { type FastagentAuthOptions } from "./auth.ts";
10
+ import { type InteractiveLoginKind } from "./login.ts";
9
11
  export interface CreatePiModelsOptions extends FastagentAuthOptions {
10
12
  /** Credentials file path. Defaults to the global `~/.fastagent/auth.json`; the directory opener passes
11
13
  * the project-level `<dir>/.fastagent/auth.json`. */
@@ -22,16 +24,41 @@ export interface CreatePiModelsOptions extends FastagentAuthOptions {
22
24
  */
23
25
  export declare function createPiModels(options?: CreatePiModelsOptions): Models;
24
26
  /**
25
- * The "provider/modelId" specs whose provider currently has USABLE credentials (a stored login or an
26
- * env key) the menu for the first-run model picker (`fastagent dev`/`start`/`invoke` with no model
27
- * set). Auth is provider-scoped, so probe once per provider (any of its models) rather than per model.
28
- * A provider that resolves no auth (unconfigured) or rejects it (configured-but-expired) is omitted:
29
- * the picker offers only models that would actually run now; `fastagent login` fixes the rest. Sorted.
30
- *
31
- * pi deliberately has no "best/tier" ranking on Model, so this does not auto-pick — it narrows the menu
32
- * to what the user can use and lets them choose (mirroring pi-coding-agent's select-then-persist).
27
+ * The `ModelRuntime`-shaped sibling of {@link createPiModels} the SAME hub semantics (built-in
28
+ * providers + fastagent's credential store at `authPath`) in the type pi's session services require
29
+ * (`createAgentSessionServices({ modelRuntime })`). Builtins only (`modelsPath: null` pi's
30
+ * machine-global models.json is definition-foreign) and no availability network, so the model
31
+ * surface equals serving's. No `providers` option: `ModelRuntime` registers providers by config
32
+ * record, not `Provider` instance — accepting the option and dropping it would be a silent no-op;
33
+ * add the mapping when a consumer actually needs it.
33
34
  */
34
- export declare function configuredModelSpecs(models: Models): Promise<string[]>;
35
+ export declare function createPiModelRuntime(options?: FastagentAuthOptions & {
36
+ authPath?: string;
37
+ }): Promise<ModelRuntime>;
38
+ /** Per-provider auth status for the first-run model picker: usable now (with the source label), not
39
+ * configured, or configured-but-broken (expired token, refresh failure, corrupt store — kept as DATA
40
+ * so the picker can show it instead of silently dropping the provider). Non-ready states carry the
41
+ * provider's {@link InteractiveLoginKind}, so the picker's hint predicts what picking does — an
42
+ * OAuth login, an API-key prompt, or (env-key-only providers) neither. */
43
+ export type ProviderAuthStatus = {
44
+ state: "ready";
45
+ source?: string;
46
+ } | {
47
+ state: "unconfigured";
48
+ login: InteractiveLoginKind;
49
+ } | {
50
+ state: "broken";
51
+ message: string;
52
+ login: InteractiveLoginKind;
53
+ };
54
+ /**
55
+ * Probe every provider's auth once (auth is provider-scoped, so any of its models works as the probe)
56
+ * — the status map behind the first-run model picker (`fastagent dev`/`start`/`invoke` with no model
57
+ * set). The picker shows the FULL catalog annotated with these statuses, so "what fastagent supports"
58
+ * and "what is authenticated on this machine" stay distinguishable; a needs-login choice triggers an
59
+ * inline `loginFlow`. Providers with no models are omitted (nothing to pick).
60
+ */
61
+ export declare function providerAuthStatuses(models: Models): Promise<Map<string, ProviderAuthStatus>>;
35
62
  /**
36
63
  * Which source currently satisfies auth for `spec` — a startup diagnostic. Returns the upstream
37
64
  * `AuthResult.source` label: `"OAuth"` for a stored OAuth credential (e.g. a logged-in openai-codex),
@@ -39,3 +66,22 @@ export declare function configuredModelSpecs(models: Models): Promise<string[]>;
39
66
  * undefined when unconfigured. Reporting-only; never throws.
40
67
  */
41
68
  export declare function probeAuthSource(models: Models, spec: string): Promise<string | undefined>;
69
+ /** Verdict of {@link probeApiKey}: `rejected` is DEFINITIVE (the provider answered HTTP 401 — the key
70
+ * is wrong); everything else non-ok is `unknown` — a 403 can be a VALID key without model permission,
71
+ * a 429/5xx/network failure says nothing about the key — so callers must only destroy state on
72
+ * `rejected`. */
73
+ export type KeyProbe = {
74
+ state: "ok";
75
+ } | {
76
+ state: "rejected" | "unknown";
77
+ message: string;
78
+ };
79
+ /**
80
+ * Quick-fail probe for a just-stored API key: one minimal real request through the standard auth
81
+ * resolution path (the same path invokes take), so a mistyped key surfaces at login time, not at the
82
+ * first turn. `complete` reports provider errors as `stopReason: "error"` rather than throwing; the
83
+ * HTTP status arrives via `onResponse` — when a provider path never calls it (SDK transports), fall
84
+ * back to a conservative "401" match in the error text. Short timeout, no retries: feedback speed
85
+ * over transient-failure tolerance (a transient lands on `unknown`, which keeps the key).
86
+ */
87
+ export declare function probeApiKey(models: Models, model: Model<Api>): Promise<KeyProbe>;
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import { defaultProviderAuthContext } from "@earendil-works/pi-ai";
8
8
  import { builtinModels } from "@earendil-works/pi-ai/providers/all";
9
- import { log } from "../../log.js";
9
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
10
10
  import { fastagentCredentialStore } from "./auth.js";
11
+ import { interactiveLoginKind } from "./login.js";
11
12
  /**
12
13
  * A `Models` with every built-in pi provider, wired to fastagent's auth: stored credentials from the
13
14
  * {@link CreatePiModelsOptions.authPath} file (via {@link fastagentCredentialStore}; the global
@@ -25,38 +26,44 @@ export function createPiModels(options = {}) {
25
26
  return models;
26
27
  }
27
28
  /**
28
- * The "provider/modelId" specs whose provider currently has USABLE credentials (a stored login or an
29
- * env key) the menu for the first-run model picker (`fastagent dev`/`start`/`invoke` with no model
30
- * set). Auth is provider-scoped, so probe once per provider (any of its models) rather than per model.
31
- * A provider that resolves no auth (unconfigured) or rejects it (configured-but-expired) is omitted:
32
- * the picker offers only models that would actually run now; `fastagent login` fixes the rest. Sorted.
33
- *
34
- * pi deliberately has no "best/tier" ranking on Model, so this does not auto-pick — it narrows the menu
35
- * to what the user can use and lets them choose (mirroring pi-coding-agent's select-then-persist).
29
+ * The `ModelRuntime`-shaped sibling of {@link createPiModels} the SAME hub semantics (built-in
30
+ * providers + fastagent's credential store at `authPath`) in the type pi's session services require
31
+ * (`createAgentSessionServices({ modelRuntime })`). Builtins only (`modelsPath: null` pi's
32
+ * machine-global models.json is definition-foreign) and no availability network, so the model
33
+ * surface equals serving's. No `providers` option: `ModelRuntime` registers providers by config
34
+ * record, not `Provider` instance — accepting the option and dropping it would be a silent no-op;
35
+ * add the mapping when a consumer actually needs it.
36
36
  */
37
- export async function configuredModelSpecs(models) {
38
- const specs = [];
37
+ export function createPiModelRuntime(options = {}) {
38
+ return ModelRuntime.create({
39
+ credentials: fastagentCredentialStore(options.authPath, { warn: options.warn }),
40
+ modelsPath: null,
41
+ allowModelNetwork: false,
42
+ });
43
+ }
44
+ /**
45
+ * Probe every provider's auth once (auth is provider-scoped, so any of its models works as the probe)
46
+ * — the status map behind the first-run model picker (`fastagent dev`/`start`/`invoke` with no model
47
+ * set). The picker shows the FULL catalog annotated with these statuses, so "what fastagent supports"
48
+ * and "what is authenticated on this machine" stay distinguishable; a needs-login choice triggers an
49
+ * inline `loginFlow`. Providers with no models are omitted (nothing to pick).
50
+ */
51
+ export async function providerAuthStatuses(models) {
52
+ const statuses = new Map();
39
53
  for (const provider of models.getProviders()) {
40
54
  const [probe] = provider.getModels();
41
55
  if (!probe)
42
56
  continue;
43
- let usable;
57
+ const login = interactiveLoginKind(provider);
44
58
  try {
45
- usable = (await models.getAuth(probe)) !== undefined;
59
+ const auth = await models.getAuth(probe);
60
+ statuses.set(provider.id, auth ? { state: "ready", source: auth.source } : { state: "unconfigured", login });
46
61
  }
47
62
  catch (error) {
48
- // Configured-but-broken (expired token, a refresh network failure, a corrupt store): omit it
49
- // from the menu, but SAY so — a silent disappearance is the same fail-visibly gap the caller
50
- // guards against for the top-level enumeration. `undefined` (plainly unconfigured) stays quiet.
51
- log.warn(`[fastagent] skipping provider "${provider.id}": auth check failed (${error.message})`);
52
- continue;
63
+ statuses.set(provider.id, { state: "broken", message: error.message, login });
53
64
  }
54
- if (!usable)
55
- continue;
56
- for (const model of provider.getModels())
57
- specs.push(`${provider.id}/${model.id}`);
58
65
  }
59
- return specs.sort();
66
+ return statuses;
60
67
  }
61
68
  /**
62
69
  * Which source currently satisfies auth for `spec` — a startup diagnostic. Returns the upstream
@@ -74,3 +81,34 @@ export async function probeAuthSource(models, spec) {
74
81
  const auth = await models.getAuth(model).catch(() => undefined);
75
82
  return auth?.source;
76
83
  }
84
+ /**
85
+ * Quick-fail probe for a just-stored API key: one minimal real request through the standard auth
86
+ * resolution path (the same path invokes take), so a mistyped key surfaces at login time, not at the
87
+ * first turn. `complete` reports provider errors as `stopReason: "error"` rather than throwing; the
88
+ * HTTP status arrives via `onResponse` — when a provider path never calls it (SDK transports), fall
89
+ * back to a conservative "401" match in the error text. Short timeout, no retries: feedback speed
90
+ * over transient-failure tolerance (a transient lands on `unknown`, which keeps the key).
91
+ */
92
+ export async function probeApiKey(models, model) {
93
+ let status;
94
+ let reply;
95
+ try {
96
+ reply = await models.complete(model, { messages: [{ role: "user", content: "ping", timestamp: Date.now() }] }, {
97
+ maxTokens: 16,
98
+ timeoutMs: 15_000,
99
+ maxRetries: 0,
100
+ onResponse: (r) => {
101
+ status = r.status;
102
+ },
103
+ });
104
+ }
105
+ catch (error) {
106
+ // Thrown = before/around the request (auth resolution, transport setup) — not a provider verdict.
107
+ return { state: "unknown", message: error.message };
108
+ }
109
+ if (reply.stopReason !== "error" && reply.stopReason !== "aborted")
110
+ return { state: "ok" };
111
+ const message = reply.errorMessage ?? `stopReason "${reply.stopReason}"`;
112
+ const unauthorized = status === 401 || (status === undefined && /(^|\D)401(\D|$)/.test(message));
113
+ return { state: unauthorized ? "rejected" : "unknown", message };
114
+ }
@@ -0,0 +1,10 @@
1
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own. */
3
+ export declare function withSearchTool(tools: AgentTool[]): AgentTool[];
4
+ /** Build the `search_tools` loader. Keyword search over the inactive tools' name+description.
5
+ *
6
+ * `executionMode: "sequential"` — pi turns any batch containing a sequential tool serial. Required for
7
+ * correct load-point attribution everywhere an OUTER active-set diff exists: pi wraps SDK customTools
8
+ * (the chat path) in a before/after diff, and two parallel loader calls would both snapshot the
9
+ * pre-activation set and get stamped with the same activation. Custom loader authors must set it too. */
10
+ export declare function makeSearchToolsTool(): AgentTool;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The built-in `search_tools` loader — the discovery surface for deferred tools (defineTool
3
+ * `deferred: true`). A deferred tool's schema is not in the request and the model cannot see it; this
4
+ * loader is how it finds and activates one. Mounted automatically (withSearchTool) only when a
5
+ * deferred tool exists; a workspace tool named `search_tools` wins — the author owns the concept then
6
+ * (same rule as the wake pair).
7
+ */
8
+ import { z } from "zod";
9
+ import { log } from "../../log.js";
10
+ import { defineTool, isDeferredTool, stripDeferredMarker } from "./tool.js";
11
+ /** Mount the built-in loader iff any mounted tool is deferred and the author didn't define their own. */
12
+ export function withSearchTool(tools) {
13
+ if (!tools.some(isDeferredTool))
14
+ return tools;
15
+ const authored = tools.find((t) => t.name === "search_tools");
16
+ if (!authored)
17
+ return [...tools, makeSearchToolsTool()];
18
+ let fixed = authored;
19
+ if (isDeferredTool(fixed)) {
20
+ // A deferred LOADER is a contradiction — it is the only entry point to the deferred tools, so
21
+ // nothing could ever activate it (or, through it, them): every deferred tool would be silently
22
+ // unreachable. Ignore the marker and keep the loader active (fail visibly, keep the capability).
23
+ log.warn("[fastagent] search_tools is marked deferred — ignoring the marker: the loader must stay active, or no deferred tool could ever be activated");
24
+ fixed = stripDeferredMarker(fixed);
25
+ }
26
+ if (fixed.executionMode !== "sequential") {
27
+ // A non-sequential loader silently revives the parallel double-attribution (pi's diff around SDK
28
+ // tools in chat) — enforce the mode rather than hope the author read the docs; warn so they know.
29
+ log.warn('[fastagent] search_tools lacks executionMode: "sequential" — forcing it: parallel loader calls would misattribute activations');
30
+ fixed = { ...fixed, executionMode: "sequential" };
31
+ }
32
+ return fixed === authored ? tools : tools.map((t) => (t === authored ? fixed : t));
33
+ }
34
+ /** Activation cap per search: activation is additive, session-persisted, and has NO deactivate path —
35
+ * without a cap, one broad token ("get", "file") would permanently activate half the catalog and
36
+ * silently spend the entire deferral benefit for the rest of the conversation. Over the cap nothing
37
+ * activates; the model gets the candidates and narrows the query. */
38
+ const MAX_ACTIVATIONS_PER_SEARCH = 5;
39
+ /** Miss-path listing cap — same rationale as the activation cap: a typo query must not pour the whole
40
+ * catalog (the thing deferral keeps OUT of the context) back in as a tool result. */
41
+ const MAX_MISS_LISTING = 10;
42
+ /** Build the `search_tools` loader. Keyword search over the inactive tools' name+description.
43
+ *
44
+ * `executionMode: "sequential"` — pi turns any batch containing a sequential tool serial. Required for
45
+ * correct load-point attribution everywhere an OUTER active-set diff exists: pi wraps SDK customTools
46
+ * (the chat path) in a before/after diff, and two parallel loader calls would both snapshot the
47
+ * pre-activation set and get stamped with the same activation. Custom loader authors must set it too. */
48
+ export function makeSearchToolsTool() {
49
+ return defineTool({
50
+ name: "search_tools",
51
+ executionMode: "sequential",
52
+ description:
53
+ // First line short on purpose: the base prompt's tools list truncates at the first newline, and
54
+ // the discovery guidance below would otherwise flood it (and duplicate its deferred note).
55
+ "Discover and activate additional tools.\n" +
56
+ "Part of this agent's toolset is inactive until needed: search by keywords (e.g. what you are " +
57
+ "trying to do), and matching inactive tools are activated and become callable from that point on " +
58
+ "(if too many match, you get the candidates back — narrow the query, or query an exact tool " +
59
+ "name). ALWAYS search here before concluding a capability is missing.",
60
+ input: z.object({
61
+ query: z.string().min(1).describe("keywords describing the capability you need (e.g. 'weather forecast')"),
62
+ }),
63
+ async execute(input, ctx) {
64
+ if (!ctx.tools)
65
+ return "tool activation is unavailable outside a conversation turn.";
66
+ // Search the WHOLE registered catalog: the loader is the only discovery surface, and in a long
67
+ // conversation the model does not remember what it activated — a "No tools matched" answer for
68
+ // an ALREADY-ACTIVE tool would push it toward the exact wrong conclusion (capability missing).
69
+ // ponytail: naive keyword match (any query token as a case-insensitive substring of
70
+ // name+description) with a hard per-search activation cap above — the two named ceilings are
71
+ // relevance and irreversibility; swap in scoring/embeddings if catalogs outgrow this.
72
+ const tokens = input.query
73
+ .toLowerCase()
74
+ .split(/[^a-z0-9]+/)
75
+ .filter(Boolean);
76
+ // AND semantics: EVERY token must hit — "adding a word narrows" must actually hold, or the
77
+ // over-cap "narrow the query" instruction sends the model in circles (OR would widen with each
78
+ // word, and a shared prefix like "fetch" could make a whole tool family permanently over-cap).
79
+ const matchesQuery = (t) => {
80
+ const haystack = `${t.name} ${t.description}`.toLowerCase();
81
+ return tokens.every((token) => haystack.includes(token));
82
+ };
83
+ const describe = (t) => `${t.name} — ${t.description.split("\n")[0]}`;
84
+ const active = new Set(ctx.tools.active());
85
+ const registered = ctx.tools.registered();
86
+ // A query with no searchable tokens (all punctuation/symbols) must not match: `every` over an
87
+ // empty token list is vacuously true, and a noise query would otherwise activate the catalog.
88
+ if (tokens.length === 0)
89
+ return `"${input.query}" contains no searchable keywords — describe the capability you need.`;
90
+ // Exact-name shortcut: the guaranteed escape hatch from the cap — a query that IS an INACTIVE
91
+ // registered tool's name addresses that one tool, no keyword scoring in the way. An exact match
92
+ // on an ACTIVE tool falls through to keyword matching: it must not swallow the discovery of
93
+ // other, still-inactive keyword matches.
94
+ const exact = registered.find((t) => t.name.toLowerCase() === input.query.trim().toLowerCase());
95
+ const activeMatches = registered.filter((t) => active.has(t.name) && (t === exact || matchesQuery(t)));
96
+ const inactiveMatches = exact && !active.has(exact.name) ? [exact] : registered.filter((t) => !active.has(t.name) && matchesQuery(t));
97
+ // Same listing cap as every other branch — a wide token can match most of the ACTIVE set too
98
+ // (in chat that includes pi's default tools), and no answer may pour a catalog into the context.
99
+ const listedActive = activeMatches.slice(0, MAX_MISS_LISTING);
100
+ const moreActive = activeMatches.length - listedActive.length;
101
+ const activeNote = activeMatches.length > 0
102
+ ? `Already active (call directly): ${listedActive.map(describe).join("; ")}${moreActive > 0 ? ` … and ${moreActive} more` : ""}.`
103
+ : "";
104
+ if (inactiveMatches.length === 0) {
105
+ if (activeNote)
106
+ return activeNote;
107
+ const inactive = registered.filter((t) => !active.has(t.name));
108
+ if (inactive.length === 0)
109
+ return "All tools are already active — nothing to discover.";
110
+ // Cap the miss listing like the activation cap — both guard the same semantic (don't pour the
111
+ // catalog back into the context the deferral exists to protect).
112
+ const listed = inactive.slice(0, MAX_MISS_LISTING);
113
+ const more = inactive.length - listed.length;
114
+ return `No tools matched "${input.query}". Inactive tools: ${listed.map(describe).join("; ")}${more > 0 ? ` … and ${more} more — search with different keywords.` : ""}`;
115
+ }
116
+ if (inactiveMatches.length > MAX_ACTIVATIONS_PER_SEARCH) {
117
+ // Same listing cap as the miss path — an over-cap answer must not pour the catalog into the
118
+ // context either. Names alone suffice: the exact-name escape only needs a name to query.
119
+ const listed = inactiveMatches.slice(0, MAX_MISS_LISTING);
120
+ const more = inactiveMatches.length - listed.length;
121
+ return `${inactiveMatches.length} inactive tools matched "${input.query}" — too many to activate at once (activation is permanent for this conversation). Narrow the query (or query an exact name). Matches: ${listed
122
+ .map((t) => t.name)
123
+ .join(", ")}${more > 0 ? ` … and ${more} more` : ""}.${activeNote ? ` ${activeNote}` : ""}`;
124
+ }
125
+ const activated = await ctx.tools.activate(inactiveMatches.map((t) => t.name));
126
+ // Report what actually happened, not what was attempted: a parallel sibling call may have
127
+ // activated the same matches first, leaving nothing new here — an empty "Activated:" would lie.
128
+ if (activated.length === 0) {
129
+ return `Matched ${inactiveMatches.map((t) => t.name).join(", ")} — already active (possibly activated by a concurrent call). Call them directly.${activeNote ? ` ${activeNote}` : ""}`;
130
+ }
131
+ const raced = inactiveMatches.filter((t) => !activated.includes(t.name));
132
+ return `Activated: ${inactiveMatches
133
+ .filter((t) => activated.includes(t.name))
134
+ .map(describe)
135
+ .join("; ")}.${raced.length > 0 ? ` Already active: ${raced.map((t) => t.name).join(", ")}.` : ""}${activeNote ? ` ${activeNote}` : ""} These tools are callable now.`;
136
+ },
137
+ });
138
+ }
@@ -0,0 +1,16 @@
1
+ import { type AgentSessionRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
2
+ export interface BuildSessionRuntimeOptions {
3
+ /** Model spec override (the CLI --model flag). Precedence: this > FASTAGENT_MODEL > config.model. */
4
+ model?: string;
5
+ /** Credentials file override (the CLI --auth-path flag). Precedence: this > FASTAGENT_AUTH_PATH >
6
+ * the workspace default `<stateRoot>/auth.json`. */
7
+ authPath?: string;
8
+ }
9
+ /**
10
+ * Build pi's interactive runtime driven by fastagent's assembled agent (model, prompt, tools,
11
+ * skills, and auth resolved exactly as the serving opener does). Split from the TUI launcher so the
12
+ * assembly — the fidelity-critical part — is inspectable and reusable without launching a TUI.
13
+ */
14
+ export declare function buildWorkspaceSessionRuntime(dir: string, options?: BuildSessionRuntimeOptions,
15
+ /** Session backend. Defaults to pi's project-scoped store; tests inject SessionManager.inMemory(). */
16
+ sessionManager?: SessionManager): Promise<AgentSessionRuntime>;