@fastagent-sh/fastagent 0.22.1 → 0.24.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 (128) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/agentcore-protocol.d.ts +2 -1
  3. package/dist/channels/agentcore-service.js +10 -18
  4. package/dist/channels/agentcore.js +6 -3
  5. package/dist/channels/control.js +23 -0
  6. package/dist/channels/kit/preview-kit.d.ts +6 -1
  7. package/dist/channels/kit/preview-kit.js +21 -4
  8. package/dist/channels/slack/config-api.d.ts +2 -2
  9. package/dist/channels/slack/config-api.js +2 -2
  10. package/dist/channels/slack/onboard.js +2 -2
  11. package/dist/channels/slack/preview.js +2 -1
  12. package/dist/channels/slack/register-webhook.js +2 -2
  13. package/dist/channels/slack/slack-api.js +4 -4
  14. package/dist/cli/add-feishu.js +4 -2
  15. package/dist/cli/auth-view.d.ts +15 -6
  16. package/dist/cli/auth-view.js +24 -8
  17. package/dist/cli/box-login.d.ts +33 -0
  18. package/dist/cli/box-login.js +162 -0
  19. package/dist/cli/commands/deploy/agentcore.js +48 -11
  20. package/dist/cli/commands/deploy/docker.js +24 -13
  21. package/dist/cli/commands/deploy/fly.js +30 -7
  22. package/dist/cli/commands/deploy/railway.js +21 -7
  23. package/dist/cli/commands/deploy/shared.d.ts +18 -17
  24. package/dist/cli/commands/deploy/shared.js +20 -21
  25. package/dist/cli/commands/destroy.js +2 -2
  26. package/dist/cli/commands/info.js +15 -12
  27. package/dist/cli/commands/invoke.js +2 -2
  28. package/dist/cli/commands/login.d.ts +7 -1
  29. package/dist/cli/commands/login.js +123 -10
  30. package/dist/cli/commands/logs.js +2 -2
  31. package/dist/cli/commands/models.d.ts +4 -1
  32. package/dist/cli/commands/models.js +37 -4
  33. package/dist/cli/commands/routine-run.js +2 -2
  34. package/dist/cli/commands/start.d.ts +0 -5
  35. package/dist/cli/commands/start.js +3 -13
  36. package/dist/cli/invoke-stream.js +14 -0
  37. package/dist/cli/kernel.d.ts +2 -0
  38. package/dist/cli/kernel.js +1 -1
  39. package/dist/cli/login-relay.d.ts +38 -0
  40. package/dist/cli/login-relay.js +121 -0
  41. package/dist/cli/program.js +46 -8
  42. package/dist/cli/shared.d.ts +6 -5
  43. package/dist/cli/shared.js +29 -36
  44. package/dist/deploy/agentcore/plan.d.ts +10 -11
  45. package/dist/deploy/agentcore/plan.js +8 -14
  46. package/dist/deploy/agentcore/run.d.ts +8 -1
  47. package/dist/deploy/agentcore/run.js +20 -14
  48. package/dist/deploy/agentcore/shell.d.ts +25 -0
  49. package/dist/deploy/agentcore/shell.js +202 -0
  50. package/dist/deploy/box-shell.d.ts +43 -0
  51. package/dist/deploy/box-shell.js +28 -0
  52. package/dist/deploy/build-context.js +16 -7
  53. package/dist/deploy/channel-ingress.d.ts +20 -1
  54. package/dist/deploy/channel-ingress.js +34 -7
  55. package/dist/deploy/container.d.ts +14 -0
  56. package/dist/deploy/container.js +45 -1
  57. package/dist/deploy/docker/plan.d.ts +7 -3
  58. package/dist/deploy/docker/plan.js +9 -14
  59. package/dist/deploy/docker/run.d.ts +14 -7
  60. package/dist/deploy/docker/run.js +58 -35
  61. package/dist/deploy/fly/plan.d.ts +6 -3
  62. package/dist/deploy/fly/plan.js +6 -8
  63. package/dist/deploy/fly/run.d.ts +8 -2
  64. package/dist/deploy/fly/run.js +15 -25
  65. package/dist/deploy/preflight.d.ts +9 -7
  66. package/dist/deploy/preflight.js +92 -20
  67. package/dist/deploy/railway/plan.d.ts +6 -3
  68. package/dist/deploy/railway/plan.js +7 -9
  69. package/dist/deploy/railway/run.d.ts +10 -6
  70. package/dist/deploy/railway/run.js +29 -13
  71. package/dist/deploy/runner.d.ts +16 -0
  72. package/dist/deploy/runner.js +31 -0
  73. package/dist/deploy/secrets.d.ts +4 -13
  74. package/dist/deploy/secrets.js +8 -27
  75. package/dist/dev-supervisor.js +7 -4
  76. package/dist/engines/pi/agent-models.d.ts +83 -0
  77. package/dist/engines/pi/agent-models.js +120 -0
  78. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  79. package/dist/engines/pi/agent-session-factory.js +132 -159
  80. package/dist/engines/pi/auth.d.ts +56 -3
  81. package/dist/engines/pi/auth.js +68 -109
  82. package/dist/engines/pi/config.d.ts +0 -25
  83. package/dist/engines/pi/config.js +3 -37
  84. package/dist/engines/pi/create.d.ts +34 -21
  85. package/dist/engines/pi/create.js +90 -57
  86. package/dist/engines/pi/entry-images.d.ts +14 -0
  87. package/dist/engines/pi/entry-images.js +23 -0
  88. package/dist/engines/pi/invoke-session.js +88 -20
  89. package/dist/engines/pi/locked-file.d.ts +9 -0
  90. package/dist/engines/pi/locked-file.js +102 -0
  91. package/dist/engines/pi/login.d.ts +27 -14
  92. package/dist/engines/pi/login.js +41 -18
  93. package/dist/engines/pi/machine.d.ts +23 -0
  94. package/dist/engines/pi/machine.js +24 -2
  95. package/dist/engines/pi/models.d.ts +120 -36
  96. package/dist/engines/pi/models.js +316 -66
  97. package/dist/engines/pi/open.d.ts +65 -20
  98. package/dist/engines/pi/open.js +88 -34
  99. package/dist/engines/pi/report.d.ts +3 -0
  100. package/dist/engines/pi/report.js +11 -0
  101. package/dist/engines/pi/service.d.ts +2 -2
  102. package/dist/engines/pi/service.js +1 -0
  103. package/dist/engines/pi/session-builder.d.ts +3 -2
  104. package/dist/engines/pi/session-builder.js +30 -76
  105. package/dist/engines/pi/session-control.d.ts +4 -4
  106. package/dist/engines/pi/session-control.js +86 -43
  107. package/dist/engines/pi/session-settings.d.ts +9 -0
  108. package/dist/engines/pi/session-settings.js +17 -0
  109. package/dist/engines/pi/session-store.d.ts +2 -2
  110. package/dist/engines/pi/tool-context.d.ts +3 -5
  111. package/dist/engines/pi/tool-context.js +4 -16
  112. package/dist/engines/pi/tool.d.ts +32 -21
  113. package/dist/engines/pi/tool.js +21 -25
  114. package/dist/engines/pi/turn-kit.d.ts +18 -9
  115. package/dist/engines/pi/turn-kit.js +115 -32
  116. package/dist/open-url.d.ts +15 -1
  117. package/dist/open-url.js +35 -3
  118. package/dist/paths.d.ts +4 -2
  119. package/dist/paths.js +6 -2
  120. package/dist/pi.d.ts +5 -3
  121. package/dist/pi.js +3 -2
  122. package/dist/service.d.ts +5 -8
  123. package/dist/session-remote.js +21 -0
  124. package/dist/session.d.ts +88 -15
  125. package/dist/session.js +5 -1
  126. package/package.json +6 -5
  127. package/dist/engines/pi/search-tools.d.ts +0 -4
  128. package/dist/engines/pi/search-tools.js +0 -109
