@fastagent-sh/fastagent 0.22.1 → 0.23.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 (103) hide show
  1. package/dist/channels/agentcore-protocol.d.ts +2 -1
  2. package/dist/channels/agentcore-service.js +10 -18
  3. package/dist/channels/agentcore.js +6 -3
  4. package/dist/channels/control.js +23 -0
  5. package/dist/channels/slack/config-api.d.ts +2 -2
  6. package/dist/channels/slack/config-api.js +2 -2
  7. package/dist/channels/slack/onboard.js +2 -2
  8. package/dist/channels/slack/register-webhook.js +2 -2
  9. package/dist/channels/slack/slack-api.js +4 -4
  10. package/dist/cli/add-feishu.js +4 -2
  11. package/dist/cli/auth-view.d.ts +15 -6
  12. package/dist/cli/auth-view.js +24 -8
  13. package/dist/cli/box-login.d.ts +33 -0
  14. package/dist/cli/box-login.js +162 -0
  15. package/dist/cli/commands/deploy/agentcore.js +48 -11
  16. package/dist/cli/commands/deploy/docker.js +24 -13
  17. package/dist/cli/commands/deploy/fly.js +30 -7
  18. package/dist/cli/commands/deploy/railway.js +21 -7
  19. package/dist/cli/commands/deploy/shared.d.ts +18 -17
  20. package/dist/cli/commands/deploy/shared.js +20 -21
  21. package/dist/cli/commands/destroy.js +2 -2
  22. package/dist/cli/commands/info.js +9 -6
  23. package/dist/cli/commands/invoke.js +2 -2
  24. package/dist/cli/commands/login.d.ts +7 -1
  25. package/dist/cli/commands/login.js +123 -10
  26. package/dist/cli/commands/logs.js +2 -2
  27. package/dist/cli/commands/models.d.ts +4 -1
  28. package/dist/cli/commands/models.js +37 -4
  29. package/dist/cli/commands/routine-run.js +2 -2
  30. package/dist/cli/commands/start.d.ts +0 -5
  31. package/dist/cli/commands/start.js +3 -13
  32. package/dist/cli/kernel.d.ts +2 -0
  33. package/dist/cli/kernel.js +1 -1
  34. package/dist/cli/login-relay.d.ts +38 -0
  35. package/dist/cli/login-relay.js +121 -0
  36. package/dist/cli/program.js +46 -8
  37. package/dist/cli/shared.d.ts +4 -4
  38. package/dist/cli/shared.js +24 -30
  39. package/dist/deploy/agentcore/plan.d.ts +10 -11
  40. package/dist/deploy/agentcore/plan.js +8 -14
  41. package/dist/deploy/agentcore/run.d.ts +8 -1
  42. package/dist/deploy/agentcore/run.js +20 -14
  43. package/dist/deploy/agentcore/shell.d.ts +25 -0
  44. package/dist/deploy/agentcore/shell.js +202 -0
  45. package/dist/deploy/box-shell.d.ts +43 -0
  46. package/dist/deploy/box-shell.js +28 -0
  47. package/dist/deploy/build-context.js +16 -7
  48. package/dist/deploy/channel-ingress.d.ts +20 -1
  49. package/dist/deploy/channel-ingress.js +34 -7
  50. package/dist/deploy/container.d.ts +14 -0
  51. package/dist/deploy/container.js +45 -1
  52. package/dist/deploy/docker/plan.d.ts +7 -3
  53. package/dist/deploy/docker/plan.js +9 -14
  54. package/dist/deploy/docker/run.d.ts +14 -7
  55. package/dist/deploy/docker/run.js +58 -35
  56. package/dist/deploy/fly/plan.d.ts +6 -3
  57. package/dist/deploy/fly/plan.js +6 -8
  58. package/dist/deploy/fly/run.d.ts +8 -2
  59. package/dist/deploy/fly/run.js +15 -25
  60. package/dist/deploy/preflight.d.ts +9 -7
  61. package/dist/deploy/preflight.js +74 -20
  62. package/dist/deploy/railway/plan.d.ts +6 -3
  63. package/dist/deploy/railway/plan.js +7 -9
  64. package/dist/deploy/railway/run.d.ts +10 -6
  65. package/dist/deploy/railway/run.js +29 -13
  66. package/dist/deploy/runner.d.ts +16 -0
  67. package/dist/deploy/runner.js +31 -0
  68. package/dist/deploy/secrets.d.ts +4 -13
  69. package/dist/deploy/secrets.js +8 -27
  70. package/dist/dev-supervisor.js +5 -2
  71. package/dist/engines/pi/agent-models.d.ts +70 -0
  72. package/dist/engines/pi/agent-models.js +93 -0
  73. package/dist/engines/pi/auth.d.ts +56 -3
  74. package/dist/engines/pi/auth.js +63 -16
  75. package/dist/engines/pi/config.d.ts +0 -25
  76. package/dist/engines/pi/config.js +3 -37
  77. package/dist/engines/pi/create.d.ts +22 -16
  78. package/dist/engines/pi/create.js +28 -28
  79. package/dist/engines/pi/entry-images.d.ts +14 -0
  80. package/dist/engines/pi/entry-images.js +23 -0
  81. package/dist/engines/pi/invoke-session.js +6 -4
  82. package/dist/engines/pi/login.d.ts +20 -13
  83. package/dist/engines/pi/login.js +19 -12
  84. package/dist/engines/pi/models.d.ts +115 -36
  85. package/dist/engines/pi/models.js +218 -48
  86. package/dist/engines/pi/open.d.ts +42 -11
  87. package/dist/engines/pi/open.js +53 -21
  88. package/dist/engines/pi/service.d.ts +2 -2
  89. package/dist/engines/pi/service.js +1 -0
  90. package/dist/engines/pi/session-builder.d.ts +3 -2
  91. package/dist/engines/pi/session-builder.js +28 -58
  92. package/dist/engines/pi/session-control.js +14 -3
  93. package/dist/engines/pi/turn-kit.js +11 -5
  94. package/dist/open-url.d.ts +15 -1
  95. package/dist/open-url.js +35 -3
  96. package/dist/paths.d.ts +4 -2
  97. package/dist/paths.js +6 -2
  98. package/dist/pi.d.ts +5 -3
  99. package/dist/pi.js +3 -2
  100. package/dist/service.d.ts +5 -8
  101. package/dist/session-remote.js +21 -0
  102. package/dist/session.d.ts +28 -7
  103. package/package.json +2 -1