@@ -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 { AGENT_MODEL_CATALOG_FILE, AGENT_MODELS_FILE, globalHome, resolveOverridePath } from "../../paths.js";
17
14
  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
- }
15
+ import { withLockedFile } from "./locked-file.js";
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) {
@@ -76,30 +70,45 @@ async function modelLayers(agentDir) {
76
70
  return { machinePath, machine, own: (await readProviders(join(agentDir, AGENT_MODELS_FILE))) ?? {} };
77
71
  }
78
72
  /**
79
- * The models.json pi loads for an agent: its own file, layered over the machine's. The agent's file wins a provider id
80
- * outright, as a definition's skill wins a name: an agent that pins an endpoint keeps it.
73
+ * The models.json pi loads for an agent: its own file, layered over the machine's when `machine` is on. The agent's
74
+ * file wins a provider id outright, as a definition's skill wins a name: an agent that pins an endpoint keeps it.
81
75
  *
82
- * The merge is a SNAPSHOT named by its content, in fastagent's own home: not in the agent (so `info` writes nothing
83
- * there), and not in a shared temp dir, which the OS clears while a long-running process still re-reads it (pi
84
- * reloads `modelsPath` on every refresh). Content addressing is what lets every process share the directory: a
85
- * snapshot is never rewritten, so no process can change what another's refresh reads. The price is that a running
86
- * process keeps the snapshot it started with; an edit to either file takes effect on the next start.
76
+ * Always a SNAPSHOT named by its content, in fastagent's own home: pi reloads `modelsPath` on every refresh, and every
77
+ * session builds its own runtime, so a live file would let turns run on an edit the control plane never read. Not in
78
+ * the agent (so `info` writes nothing there), and not in a shared temp dir, which the OS clears while a long-running
79
+ * process still re-reads it. Content addressing is what lets every process share the directory: a snapshot is never
80
+ * rewritten, so no process can change what another reads. A running process keeps the content it started with; an
81
+ * edit to either file takes effect on the next start.
87
82
  *
88
83
  * ponytail: snapshots are never pruned (one per distinct content, a few KB each) because a running process may still
89
84
  * read an old one; delete the directory while no fastagent process runs if it ever matters.
90
85
  */
91
- async function modelsFileFor(agentDir) {
86
+ async function modelsFileFor(agentDir, machine) {
92
87
  const definition = join(agentDir, AGENT_MODELS_FILE);
93
- const layers = await modelLayers(agentDir);
94
- if (!layers)
95
- return { path: definition };
96
- const content = JSON.stringify({ providers: { ...layers.machine, ...layers.own } }, null, 2);
88
+ const layers = machine ? await modelLayers(agentDir) : undefined;
89
+ if (layers) {
90
+ const content = JSON.stringify({ providers: { ...layers.machine, ...layers.own } }, null, 2);
91
+ return { path: snapshotFile(content), merged: { machine: layers.machinePath, definition } };
92
+ }
93
+ let own;
94
+ try {
95
+ own = await readFile(definition, "utf8");
96
+ }
97
+ catch (error) {
98
+ if (error.code === "ENOENT")
99
+ return { path: null };
100
+ throw new Error(`could not read ${definition}: ${error.message}`);
101
+ }
102
+ // Verbatim, so pi parses it exactly as it would the file itself.
103
+ return { path: snapshotFile(own), snapshotOf: definition };
104
+ }
105
+ function snapshotFile(content) {
97
106
  const hash = createHash("sha256").update(content).digest("hex").slice(0, 32);
98
- const path = join(homedir(), GLOBAL_HOME_DIR, ".cache", "models", `${hash}.json`);
107
+ const path = join(globalHome(), ".cache", "models", `${hash}.json`);
99
108
  // 0600: it may carry literal keys. Several processes may create the same snapshot at once.
100
109
  if (!existsSync(path))
101
110
  writeFileAtomic(path, content, 0o600, true);
102
- return { path, merged: { machine: layers.machinePath, definition } };
111
+ return path;
103
112
  }
104
113
  /**
105
114
  * Where an agent's custom endpoints come from, for a report: the machine file, the providers the agent inherits from
@@ -120,49 +129,265 @@ export async function machineModels(agentDir) {
120
129
  export function isBuiltinProvider(providerId) {
121
130
  return builtinProviders().some((provider) => provider.id === providerId);
122
131
  }
123
- /** The `ModelRuntime`-shaped sibling of {@link createPiModels}. */
124
- export async function createPiModelRuntime(options = {}) {
132
+ /**
133
+ * The MACHINE's model catalog: the models pi.dev lists that are newer than the catalog bundled with pi, fetched by
134
+ * `fastagent models --refresh -g`. Every agent here reads it under its own; like `~/.fastagent/models.json`, it
135
+ * never ships.
136
+ */
137
+ export function globalCatalogPath() {
138
+ return join(globalHome(), AGENT_MODEL_CATALOG_FILE);
139
+ }
140
+ /** A catalog file as pi writes it (entries by provider), or `{}` when there is none. Read without a lock: its one
141
+ * writer ({@link catalogFileStore}) replaces the file whole, so a read sees the old catalog or the new one. */
142
+ async function readCatalog(path) {
143
+ if (!existsSync(path))
144
+ return {};
145
+ return parseCatalog(await readFile(path, "utf8"), path);
146
+ }
147
+ /**
148
+ * The one reading of a catalog file's content, for reads and writes alike. Empty is an empty catalog, as pi reads it:
149
+ * the writer creates the file exclusively an instant before its first content, and pi's own store, which wrote these
150
+ * files before, left one behind whenever a refresh it began wrote no entry. That is safe only because nothing writes
151
+ * these files in place any more: a rewrite in place is read mostly as empty while it runs. Anything else that does not
152
+ * parse is a corrupt file, and a WRITE refuses it too: serializing over it would drop every other provider's entries.
153
+ */
154
+ function parseCatalog(content, path) {
155
+ if (content === "")
156
+ return {};
157
+ try {
158
+ return JSON.parse(content);
159
+ }
160
+ catch (error) {
161
+ throw new Error(`model catalog ${path} is not valid JSON (${error.message}): refresh or delete it`);
162
+ }
163
+ }
164
+ /**
165
+ * The catalog file a refresh writes, as pi's store. It replaces pi's own file store, which rewrites the file in place
166
+ * under its lock: a reader that takes no lock (every catalog read here, and anything else reading the file) could open
167
+ * it cut short mid-write and blame a healthy file. Same lock as pi's, so concurrent refreshes still queue; reading
168
+ * creates nothing, so a refresh that is refused writes no file.
169
+ */
170
+ export function catalogFileStore(path) {
171
+ const change = (update, signal) => withLockedFile(path, async (current) => {
172
+ const catalog = parseCatalog(current ?? "", path);
173
+ update(catalog);
174
+ return { result: undefined, next: JSON.stringify(catalog, null, 2) };
175
+ }, signal ? { signal } : {});
176
+ return {
177
+ async read(providerId, options) {
178
+ options?.signal?.throwIfAborted();
179
+ return (await readCatalog(path))[providerId];
180
+ },
181
+ async write(providerId, entry, options) {
182
+ options?.signal?.throwIfAborted();
183
+ await change((catalog) => {
184
+ catalog[providerId] = structuredClone(entry);
185
+ }, options?.signal);
186
+ },
187
+ async delete(providerId, options) {
188
+ options?.signal?.throwIfAborted();
189
+ await change((catalog) => {
190
+ delete catalog[providerId];
191
+ }, options?.signal);
192
+ },
193
+ };
194
+ }
195
+ /**
196
+ * The catalog files layered into one read-only store, later files winning a (type, model id) pair. An entry pi would ignore (no
197
+ * newer than the catalog bundled with it) is dropped before the merge, so a stale layer cannot ride a newer one's
198
+ * date past pi's rule.
199
+ *
200
+ * Read here rather than handed to pi as a file: pi takes one store, and its file store creates the file (and a lock)
201
+ * on read, which would drop an empty catalog into every agent dir that was never refreshed.
202
+ */
203
+ async function layeredCatalog(paths) {
204
+ return catalogStore(await readLayeredCatalog(paths));
205
+ }
206
+ /** A fresh store over a catalog read once: pi may write into the store it is given, so runtimes never share one. */
207
+ async function catalogStore(entries) {
208
+ const store = new InMemoryModelsStore();
209
+ for (const [provider, entry] of entries)
210
+ await store.write(provider, structuredClone(entry));
211
+ return store;
212
+ }
213
+ async function readLayeredCatalog(paths) {
214
+ const bundledAt = getBuiltinModelDataGeneratedAt();
215
+ const merged = new Map();
216
+ for (const path of paths) {
217
+ for (const [provider, entry] of Object.entries(await readCatalog(path))) {
218
+ if (bundledAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= bundledAt))
219
+ continue;
220
+ const held = merged.get(provider);
221
+ const key = (model) => `${model.type ?? "chat"}\u0000${model.id}`;
222
+ const ids = new Set(entry.models.map(key));
223
+ merged.set(provider, held
224
+ ? {
225
+ models: [...held.models.filter((model) => !ids.has(key(model))), ...entry.models],
226
+ lastModified: Math.max(held.lastModified ?? 0, entry.lastModified ?? 0),
227
+ }
228
+ : entry);
229
+ }
230
+ }
231
+ return merged;
232
+ }
233
+ /**
234
+ * Whether the machine's model catalog supplies this model, by the same rule every registry reads it with
235
+ * ({@link layeredCatalog}): an entry pi would ignore supplies nothing. The one layer a deployed agent does not have.
236
+ */
237
+ export async function inGlobalCatalog(provider, id) {
238
+ const entry = await (await layeredCatalog([globalCatalogPath()])).read(provider);
239
+ return entry?.models.some((model) => (model.type ?? "chat") === "chat" && model.id === id) ?? false;
240
+ }
241
+ /** The models.json a runtime for these options loads, and which model catalog it reads. */
242
+ export async function modelRuntimeFiles(options) {
125
243
  const { agentDir } = options;
126
- const models = !agentDir
127
- ? undefined
128
- : options.machineLayer === false
129
- ? { path: join(agentDir, AGENT_MODELS_FILE) }
130
- : await modelsFileFor(agentDir);
244
+ const machine = options.machineLayer !== false;
245
+ const models = agentDir ? await modelsFileFor(agentDir, machine) : undefined;
246
+ const catalogs = !agentDir
247
+ ? []
248
+ : [...(machine ? [globalCatalogPath()] : []), join(agentDir, AGENT_MODEL_CATALOG_FILE)];
249
+ const catalog = !options.catalogFile && agentDir ? await readLayeredCatalog(catalogs) : undefined;
250
+ return {
251
+ ...(models ? { models } : {}),
252
+ create: async () => ({
253
+ modelsPath: models?.path ?? null,
254
+ // Always a store of our own whenever modelsPath is set: pi's default is a file at
255
+ // `<dirname(modelsPath)>/models-store.json`. Without a directory, pi keeps an empty one in memory.
256
+ ...(options.catalogFile
257
+ ? { modelsStore: catalogFileStore(options.catalogFile) }
258
+ : catalog
259
+ ? { modelsStore: await catalogStore(catalog) }
260
+ : {}),
261
+ // Never fetched while a runtime is built: serving stays offline and reproducible. A refresh is asked for.
262
+ allowModelNetwork: false,
263
+ ...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
264
+ }),
265
+ };
266
+ }
267
+ /**
268
+ * This machine's registry, without an agent: pi's built-ins, the machine's models.json and its model catalog. What
269
+ * `fastagent models` lists outside an agent; {@link catalogFile} is the refresh's (it writes the file).
270
+ */
271
+ export async function machineModelRuntime(options = {}) {
131
272
  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
- : {}),
273
+ credentials: options.credentials ?? new InMemoryCredentialStore(),
274
+ modelsPath: machineModelsPath(),
275
+ modelsStore: options.catalogFile
276
+ ? catalogFileStore(options.catalogFile)
277
+ : await layeredCatalog([globalCatalogPath()]),
140
278
  allowModelNetwork: false,
279
+ ...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
141
280
  });