@@ -1,24 +1,18 @@
1
1
  /**
2
- * The pi `Models` collection — the single hub that owns BOTH model resolution (provider/modelId lookup) AND auth
3
- * (per-request credential resolution). fastagent builds one per opener and threads it into the engine alongside the
4
- * selected `model`; the two must come from the same collection so the model's provider auth is in scope.
2
+ * Building pi's model registry: the built-ins, an agent's `models.json` and model catalog layered over the machine's,
3
+ * the catalog refresh, and the reads of those files a report or a deploy check needs. Which credential store an agent
4
+ * runs it over is agent-models.ts's; registry and auth are one collection, so the model's provider auth is in scope.
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { existsSync } from "node:fs";
8
8
  import { readFile } from "node:fs/promises";
9
- import { homedir } from "node:os";
10
9
  import { join } from "node:path";
11
- import { InMemoryCredentialStore, defaultProviderAuthContext, } from "@earendil-works/pi-ai";
12
- import { builtinModels, builtinProviders } from "@earendil-works/pi-ai/providers/all";
10
+ import { InMemoryCredentialStore, InMemoryModelsStore, defaultProviderAuthContext, } from "@earendil-works/pi-ai";
11
+ import { builtinModels, builtinProviders, getBuiltinModelDataGeneratedAt } from "@earendil-works/pi-ai/providers/all";
13
12
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
14
- import { fastagentCredentialStore } from "./auth.js";
15
- import { providerOf } from "./config.js";
16
- import { AGENT_MODELS_FILE, GLOBAL_HOME_DIR, resolveOverridePath, resolveStateRoot } from "../../paths.js";
13
+ import { GLOBAL_AUTH_PATH, fastagentCredentialStore } from "./auth.js";
14
+ import { AGENT_MODEL_CATALOG_FILE, AGENT_MODELS_FILE, globalHome, resolveOverridePath } from "../../paths.js";
17
15
  import { writeFileAtomic } from "../../atomic-write.js";
18
- /** A `Models` with every built-in pi provider, wired to fastagent's auth. */
19
- export function createPiModels(options = {}) {
20
- return piModelsOver(fastagentCredentialStore(options.authPath, { warn: options.warn }), options.providers);
21
- }
22
16
  /** The built-ins plus `providers` (a same id replaces a built-in), over any credential store. */
23
17
  export function piModelsOver(credentials, providers = []) {
24
18
  const models = builtinModels({ credentials, authContext: defaultProviderAuthContext() });
@@ -33,8 +27,8 @@ export const DEFAULT_THINKING_LEVEL = "medium";
33
27
  * (a local Ollama, a company gateway), which every agent here inherits the way it inherits the machine's skills.
34
28
  * `FASTAGENT_MODELS_PATH` moves it. It never ships: a deployed agent has only its definition's file.
35
29
  */
36
- export function machineModelsPath(env = process.env) {
37
- return resolveOverridePath(env.FASTAGENT_MODELS_PATH) ?? join(homedir(), GLOBAL_HOME_DIR, AGENT_MODELS_FILE);
30
+ function machineModelsPath(env = process.env) {
31
+ return resolveOverridePath(env.FASTAGENT_MODELS_PATH) ?? join(globalHome(), AGENT_MODELS_FILE);
38
32
  }
39
33
  /** A custom-endpoint file's providers by id, or undefined when there is no file. Strict JSON: see {@link modelLayers}. */
40
34
  async function readProviders(path) {
@@ -95,7 +89,7 @@ async function modelsFileFor(agentDir) {
95
89
  return { path: definition };
96
90
  const content = JSON.stringify({ providers: { ...layers.machine, ...layers.own } }, null, 2);
97
91
  const hash = createHash("sha256").update(content).digest("hex").slice(0, 32);
98
- const path = join(homedir(), GLOBAL_HOME_DIR, ".cache", "models", `${hash}.json`);
92
+ const path = join(globalHome(), ".cache", "models", `${hash}.json`);
99
93
  // 0600: it may carry literal keys. Several processes may create the same snapshot at once.
100
94
  if (!existsSync(path))
101
95
  writeFileAtomic(path, content, 0o600, true);
@@ -120,25 +114,190 @@ export async function machineModels(agentDir) {
120
114
  export function isBuiltinProvider(providerId) {
121
115
  return builtinProviders().some((provider) => provider.id === providerId);
122
116
  }
123
- /** The `ModelRuntime`-shaped sibling of {@link createPiModels}. */
124
- export async function createPiModelRuntime(options = {}) {
117
+ /**
118
+ * The MACHINE's model catalog: the models pi.dev lists that are newer than the catalog bundled with pi, fetched by
119
+ * `fastagent models --refresh -g`. Every agent here reads it under its own; like `~/.fastagent/models.json`, it
120
+ * never ships.
121
+ */
122
+ export function globalCatalogPath() {
123
+ return join(globalHome(), AGENT_MODEL_CATALOG_FILE);
124
+ }
125
+ /** A catalog file as pi writes it (entries by provider), or `{}` when there is none. */
126
+ async function readCatalog(path) {
127
+ if (!existsSync(path))
128
+ return {};
129
+ try {
130
+ return JSON.parse(await readFile(path, "utf8"));
131
+ }
132
+ catch (error) {
133
+ throw new Error(`model catalog ${path} is not valid JSON (${error.message}): refresh or delete it`);
134
+ }
135
+ }
136
+ /**
137
+ * The catalog files layered into one read-only store, later files winning a model id. An entry pi would ignore (no
138
+ * newer than the catalog bundled with it) is dropped before the merge, so a stale layer cannot ride a newer one's
139
+ * date past pi's rule.
140
+ *
141
+ * Read here rather than handed to pi as a file: pi takes one store, and its file store creates the file (and a lock)
142
+ * on read, which would drop an empty catalog into every agent dir that was never refreshed.
143
+ */
144
+ async function layeredCatalog(paths) {
145
+ const bundledAt = getBuiltinModelDataGeneratedAt();
146
+ const merged = new Map();
147
+ for (const path of paths) {
148
+ for (const [provider, entry] of Object.entries(await readCatalog(path))) {
149
+ if (bundledAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= bundledAt))
150
+ continue;
151
+ const held = merged.get(provider);
152
+ const ids = new Set(entry.models.map((model) => model.id));
153
+ merged.set(provider, held
154
+ ? {
155
+ models: [...held.models.filter((model) => !ids.has(model.id)), ...entry.models],
156
+ lastModified: Math.max(held.lastModified ?? 0, entry.lastModified ?? 0),
157
+ }
158
+ : entry);
159
+ }
160
+ }
161
+ const store = new InMemoryModelsStore();
162
+ for (const [provider, entry] of merged)
163
+ await store.write(provider, entry);
164
+ return store;
165
+ }
166
+ /**
167
+ * Whether the machine's model catalog supplies this model, by the same rule every registry reads it with
168
+ * ({@link layeredCatalog}): an entry pi would ignore supplies nothing. The one layer a deployed agent does not have.
169
+ */
170
+ export async function inGlobalCatalog(provider, id) {
171
+ const entry = await (await layeredCatalog([globalCatalogPath()])).read(provider);
172
+ return entry?.models.some((model) => model.id === id) ?? false;
173
+ }
174
+ /** The models.json a runtime for these options loads, and which model catalog it reads. */
175
+ export async function modelRuntimeFiles(options) {
125
176
  const { agentDir } = options;
177
+ const machine = options.machineLayer !== false;
126
178
  const models = !agentDir
127
179
  ? undefined
128
- : options.machineLayer === false
129
- ? { path: join(agentDir, AGENT_MODELS_FILE) }
130
- : await modelsFileFor(agentDir);
180
+ : machine
181
+ ? await modelsFileFor(agentDir)
182
+ : { path: join(agentDir, AGENT_MODELS_FILE) };
183
+ const catalogs = !agentDir
184
+ ? []
185
+ : [...(machine ? [globalCatalogPath()] : []), join(agentDir, AGENT_MODEL_CATALOG_FILE)];
186
+ return {
187
+ models,
188
+ create: {
189
+ modelsPath: models?.path ?? null,
190
+ // Always a store of our own whenever modelsPath is set: pi's default is a file at
191
+ // `<dirname(modelsPath)>/models-store.json`. Without a directory, pi keeps an empty one in memory.
192
+ ...(options.catalogFile
193
+ ? { modelsStorePath: options.catalogFile }
194
+ : agentDir
195
+ ? { modelsStore: await layeredCatalog(catalogs) }
196
+ : {}),
197
+ // Never fetched while a runtime is built: serving stays offline and reproducible. A refresh is asked for.
198
+ allowModelNetwork: false,
199
+ ...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
200
+ },
201
+ };
202
+ }
203
+ /**
204
+ * This machine's registry, without an agent: pi's built-ins, the machine's models.json and its model catalog. What
205
+ * `fastagent models` lists outside an agent; {@link catalogFile} is the refresh's (it writes the file).
206
+ */
207
+ export async function machineModelRuntime(options = {}) {
131
208
  const runtime = await ModelRuntime.create({
132
- credentials: options.credentials ??
133
- fastagentCredentialStore(options.auth?.path, { warn: options.warn, fallbackPath: options.auth?.fallback }),
134
- modelsPath: models?.path ?? null,
135
- // MUST be set whenever modelsPath is: pi defaults this to `<dirname(modelsPath)>/models-store.json`, which would
136
- // write a generated cache INTO the author's agent dir.
137
- ...(agentDir
138
- ? { modelsStorePath: join(options.stateRoot ?? resolveStateRoot(agentDir), "models-store.json") }
139
- : {}),
209
+ credentials: options.credentials ?? new InMemoryCredentialStore(),
210
+ modelsPath: machineModelsPath(),
211
+ ...(options.catalogFile
212
+ ? { modelsStorePath: options.catalogFile }
213
+ : { modelsStore: await layeredCatalog([globalCatalogPath()]) }),
140
214
  allowModelNetwork: false,
215
+ ...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
216
+ });
217
+ const error = runtime.getError();
218
+ if (error)
219
+ throw new Error(error);
220
+ return runtime;
221
+ }
222
+ /**
223
+ * Refresh the machine's catalog ({@link globalCatalogPath}) with the machine's credentials: the global credentials
224
+ * file and the environment. See {@link refreshCatalog} for what it asks and when it rejects.
225
+ */
226
+ export async function refreshGlobalModelCatalog(options = {}) {
227
+ const credentials = fastagentCredentialStore(GLOBAL_AUTH_PATH);
228
+ await refreshCatalog((catalogFile) => machineModelRuntime({
229
+ credentials,
230
+ ...(catalogFile ? { catalogFile: globalCatalogPath() } : {}),
231
+ ...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
232
+ }), options.signal ? { signal: options.signal } : {});
233
+ }
234
+ /** How long a catalog refresh may take, as `pi update --models` allows. */
235
+ const CATALOG_REFRESH_TIMEOUT_MS = 15_000;
236
+ /**
237
+ * Fetch the model catalog of every provider the runtime can authenticate into its catalog file: the refresh
238
+ * `pi update --models` runs. `runtime(true)` is built over that file, `runtime(false)` over the read-only layers; the
239
+ * file-backed one is built only once the refresh is known to run, because pi's file store creates the file on read and
240
+ * a refused refresh must leave nothing behind. pi asks pi.dev only for a provider with a usable
241
+ * credential, and may refresh an expired OAuth token of the runtime's store to get one. Rejects, naming each provider
242
+ * that failed, when any part fails, when it outlasts 15 seconds, when `PI_OFFLINE` is set, and when no provider has a
243
+ * usable credential (the refresh would ask for nothing).
244
+ */
245
+ export async function refreshCatalog(runtime, options = {}) {
246
+ // pi skips its own background refresh under PI_OFFLINE, but an explicit `allowNetwork: true` overrides that.
247
+ if (process.env.PI_OFFLINE !== undefined)
248
+ throw new Error("PI_OFFLINE is set, so the model catalog is not refreshed");
249
+ // pi skips a provider it cannot authenticate without recording anything, so with no usable credential at all the
250
+ // refresh would "succeed" having asked for nothing.
251
+ const probe = await runtime(false);
252
+ const refreshable = probe.getProviders().filter((provider) => provider.refreshModels !== undefined);
253
+ const usable = await Promise.all(refreshable.map(async (provider) => (await probe.checkAuth(provider.id)) !== undefined));
254
+ if (!usable.includes(true)) {
255
+ throw new Error("no provider has a usable credential here, so there is no model catalog to fetch — log in " +
256
+ "(`fastagent login`) or set a provider's API key first");
257
+ }
258
+ const timeout = AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS);
259
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
260
+ const result = await (await runtime(true)).refresh({ allowNetwork: true, force: true, signal });
261
+ if (result.aborted) {
262
+ throw new Error(options.signal?.aborted
263
+ ? "model catalog refresh cancelled"
264
+ : `model catalog refresh did not finish within ${CATALOG_REFRESH_TIMEOUT_MS / 1000}s`);
265
+ }
266
+ if (result.errors.size > 0) {
267
+ const details = [...result.errors].map(([provider, error]) => `${provider}: ${error.message}`).join("; ");
268
+ throw new Error(`could not refresh the model catalog: ${details}`);
269
+ }
270
+ }
271
+ /**
272
+ * What satisfies a provider from `env` alone (its API-key variable, or an ambient source such as an AWS profile), by
273
+ * pi's own check, or undefined. Passed explicitly rather than read from `process.env`: only the environment a SHELL
274
+ * hands down is shared by every agent on the machine, and a process that has loaded an agent's `.secrets/.env` holds
275
+ * that agent's variables too. What the shell shares outranks the global credentials file (agent-models.ts),
276
+ * so a global login for such a provider is not used.
277
+ *
278
+ * `fileExists` answers for the files a source points at (an AWS profile, Google ADC): this machine's by default, which
279
+ * is right wherever the answer is about THIS machine. A deployment passes one that finds nothing, because no file of
280
+ * the builder's travels with it.
281
+ */
282
+ export async function environmentAuthSource(providerId, env, fileExists = defaultProviderAuthContext().fileExists) {
283
+ const models = builtinModels({
284
+ credentials: new InMemoryCredentialStore(),
285
+ authContext: {
286
+ // pi's own reading of a variable (a blank one is unset), over the given environment.
287
+ env: async (name) => (env[name]?.trim() ? env[name] : undefined),
288
+ fileExists,
289
+ },
141
290
  });
291
+ return (await models.checkAuth(providerId))?.source;
292
+ }
293
+ /**
294
+ * A registry over the given credential store. What an agent runs on is built through `agentModels`
295
+ * (agent-models.ts); this is for a registry over some OTHER store: none at all (`models`, a deploy check of what
296
+ * ships), a trial key under test (login), or the catalog file a refresh writes.
297
+ */
298
+ export async function createPiModelRuntime(options) {
299
+ const { models, create } = await modelRuntimeFiles(options);
300
+ const runtime = await ModelRuntime.create({ credentials: options.credentials, ...create });
142
301
  // A malformed models.json does NOT throw upstream — `create` resolves with the built-ins and parks the reason in
143
302
  // getError().
144
303
  const error = runtime.getError();
@@ -152,17 +311,6 @@ export async function createPiModelRuntime(options = {}) {
152
311
  runtime.registerNativeProvider(provider);
153
312
  return runtime;
154
313
  }
155
- /** How a model's credential will REACH a deployed agent. */
156
- export function modelCredentialCarry(runtime, spec) {
157
- const status = runtime.getProviderAuthStatus(providerOf(spec));
158
- if (!status.configured)
159
- return { inDefinition: false };
160
- // An env-var name is only useful downstream if it IS one.
161
- if (status.source === "environment" && status.label && /^[A-Z][A-Z0-9_]*$/.test(status.label)) {
162
- return { envVar: status.label, inDefinition: false };
163
- }
164
- return { inDefinition: status.source !== "stored" };
165
- }
166
314
  /**
167
315
  * EVERY provider whose `models.json` entry writes its key as a LITERAL rather than a `"$NAME"` reference or a
168
316
  * `"!cmd"`. The file ships inside the image, so a literal there is a credential in a readable layer.
@@ -178,6 +326,27 @@ export function modelCredentialCarry(runtime, spec) {
178
326
  * gates what IT causes; what the author wrote into their own committed file, it reports.
179
327
  */
180
328
  export async function literalKeyProviders(agentDir) {
329
+ return Object.entries(await definitionApiKeys(agentDir))
330
+ .filter(([, apiKey]) => isLiteralKey(apiKey))
331
+ .map(([id]) => id);
332
+ }
333
+ /**
334
+ * How the agent's OWN models.json supplies `providerId`'s key, read from the file for the reason
335
+ * {@link literalKeyProviders} gives: `reference` is the variable a `"$NAME"` reads (the value file must hold it),
336
+ * `inFile` a key that travels with the definition itself (a literal, or a `"!command"` run on the box), and undefined
337
+ * no apiKey there at all.
338
+ */
339
+ export async function definitionKeyOf(agentDir, providerId) {
340
+ const apiKey = (await definitionApiKeys(agentDir))[providerId];
341
+ if (typeof apiKey !== "string" || apiKey === "")
342
+ return undefined;
343
+ if (apiKey.startsWith("!") || isLiteralKey(apiKey))
344
+ return { inFile: true };
345
+ // Not a literal, so isLiteralKey's own pattern matched: there is a name to read.
346
+ return { reference: apiKey.replaceAll("$$", "").match(/\$\{?([A-Za-z_][A-Za-z0-9_]*)/)?.[1] };
347
+ }
348
+ /** Each provider's `apiKey` as the agent's own models.json writes it. */
349
+ async function definitionApiKeys(agentDir) {
181
350
  const file = join(agentDir, AGENT_MODELS_FILE);
182
351
  let raw;
183
352
  try {
@@ -186,15 +355,13 @@ export async function literalKeyProviders(agentDir) {
186
355
  catch (error) {
187
356
  // No custom endpoints is the normal case; anything else (unreadable, a directory) is the caller's problem.
188
357
  if (error.code === "ENOENT")
189
- return [];
358
+ return {};
190
359
  throw error;
191
360
  }
192
361
  // Malformed JSON already threw out of createPiModelRuntime before this runs, so a parse failure here would be a
193
362
  // genuine surprise and must not be swallowed.
194
363
  const providers = JSON.parse(raw).providers ?? {};
195
- return Object.entries(providers)
196
- .filter(([, provider]) => isLiteralKey(provider?.apiKey))
197
- .map(([id]) => id);
364
+ return Object.fromEntries(Object.entries(providers).map(([id, provider]) => [id, provider?.apiKey]));
198
365
  }
199
366
  /**
200
367
  * `!cmd` runs on the box; `$NAME` / `${NAME}` reads the environment; anything else is the key itself.
@@ -216,7 +383,7 @@ export function interactiveLoginKind(p) {
216
383
  export function interactiveAuth(provider, method) {
217
384
  return method === "oauth" ? provider.auth.oauth : provider.auth.apiKey?.login ? provider.auth.apiKey : undefined;
218
385
  }
219
- /** The providers a sign-in can target: pi's built-ins plus `extra`, composed exactly as {@link createPiModels} does. */
386
+ /** The providers a sign-in can target: pi's built-ins plus `extra`, composed exactly as {@link piModelsOver} does. */
220
387
  export function loginProviders(extra) {
221
388
  return piModelsOver(new InMemoryCredentialStore(), extra).getProviders();
222
389
  }
@@ -238,7 +405,11 @@ export async function providerAuthStatuses(models) {
238
405
  }
239
406
  return statuses;
240
407
  }
241
- /** Which source currently satisfies auth for `spec` — a startup diagnostic. */
408
+ /**
409
+ * Which source currently satisfies auth for `spec`, or undefined when none does (or the spec is not in `models`).
410
+ * Rejects when resolving fails — a corrupt entry, a refresh the provider refused — which is a different answer from
411
+ * "nothing configured" and must reach whoever reports it.
412
+ */
242
413
  export async function probeAuthSource(models, spec) {
243
414
  const slash = spec.indexOf("/");
244
415
  if (slash < 1)
@@ -246,8 +417,7 @@ export async function probeAuthSource(models, spec) {
246
417
  const model = models.getModel(spec.slice(0, slash), spec.slice(slash + 1));
247
418
  if (!model)
248
419
  return undefined;
249
- const auth = await models.getAuth(model).catch(() => undefined);
250
- return auth?.source;
420
+ return (await models.getAuth(model))?.source;
251
421
  }
252
422
  /** Quick-fail probe for a just-stored API key. */
253
423
  export async function probeApiKey(models, model, signal) {
@@ -1,10 +1,13 @@
1
1
  import type { Agent } from "../../agent.ts";
2
- import { type AuthLayers, type FastagentConfig } from "./config.ts";
2
+ import { type FastagentConfig } from "./config.ts";
3
3
  import type { AgentCommand, SessionControl } from "../../session.ts";
4
+ import { type PiAssembly } from "./create.ts";
4
5
  import type { SessionObserver } from "./turn-kit.ts";
5
6
  import { type LoadedDefinition } from "./definition.ts";
6
7
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
7
- import type { FastagentAuthOptions } from "./auth.ts";
8
+ import type { CredentialStore } from "@earendil-works/pi-ai";
9
+ import type { CredentialSourceOptions, FastagentAuthOptions } from "./auth.ts";
10
+ import { type AgentModels } from "./agent-models.ts";
8
11
  import { type PiSessionRecordStore } from "./session-store.ts";
9
12
  import type { ToolCollision, MountedTool } from "./tool.ts";
10
13
  import type { DeclaredSecret } from "../../declared-secrets.ts";
@@ -36,6 +39,8 @@ export interface CreatePiAgentFromDirOptions {
36
39
  sessionsDir?: string;
37
40
  /** Credentials file override. */
38
41
  authPath?: string;
42
+ /** The caller's own credential store, in place of any file ({@link CredentialSourceOptions}). */
43
+ credentialStore?: CredentialStore;
39
44
  /**
40
45
  * This is a long-running SERVE (`dev`/`start`), where the scheduler poller runs — so a self-scheduled wake-up is
41
46
  * actually honored.
@@ -65,8 +70,8 @@ export interface AgentAssembly {
65
70
  workspace: string;
66
71
  /** Absolute state root (FASTAGENT_STATE_DIR > <agentDir>/.state). */
67
72
  stateRoot: string;
68
- /** The credentials files it reads ({@link resolveAuthLayers}). */
69
- auth: AuthLayers;
73
+ /** The credential store and model registry every turn and every report reads ({@link agentModels}). */
74
+ models: AgentModels;
70
75
  /** The full mounted tool surface (all coding tools + config.tools + discovered tools/, search_tools applied). */
71
76
  tools: MountedTool[];
72
77
  toolNames: string[];
@@ -77,8 +82,19 @@ export interface AgentAssembly {
77
82
  }
78
83
  export declare function resolveAgentAssembly(dir: string, options?: {
79
84
  model?: string;
80
- authPath?: string;
81
- }): Promise<AgentAssembly>;
85
+ } & CredentialSourceOptions): Promise<AgentAssembly>;
86
+ /**
87
+ * L2 over a resolved front half: the ONE mapping from what the directory resolved to what the assembly runs on, for
88
+ * both session shapes (the served agent and `chat`'s resident one). Two copies of it drifted once (#566, chat ran at
89
+ * the wrong reasoning effort). `models` is the front's own, so the assembly runs on what the report describes.
90
+ */
91
+ export declare function assembleFront(front: AgentAssembly, extra?: {
92
+ tools?: MountedTool[];
93
+ sessions?: PiSessionRecordStore;
94
+ }): Promise<{
95
+ assembly: PiAssembly;
96
+ definition: LoadedDefinition;
97
+ }>;
82
98
  /**
83
99
  * The model specs `createPiAgentFromDir(dir, { authPath })` could run now: the agent's registry (pi's built-ins plus
84
100
  * its `models.json`) filtered to the providers whose credentials are configured, sorted. The directory needs no model
@@ -88,9 +104,24 @@ export declare function resolveAgentAssembly(dir: string, options?: {
88
104
  * `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
89
105
  * configured", so a client that must not show that as an empty list passes a sink that throws.
90
106
  */
91
- export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & {
92
- authPath?: string;
93
- }): Promise<string[]>;
107
+ export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions): Promise<string[]>;
108
+ /**
109
+ * Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
110
+ * `createPiAgentFromDir(dir, { authPath })` would use, so a model released after the installed pi appears in
111
+ * {@link availableModelsFromDir} and runs. Only providers those credentials authenticate are fetched (pi asks pi.dev
112
+ * for no other). Nothing refreshes it on its own. The file is part of the definition: commit it, and it ships with a
113
+ * deploy, so the deployed agent knows the same models without a network call.
114
+ *
115
+ * Rejects, naming each provider that failed, when the refresh fails, outlasts 15 seconds or `PI_OFFLINE` is set, and
116
+ * when the credentials authenticate no provider at all.
117
+ */
118
+ export declare function refreshModelCatalog(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions & {
119
+ signal?: AbortSignal;
120
+ }): Promise<void>;
121
+ /** {@link refreshModelCatalog} against another catalog server. Not public: a test seam. */
122
+ export declare function refreshModelCatalogOver(dir: string, options: FastagentAuthOptions & CredentialSourceOptions & {
123
+ signal?: AbortSignal;
124
+ }, catalogBaseUrl?: string): Promise<void>;
94
125
  /**
95
126
  * "Point at a directory → agent": resolve the placement (`dir` may be either end — the workspace or the agent dir
96
127
  * itself), load the config, resolve model and tools, then L2.
@@ -110,8 +141,8 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
110
141
  stateRoot: string;
111
142
  /** Absolute session store directory in use (for the startup report). */
112
143
  sessionsDir: string;
113
- /** The credentials files in use — the startup report names whichever layer the credential came from. */
114
- auth: AuthLayers;
144
+ /** The credential store and registry the agent runs on — what the startup report describes. */
145
+ models: AgentModels;
115
146
  sessions: PiSessionRecordStore;
116
147
  /** The observation plane over this agent's sessions; present on every serve (a channel's stop command reaches the
117
148
  * live run through it). Its boundary is wired only when {@link publishControl}. */
@@ -3,9 +3,10 @@
3
3
  * drive.
4
4
  */
5
5
  import { mkdir } from "node:fs/promises";
6
- import { loadConfig, resolveAuthLayers, resolveModelSpec, } from "./config.js";
7
- import { resolveSessionsDir, resolveStateRoot, resolvePlacement } from "../../paths.js";
8
- import { agentOf, assemblePiFromDefinition, definitionModelRuntime, resolveAgentTools } from "./create.js";
6
+ import { join } from "node:path";
7
+ import { loadConfig, resolveModelSpec } from "./config.js";
8
+ import { AGENT_MODEL_CATALOG_FILE, resolveSessionsDir, resolveStateRoot, resolvePlacement } from "../../paths.js";
9
+ import { agentOf, assemblePiFromDefinition, resolveAgentTools } from "./create.js";
9
10
  import { createPiSessionControl } from "./session-control.js";
10
11
  import { withWakeTool } from "./wake-tool.js";
11
12
  import { refuseBrokenDeclarations } from "../../loader.js";
@@ -13,6 +14,8 @@ import { loadAgentSkills } from "./definition.js";
13
14
  import { servedExtensionCommands } from "./agent-session-factory.js";
14
15
  import { reportFindingsIfChanged } from "./report.js";
15
16
  import { readMachine, withMachine } from "./machine.js";
17
+ import { createPiModelRuntime, refreshCatalog } from "./models.js";
18
+ import { agentModels } from "./agent-models.js";
16
19
  import { piSessionRecordStore } from "./session-store.js";
17
20
  import { gateSecrets } from "../../secrets-gate.js";
18
21
  /**
@@ -83,8 +86,6 @@ export async function resolveAgentAssembly(dir, options = {}) {
83
86
  // The state root: sessions/channel state/schedule state derive from it (FASTAGENT_STATE_DIR moves it in one knob —
84
87
  // a container points it at its volume).
85
88
  const stateRoot = resolveStateRoot(agentDir);
86
- // The credentials file: project-level by default (under `<agentDir>/.secrets`).
87
- const auth = resolveAuthLayers(agentDir, options.authPath);
88
89
  return {
89
90
  config,
90
91
  configPath,
@@ -92,7 +93,8 @@ export async function resolveAgentAssembly(dir, options = {}) {
92
93
  agentDir,
93
94
  workspace,
94
95
  stateRoot,
95
- auth,
96
+ // Project-level by default (under `<agentDir>/.secrets`), with the global file behind it per provider.
97
+ models: agentModels(agentDir, options),
96
98
  tools,
97
99
  toolNames,
98
100
  deferredToolNames,
@@ -100,6 +102,21 @@ export async function resolveAgentAssembly(dir, options = {}) {
100
102
  toolSecrets,
101
103
  };
102
104
  }
105
+ /**
106
+ * L2 over a resolved front half: the ONE mapping from what the directory resolved to what the assembly runs on, for
107
+ * both session shapes (the served agent and `chat`'s resident one). Two copies of it drifted once (#566, chat ran at
108
+ * the wrong reasoning effort). `models` is the front's own, so the assembly runs on what the report describes.
109
+ */
110
+ export function assembleFront(front, extra = {}) {
111
+ return assemblePiFromDefinition(front.agentDir, {
112
+ model: front.modelSpec,
113
+ thinkingLevel: front.config.thinkingLevel,
114
+ cwd: front.workspace,
115
+ tools: extra.tools ?? front.tools,
116
+ models: front.models,
117
+ ...(extra.sessions ? { sessions: extra.sessions } : {}),
118
+ });
119
+ }
103
120
  /**
104
121
  * The model specs `createPiAgentFromDir(dir, { authPath })` could run now: the agent's registry (pi's built-ins plus
105
122
  * its `models.json`) filtered to the providers whose credentials are configured, sorted. The directory needs no model
@@ -111,18 +128,40 @@ export async function resolveAgentAssembly(dir, options = {}) {
111
128
  */
112
129
  export async function availableModelsFromDir(dir, options = {}) {
113
130
  const { agentDir } = resolvePlacement(dir);
114
- const models = await definitionModelRuntime(agentDir, {
115
- auth: resolveAuthLayers(agentDir, options.authPath),
116
- ...(options.warn ? { warn: options.warn } : {}),
117
- });
131
+ const models = await agentModels(agentDir, options).runtime();
118
132
  return (await models.getAvailable()).map((model) => `${model.provider}/${model.id}`).sort();
119
133
  }
134
+ /**
135
+ * Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
136
+ * `createPiAgentFromDir(dir, { authPath })` would use, so a model released after the installed pi appears in
137
+ * {@link availableModelsFromDir} and runs. Only providers those credentials authenticate are fetched (pi asks pi.dev
138
+ * for no other). Nothing refreshes it on its own. The file is part of the definition: commit it, and it ships with a
139
+ * deploy, so the deployed agent knows the same models without a network call.
140
+ *
141
+ * Rejects, naming each provider that failed, when the refresh fails, outlasts 15 seconds or `PI_OFFLINE` is set, and
142
+ * when the credentials authenticate no provider at all.
143
+ */
144
+ export function refreshModelCatalog(dir, options = {}) {
145
+ return refreshModelCatalogOver(dir, options);
146
+ }
147
+ /** {@link refreshModelCatalog} against another catalog server. Not public: a test seam. */
148
+ export async function refreshModelCatalogOver(dir, options, catalogBaseUrl) {
149
+ const { agentDir } = resolvePlacement(dir);
150
+ const { credentials } = agentModels(agentDir, options);
151
+ await refreshCatalog((catalogFile) => createPiModelRuntime({
152
+ agentDir,
153
+ credentials,
154
+ ...(catalogFile ? { catalogFile: join(agentDir, AGENT_MODEL_CATALOG_FILE) } : {}),
155
+ ...(catalogBaseUrl ? { catalogBaseUrl } : {}),
156
+ }), options.signal ? { signal: options.signal } : {});
157
+ }
120
158
  /**
121
159
  * "Point at a directory → agent": resolve the placement (`dir` may be either end — the workspace or the agent dir
122
160
  * itself), load the config, resolve model and tools, then L2.
123
161
  */
124
162
  export async function createPiAgentFromDir(dir, options = {}) {
125
- const { config, configPath, modelSpec, agentDir, workspace, stateRoot, auth, tools, toolNames, deferredToolNames, toolCollisions, } = await resolveAgentAssembly(dir, options);
163
+ const front = await resolveAgentAssembly(dir, options);
164
+ const { config, configPath, modelSpec, agentDir, workspace, stateRoot, models, tools, toolNames, deferredToolNames, toolCollisions, } = front;
126
165
  // Every serve mounts the built-in `wake` tool: the agent's own follow-up work is a default capability, and a serve
127
166
  // is where the poller that honors it runs. A one-shot `invoke` has no poller, so nothing there would fire it.
128
167
  const mountedTools = withWakeTool(tools, stateRoot, !!options.serving);
@@ -131,15 +170,8 @@ export async function createPiAgentFromDir(dir, options = {}) {
131
170
  const sessionsDir = options.sessionsDir ?? resolveSessionsDir(agentDir);
132
171
  await mkdir(sessionsDir, { recursive: true });
133
172
  const sessions = piSessionRecordStore({ dir: sessionsDir, cwd: workspace });
134
- const { assembly, definition } = await assemblePiFromDefinition(agentDir, {
135
- model: modelSpec,
136
- thinkingLevel: config.thinkingLevel,
137
- cwd: workspace,
138
- tools: mountedTools,
139
- auth,
140
- // Skills are definition-only (the agent is its directory), so dev mirrors deployment exactly.
141
- sessions,
142
- });
173
+ // Skills are definition-only (the agent is its directory), so dev mirrors deployment exactly.
174
+ const { assembly, definition } = await assembleFront(front, { tools: mountedTools, sessions });
143
175
  // The hub is wired HERE because the store is created here (an external `createPiSessionControl` cannot exist before
144
176
  // the store does).
145
177
  const caller = options.observer;
@@ -199,7 +231,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
199
231
  modelSpec,
200
232
  stateRoot,
201
233
  sessionsDir,
202
- auth,
234
+ models,
203
235
  toolNames,
204
236
  deferredToolNames,
205
237
  toolCollisions,
@@ -1,8 +1,8 @@
1
1
  /** `createAgentService` — the product as one call, with pi supplying the engine. */
2
2
  import { type AgentService, type MountAgentServiceOptions } from "../../service.ts";
3
- export interface CreateAgentServiceOptions extends MountAgentServiceOptions {
3
+ import type { CredentialSourceOptions } from "./auth.ts";
4
+ export interface CreateAgentServiceOptions extends MountAgentServiceOptions, CredentialSourceOptions {
4
5
  model?: string;
5
- authPath?: string;
6
6
  sessionsDir?: string;
7
7
  }
8
8
  /** Open an agent directory as a live service: one handler, mounted wherever you serve. */
@@ -6,6 +6,7 @@ export async function createAgentService(dir, options = {}) {
6
6
  const opened = await createPiAgentFromDir(dir, {
7
7
  ...(options.model !== undefined ? { model: options.model } : {}),
8
8
  ...(options.authPath !== undefined ? { authPath: options.authPath } : {}),
9
+ ...(options.credentialStore !== undefined ? { credentialStore: options.credentialStore } : {}),
9
10
  ...(options.sessionsDir !== undefined ? { sessionsDir: options.sessionsDir } : {}),
10
11
  serving: true, // a mounted service is long-running: the scheduler poller runs
11
12
  });
@@ -6,7 +6,8 @@ export interface BuildSessionRuntimeOptions {
6
6
  authPath?: string;
7
7
  }
8
8
  /**
9
- * Build pi's interactive runtime driven by fastagent's assembled agent (model, prompt, tools, skills, and auth
10
- * resolved exactly as the serving opener does).
9
+ * Build pi's interactive runtime over the SAME assembly `dev`/`start` serve ({@link assembleFront}): model
10
+ * registry, prompt, tools, skills, reasoning effort and credentials all come from it, so what differs is only the
11
+ * session's shape (one resident runtime rather than one session per invoke).
11
12
  */
12
13
  export declare function buildAgentSessionRuntime(dir: string, options?: BuildSessionRuntimeOptions, sessionManager?: SessionManager): Promise<AgentSessionRuntime>;