281
+ const error = runtime.getError();
282
+ if (error)
283
+ throw new Error(error);
284
+ return runtime;
285
+ }
286
+ /** How long a catalog refresh may take, as `pi update --models` allows. */
287
+ const CATALOG_REFRESH_TIMEOUT_MS = 15_000;
288
+ /**
289
+ * Fetch the model catalog of every provider the runtime can authenticate into `catalogFile` ({@link
290
+ * catalogFileStore}): the refresh `pi update --models` runs. `build` makes the runtime over that file, and runs only
291
+ * once the refresh is known to go ahead. A refusal writes nothing: the store creates the file only when pi writes an
292
+ * entry. pi asks pi.dev only for a provider with a usable credential, and may refresh an expired OAuth token of the
293
+ * runtime's store to get one. Rejects, naming each provider that failed, when any part fails, when it outlasts 15
294
+ * seconds, when `PI_OFFLINE` is set, when the catalog file is corrupt, and when no provider has a usable credential (the
295
+ * refresh would ask for nothing).
296
+ */
297
+ export async function refreshCatalog(catalogFile, build, options = {}) {
298
+ // pi skips its own background refresh under PI_OFFLINE, but an explicit `allowNetwork: true` overrides that.
299
+ if (process.env.PI_OFFLINE !== undefined)
300
+ throw new Error("PI_OFFLINE is set, so the model catalog is not refreshed");
301
+ // A corrupt file fails here, once, by its own message: past this point every provider's refresh reads the store and
302
+ // would repeat it, worded as that provider's failure.
303
+ await readCatalog(catalogFile);
304
+ const runtime = await build(catalogFile);
305
+ // pi skips a provider it cannot authenticate without recording anything, so with no usable credential at all the
306
+ // refresh would "succeed" having asked for nothing.
307
+ const refreshable = runtime.getProviders().filter((provider) => provider.refreshModels !== undefined);
308
+ const usable = await Promise.all(refreshable.map(async (provider) => (await runtime.checkAuth(provider.id)) !== undefined));
309
+ if (!usable.includes(true)) {
310
+ throw new Error("no provider has a usable credential here, so there is no model catalog to fetch — log in " +
311
+ "(`fastagent login`) or set a provider's API key first");
312
+ }
313
+ const timeout = AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS);
314
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
315
+ const result = await runtime.refresh({ allowNetwork: true, force: true, signal });
316
+ if (result.aborted) {
317
+ throw new Error(options.signal?.aborted
318
+ ? "model catalog refresh cancelled"
319
+ : `model catalog refresh did not finish within ${CATALOG_REFRESH_TIMEOUT_MS / 1000}s`);
320
+ }
321
+ if (result.errors.size > 0) {
322
+ const details = [...result.errors].map(([provider, error]) => `${provider}: ${error.message}`).join("; ");
323
+ throw new Error(`could not refresh the model catalog: ${details}`);
324
+ }
325
+ }
326
+ /**
327
+ * What satisfies a provider from `env` alone (its API-key variable, or an ambient source such as an AWS profile), by
328
+ * pi's own check, or undefined. Passed explicitly rather than read from `process.env`: only the environment a SHELL
329
+ * hands down is shared by every agent on the machine, and a process that has loaded an agent's `.secrets/.env` holds
330
+ * that agent's variables too. What the shell shares outranks the global credentials file (agent-models.ts),
331
+ * so a global login for such a provider is not used.
332
+ *
333
+ * `fileExists` answers for the files a source points at (an AWS profile, Google ADC): this machine's by default, which
334
+ * is right wherever the answer is about THIS machine. A deployment passes one that finds nothing, because no file of
335
+ * the builder's travels with it.
336
+ */
337
+ export async function environmentAuthSource(providerId, env, fileExists = defaultProviderAuthContext().fileExists) {
338
+ const models = builtinModels({
339
+ credentials: new InMemoryCredentialStore(),
340
+ authContext: {
341
+ // pi's own reading of a variable (a blank one is unset), over the given environment.
342
+ env: async (name) => (env[name]?.trim() ? env[name] : undefined),
343
+ fileExists,
344
+ },
345
+ });
346
+ return (await models.checkAuth(providerId))?.source;
347
+ }
348
+ /** Pi registration starts background refreshes; wait for all of them before publishing this fresh runtime. */
349
+ export async function withModelRegistration(runtime, register) {
350
+ const refresh = runtime.refresh;
351
+ const refreshes = [];
352
+ runtime.refresh = (...args) => {
353
+ const pending = refresh.apply(runtime, args);
354
+ refreshes.push(pending);
355
+ return pending;
356
+ };
357
+ try {
358
+ const result = await register();
359
+ await Promise.all(refreshes);
360
+ return result;
361
+ }
362
+ finally {
363
+ runtime.refresh = refresh;
364
+ }
365
+ }
366
+ /**
367
+ * A registry over the given credential store. What an agent runs on is built through `agentModels`
368
+ * (agent-models.ts); this is for a registry over some OTHER store: none at all (`models`, a deploy check of what
369
+ * ships), a trial key under test (login), or the catalog file a refresh writes.
370
+ */
371
+ export async function createPiModelRuntime(options) {
372
+ const { models, create } = options.files ?? (await modelRuntimeFiles(options));
373
+ const runtime = await ModelRuntime.create({ credentials: options.credentials, ...(await create()) });
142
374
  // A malformed models.json does NOT throw upstream — `create` resolves with the built-ins and parks the reason in
143
375
  // getError().
144
376
  const error = runtime.getError();
145
377
  if (error) {
146
378
  const origin = models?.merged
147
379
  ? `\n\nThat file merges ${models.merged.machine} with ${models.merged.definition} (the agent's own wins a provider id).`
148
- : "";
380
+ : models?.snapshotOf
381
+ ? `\n\nThat file is ${models.snapshotOf} as read at startup.`
382
+ : "";
149
383
  throw new Error(`${error}${origin}`);
150
384
  }
151
- for (const provider of options.providers ?? [])
152
- runtime.registerNativeProvider(provider);
385
+ await withModelRegistration(runtime, async () => {
386
+ for (const provider of options.providers ?? [])
387
+ runtime.registerNativeProvider(provider);
388
+ });
153
389
  return runtime;
154
390
  }
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
391
  /**
167
392
  * EVERY provider whose `models.json` entry writes its key as a LITERAL rather than a `"$NAME"` reference or a
168
393
  * `"!cmd"`. The file ships inside the image, so a literal there is a credential in a readable layer.
@@ -178,6 +403,27 @@ export function modelCredentialCarry(runtime, spec) {
178
403
  * gates what IT causes; what the author wrote into their own committed file, it reports.
179
404
  */
180
405
  export async function literalKeyProviders(agentDir) {
406
+ return Object.entries(await definitionApiKeys(agentDir))
407
+ .filter(([, apiKey]) => isLiteralKey(apiKey))
408
+ .map(([id]) => id);
409
+ }
410
+ /**
411
+ * How the agent's OWN models.json supplies `providerId`'s key, read from the file for the reason
412
+ * {@link literalKeyProviders} gives: `reference` is the variable a `"$NAME"` reads (the value file must hold it),
413
+ * `inFile` a key that travels with the definition itself (a literal, or a `"!command"` run on the box), and undefined
414
+ * no apiKey there at all.
415
+ */
416
+ export async function definitionKeyOf(agentDir, providerId) {
417
+ const apiKey = (await definitionApiKeys(agentDir))[providerId];
418
+ if (typeof apiKey !== "string" || apiKey === "")
419
+ return undefined;
420
+ if (apiKey.startsWith("!") || isLiteralKey(apiKey))
421
+ return { inFile: true };
422
+ // Not a literal, so isLiteralKey's own pattern matched: there is a name to read.
423
+ return { reference: apiKey.replaceAll("$$", "").match(/\$\{?([A-Za-z_][A-Za-z0-9_]*)/)?.[1] };
424
+ }
425
+ /** Each provider's `apiKey` as the agent's own models.json writes it. */
426
+ async function definitionApiKeys(agentDir) {
181
427
  const file = join(agentDir, AGENT_MODELS_FILE);
182
428
  let raw;
183
429
  try {
@@ -186,15 +432,13 @@ export async function literalKeyProviders(agentDir) {
186
432
  catch (error) {
187
433
  // No custom endpoints is the normal case; anything else (unreadable, a directory) is the caller's problem.
188
434
  if (error.code === "ENOENT")
189
- return [];
435
+ return {};
190
436
  throw error;
191
437
  }
192
438
  // Malformed JSON already threw out of createPiModelRuntime before this runs, so a parse failure here would be a
193
439
  // genuine surprise and must not be swallowed.
194
440
  const providers = JSON.parse(raw).providers ?? {};
195
- return Object.entries(providers)
196
- .filter(([, provider]) => isLiteralKey(provider?.apiKey))
197
- .map(([id]) => id);
441
+ return Object.fromEntries(Object.entries(providers).map(([id, provider]) => [id, provider?.apiKey]));
198
442
  }
199
443
  /**
200
444
  * `!cmd` runs on the box; `$NAME` / `${NAME}` reads the environment; anything else is the key itself.
@@ -216,7 +460,7 @@ export function interactiveLoginKind(p) {
216
460
  export function interactiveAuth(provider, method) {
217
461
  return method === "oauth" ? provider.auth.oauth : provider.auth.apiKey?.login ? provider.auth.apiKey : undefined;
218
462
  }
219
- /** The providers a sign-in can target: pi's built-ins plus `extra`, composed exactly as {@link createPiModels} does. */
463
+ /** The providers a sign-in can target: pi's built-ins plus `extra`, composed exactly as {@link piModelsOver} does. */
220
464
  export function loginProviders(extra) {
221
465
  return piModelsOver(new InMemoryCredentialStore(), extra).getProviders();
222
466
  }
@@ -238,7 +482,11 @@ export async function providerAuthStatuses(models) {
238
482
  }
239
483
  return statuses;
240
484
  }
241
- /** Which source currently satisfies auth for `spec` — a startup diagnostic. */
485
+ /**
486
+ * Which source currently satisfies auth for `spec`, or undefined when none does (or the spec is not in `models`).
487
+ * Rejects when resolving fails — a corrupt entry, a refresh the provider refused — which is a different answer from
488
+ * "nothing configured" and must reach whoever reports it.
489
+ */
242
490
  export async function probeAuthSource(models, spec) {
243
491
  const slash = spec.indexOf("/");
244
492
  if (slash < 1)
@@ -246,8 +494,10 @@ export async function probeAuthSource(models, spec) {
246
494
  const model = models.getModel(spec.slice(0, slash), spec.slice(slash + 1));
247
495
  if (!model)
248
496
  return undefined;
249
- const auth = await models.getAuth(model).catch(() => undefined);
250
- return auth?.source;
497
+ // A virtual selection authenticates the physical model after routing, not its catalog entry.
498
+ if (model.api === "pi-virtual")
499
+ return "virtual";
500
+ return (await models.getAuth(model))?.source;
251
501
  }
252
502
  /** Quick-fail probe for a just-stored API key. */
253
503
  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";
3
- import type { AgentCommand, SessionControl } from "../../session.ts";
2
+ import { type FastagentConfig } from "./config.ts";
3
+ import type { AgentCommand, ModelDescriptor, SessionControl } from "../../session.ts";
4
+ import { type IndirectTool, 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,32 +70,72 @@ 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;
70
- /** The full mounted tool surface (all coding tools + config.tools + discovered tools/, search_tools applied). */
73
+ /** The credential store and model registry every turn and every report reads ({@link agentModels}). */
74
+ models: AgentModels;
75
+ /** The full mounted tool surface (all coding tools + config.tools + discovered tools/). */
71
76
  tools: MountedTool[];
72
77
  toolNames: string[];
73
- deferredToolNames: string[];
78
+ indirectTools: IndirectTool[];
74
79
  toolCollisions: ToolCollision[];
75
80
  /** Env vars the mounted tools declared, by tool name — already asserted present by this function. */
76
81
  toolSecrets: Map<string, DeclaredSecret[]>;
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
- * The model specs `createPiAgentFromDir(dir, { authPath })` could run now: the agent's registry (pi's built-ins plus
84
- * its `models.json`) filtered to the providers whose credentials are configured, sorted. The directory needs no model
85
- * set, and the same placement and credential layers as the opener apply, so a listed spec authenticates there.
86
- * Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
99
+ * The models `createPiAgentFromDir(dir, { authPath })` could run now, as a picker shows them (spec, name, the thinking
100
+ * levels a session on it accepts, context window), sorted by spec: the agent's registry (pi's built-ins, its
101
+ * `models.json`, and what its `extensions/` declare) filtered to the providers whose credentials are configured. The
102
+ * directory needs no model set, and the same placement and credential layers as the opener apply, so a listed spec
103
+ * authenticates there. Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
87
104
  *
88
105
  * `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
89
106
  * configured", so a client that must not show that as an empty list passes a sink that throws.
90
107
  */
91
- export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & {
92
- authPath?: string;
93
- }): Promise<string[]>;
108
+ export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions): Promise<ModelDescriptor[]>;
109
+ /**
110
+ * Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
111
+ * `createPiAgentFromDir(dir, { authPath })` would use, so a model released after the installed pi appears in
112
+ * {@link availableModelsFromDir} and runs. Only providers those credentials authenticate are fetched (pi asks pi.dev
113
+ * for no other). Nothing refreshes it on its own. The file is part of the definition: commit it, and it ships with a
114
+ * deploy, so the deployed agent knows the same models without a network call.
115
+ *
116
+ * Rejects, naming each provider that failed, when the refresh fails, outlasts 15 seconds or `PI_OFFLINE` is set, and
117
+ * when the credentials authenticate no provider at all.
118
+ */
119
+ export declare function refreshModelCatalog(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions & {
120
+ signal?: AbortSignal;
121
+ }): Promise<void>;
122
+ /** {@link refreshModelCatalog} against another catalog server. Not public: a test seam. */
123
+ export declare function refreshModelCatalogOver(dir: string, options: FastagentAuthOptions & CredentialSourceOptions & {
124
+ signal?: AbortSignal;
125
+ }, catalogBaseUrl?: string): Promise<void>;
126
+ /**
127
+ * Refresh the MACHINE's model catalog (`~/.fastagent/models-store.json`), which every agent here reads under its own,
128
+ * so one refresh serves them all and no file is written into any agent. Credentials resolve as for
129
+ * {@link refreshModelCatalog} without a directory: `credentialStore`, else `authPath`, else the global credentials file;
130
+ * the environment applies either way. Rejects for the same reasons. The file never ships with a deploy.
131
+ */
132
+ export declare function refreshMachineModelCatalog(options?: FastagentAuthOptions & CredentialSourceOptions & {
133
+ signal?: AbortSignal;
134
+ }): Promise<void>;
135
+ /** {@link refreshMachineModelCatalog} against another catalog server. Not public: a test seam. */
136
+ export declare function refreshMachineModelCatalogOver(options: FastagentAuthOptions & CredentialSourceOptions & {
137
+ signal?: AbortSignal;
138
+ }, catalogBaseUrl?: string): Promise<void>;
94
139
  /**
95
140
  * "Point at a directory → agent": resolve the placement (`dir` may be either end — the workspace or the agent dir
96
141
  * itself), load the config, resolve model and tools, then L2.
@@ -110,8 +155,8 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
110
155
  stateRoot: string;
111
156
  /** Absolute session store directory in use (for the startup report). */
112
157
  sessionsDir: string;
113
- /** The credentials files in use — the startup report names whichever layer the credential came from. */
114
- auth: AuthLayers;
158
+ /** The credential store and registry the agent runs on — what the startup report describes. */
159
+ models: AgentModels;
115
160
  sessions: PiSessionRecordStore;
116
161
  /** The observation plane over this agent's sessions; present on every serve (a channel's stop command reaches the
117
162
  * live run through it). Its boundary is wired only when {@link publishControl}. */
@@ -122,7 +167,7 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
122
167
  http?: HttpSurface;
123
168
  /** Non-default, active-by-default tool names in effect: config.tools + discovered tools/. */
124
169
  toolNames: string[];
125
- /** Tools registered but not initially active (deferred) — activated via search_tools. */
126
- deferredToolNames: string[];
170
+ /** Mounted authored tools the model is not given up front, with how each is reached. */
171
+ indirectTools: IndirectTool[];
127
172
  toolCollisions: ToolCollision[];
128
173
  }>;