@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
@@ -3,45 +3,14 @@
3
3
  * durable record, per invoke.
4
4
  */
5
5
  import { dirname } from "node:path";
6
- import { readMachine, withMachine } from "./machine.js";
7
- import { ExtensionRunner, ModelRegistry, SessionManager, DefaultResourceLoader, createAgentSessionFromServices, getAgentDir, initTheme, } from "@earendil-works/pi-coding-agent";
6
+ import { BUILTIN_EXTENSIONS, DISCOVERY, readMachine, withMachine } from "./machine.js";
7
+ import { ExtensionRunner, ModelRegistry, SessionManager, createAgentSessionServices, createCodemodeExtension, createToolSearchExtension, createAgentSessionFromServices, getAgentDir, initTheme, } from "@earendil-works/pi-coding-agent";
8
8
  import { log } from "../../log.js";
9
- import { isDeferredTool } from "./tool.js";
9
+ import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
10
+ import { resolveModel } from "./config.js";
10
11
  import { activePath, resolveSessionSettings } from "./session-settings.js";
11
- import { DEFAULT_THINKING_LEVEL } from "./models.js";
12
+ import { DEFAULT_THINKING_LEVEL, withModelRegistration } from "./models.js";
12
13
  import { agentSessionManager, sessionToolActivation, turnContext } from "./tool-context.js";
13
- /**
14
- * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred tools a loader
15
- * activated in that call.
16
- *
17
- * Not replaceable by pi's own transcript record. Since 0.86 the journal carries `toolsAdded` system messages and
18
- * `getCurrentTools()` replays them, but that answers a different question: it reports every tool ever DECLARED to
19
- * the model, which cannot tell a tool this conversation DISCOVERED from one that merely happened to be in the
20
- * initial set at the time. Restoring from it would keep a tool later flipped to `deferred` active in sessions that
21
- * never discovered it. This entry holds only what a loader activated, which is what makes the restore below
22
- * "today's non-deferred tools PLUS what this conversation found".
23
- */
24
- const TOOL_ACTIVATION_ENTRY = "fastagent:tool-activation";
25
- /** Every deferred tool this session has ever discovered, oldest first. */
26
- function recordedActivations(session) {
27
- const names = [];
28
- for (const entry of session.sessionManager.getBranch()) {
29
- const record = entry;
30
- if (record.type !== "custom" || record.customType !== TOOL_ACTIVATION_ENTRY)
31
- continue;
32
- if (Array.isArray(record.data?.names)) {
33
- for (const name of record.data.names)
34
- if (typeof name === "string")
35
- names.push(name);
36
- }
37
- }
38
- return names;
39
- }
40
- /**
41
- * Warned once per session+missing set: a fresh session is built per invoke and channel sessions run for weeks, so an
42
- * un-deduped warn would repeat every turn and dilute its own signal.
43
- */
44
- const warnedDroppedActivations = new Set();
45
14
  /** fastagent's tools as pi tool definitions, bound to ONE session. */
46
15
  function toolDefinitions(tools, bound, sessionId) {
47
16
  return tools.map((tool) => ({
@@ -72,14 +41,22 @@ function toolDefinitions(tools, bound, sessionId) {
72
41
  },
73
42
  }));
74
43
  }
44
+ /** The {@link DISCOVERY} tools the mounted tools' exposures need. A disabled extension's name is ignored by Pi. */
45
+ function discoveryToolNames(tools) {
46
+ const names = new Set();
47
+ for (const tool of tools) {
48
+ if (tool.exposure === "codemode" || tool.exposure === "deferred")
49
+ names.add(DISCOVERY[tool.exposure].tool);
50
+ }
51
+ return [...names];
52
+ }
75
53
  /**
76
- * Bind ONE pi session to a record: the definition's tools as pi definitions over one turn context, pi's own tool
77
- * copies kept off, and deferral applied.
54
+ * Bind ONE pi session to a record: authored tools replace same-name built-ins; Pi owns exposure and discovery.
78
55
  */
79
56
  export async function bindPiSession(options) {
80
- const { services, sessionManager, model, thinkingLevel, tools, cwd, recordActivations } = options;
57
+ const { services, sessionManager, model, thinkingLevel, tools, cwd } = options;
81
58
  const excludedToolNames = options.excludedToolNames ?? [];
82
- const deferred = tools.filter(isDeferredTool).map((t) => t.name);
59
+ const history = sessionManager.buildSessionContext().messages;
83
60
  const bound = {};
84
61
  const sessionId = options.sessionId ?? sessionManager.getSessionId();
85
62
  const result = await createAgentSessionFromServices({
@@ -88,8 +65,6 @@ export async function bindPiSession(options) {
88
65
  ...(options.sessionStartEvent ? { sessionStartEvent: options.sessionStartEvent } : {}),
89
66
  model,
90
67
  thinkingLevel,
91
- // pi would otherwise mount its built-ins on top of fastagent's copies, offering duplicate names.
92
- noTools: "builtin",
93
68
  ...(excludedToolNames.length > 0 ? { excludeTools: [...excludedToolNames] } : {}),
94
69
  customTools: toolDefinitions(tools, bound, sessionId),
95
70
  });
@@ -100,86 +75,59 @@ export async function bindPiSession(options) {
100
75
  context: {
101
76
  cwd,
102
77
  sessionManager: agentSessionManager(session, sessionId),
103
- // Persist each discovery so a served session can restore it on its next turn.
104
- tools: sessionToolActivation(session, recordActivations
105
- ? (added) => session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added })
106
- : undefined),
78
+ tools: sessionToolActivation(session),
107
79
  },
108
80
  };
109
- // Deferral, then restoration: pi starts every mounted tool active, so narrow by SUBTRACTING the deferred names
110
- // (robust to pi mounting tools of its own, unlike an exact-set replacement), then add back what THIS session has
111
- // already discovered.
112
- if (deferred.length > 0) {
113
- const active = session.getActiveToolNames();
114
- const mounted = new Set(session.getAllTools().map((tool) => tool.name));
115
- const recorded = recordActivations ? recordedActivations(session) : [];
116
- // A recorded name that is no longer mounted is dropped rather than replayed.
117
- const restored = recorded.filter((name) => mounted.has(name));
118
- const dropped = recorded.filter((name) => !mounted.has(name));
119
- if (dropped.length > 0) {
120
- const key = `${sessionId}\u0000${[...new Set(dropped)].sort().join(",")}`;
121
- const emit = warnedDroppedActivations.has(key) ? log.debug : log.warn;
122
- warnedDroppedActivations.add(key);
123
- emit(`[fastagent] session ${sessionId}: dropping recorded activation(s) no longer mounted: ${[...new Set(dropped)].join(", ")}`);
124
- }
125
- const next = [...new Set([...active.filter((name) => !deferred.includes(name)), ...restored])];
126
- if (next.length !== active.length || next.some((name) => !active.includes(name))) {
127
- session.setActiveToolsByName(next);
128
- }
129
- }
81
+ // The SDK always supplies an initial loadout (settings' defaultTools applied), bypassing AgentSession's native
82
+ // transcript restore. The loadout is the definition's defaults as they are NOW, plus whatever the transcript still
83
+ // declares and is still mounted (a discovery). Removals in the transcript are not honored: fastagent's own activation
84
+ // only adds, so a removal records a turn on which the definition did not mount the tool, not the conversation's
85
+ // choice, and honoring it would keep a tool out of a long-lived chat after a release that briefly lacked it.
86
+ // Replayed through Pi's public APIs, for serving and chat alike.
87
+ const defaults = [...session.getActiveToolNames(), ...discoveryToolNames(tools)];
88
+ const current = getCurrentSystemMessage(history);
89
+ const mounted = new Set(session.getAllTools().map((tool) => tool.name));
90
+ const declared = (current?.toolsAdded ?? []).map((tool) => tool.name);
91
+ const dropped = declared.filter((name) => !mounted.has(name));
92
+ // Debug, not warn: `session_start` handlers register their tools after this point, and Pi activates the
93
+ // default-active ones when they do (`_refreshToolRegistry`), so an absence here is routine on every such bind.
94
+ if (dropped.length)
95
+ log.debug(`[fastagent] session ${sessionId}: not mounted yet or removed: ${dropped.join(", ")}`);
96
+ session.setActiveToolsByName([...defaults, ...declared.filter((name) => mounted.has(name))]);
130
97
  return result;
131
98
  }
132
- /** Each distinct load failure is said once per process: serving loads the extensions again for every session. */
133
- const reportedExtensionErrors = new Set();
99
+ let themeReady = false;
100
+ /** Each distinct load diagnostic is said once per process: serving loads the extensions again for every session. */
101
+ const reportedExtensionDiagnostics = new Set();
134
102
  /**
135
- * Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and carries on with the
136
- * rest.
103
+ * Extension notifications said once per process at their own level, then at debug: every served turn starts the
104
+ * extensions again, so a notification from `session_start` would otherwise log the same line on every message.
137
105
  */
138
- export function reportExtensionErrors(services) {
139
- for (const { path, error } of services.resourceLoader.getExtensions().errors) {
140
- const key = `${path}\u0000${error}`;
141
- if (reportedExtensionErrors.has(key))
142
- continue;
143
- reportedExtensionErrors.add(key);
144
- log.warn(`[fastagent] extension ${path} failed to load: ${error}`);
145
- }
146
- }
147
- const PROVIDER_REFUSAL = "extensions cannot register model providers when serving: a provider is process-wide, shared by every " +
148
- "conversation, and never unregistered. Declare it in the agent's models.json";
106
+ const reportedNotifications = new Set();
107
+ // ponytail: cleared wholesale at this size, so a notification with a counter or id in it cannot grow the set for the
108
+ // life of a `start` process; the price is one repeat at warn level after each clear. An LRU if that ever matters.
109
+ const MAX_REPORTED_NOTIFICATIONS = 1000;
149
110
  /**
150
- * Serving refuses an extension that registers a provider while loading. pi would write it into the ONE `ModelRuntime`
151
- * every conversation resolves against, merge a re-registration into the old entry, and never drop one the code stopped
152
- * registering. The whole extension is left out, the way a tool file that fails to load is, and the refusal is its
153
- * load error.
111
+ * Announce extensions pi failed to load, and what it warned about while loading the rest: among them a definition
112
+ * extension that registers `codemode` or `tool_search`, which keeps pi's built-in of that name from loading. pi
113
+ * collects both into `LoadExtensionsResult` and carries on; only its own TUI shows them.
154
114
  */
155
- function refuseLoadTimeProviders(base) {
156
- const offenders = new Set([
157
- ...base.runtime.pendingProviderRegistrations.map((r) => r.extensionPath),
158
- ...base.runtime.pendingNativeProviderRegistrations.map((r) => r.extensionPath),
159
- ]);
160
- if (offenders.size === 0)
161
- return base;
162
- base.runtime.pendingProviderRegistrations = [];
163
- base.runtime.pendingNativeProviderRegistrations = [];
164
- return {
165
- ...base,
166
- extensions: base.extensions.filter((extension) => !offenders.has(extension.path)),
167
- errors: [...base.errors, ...[...offenders].map((path) => ({ path, error: PROVIDER_REFUSAL }))],
168
- };
169
- }
170
- /**
171
- * ...and a registration made AFTER loading (from an event handler or a command), which pi applies to the shared
172
- * runtime directly. Must run after the session is created (pi installs its own actions then) and before
173
- * `bindExtensions` (which fires `session_start`).
174
- */
175
- function refuseLateProviders(services) {
176
- const runtime = services.resourceLoader.getExtensions().runtime;
177
- const refuse = () => {
178
- throw new Error(PROVIDER_REFUSAL);
179
- };
180
- runtime.registerProvider = refuse;
181
- runtime.registerNativeProvider = refuse;
182
- runtime.unregisterProvider = refuse;
115
+ function reportExtensionDiagnostics(services) {
116
+ for (const diagnostic of services.diagnostics) {
117
+ if (diagnostic.type === "error")
118
+ throw new Error(diagnostic.message);
119
+ }
120
+ const { errors, warnings = [] } = services.resourceLoader.getExtensions();
121
+ const said = [
122
+ ...errors.map(({ path, error }) => `extension ${path} failed to load: ${error}`),
123
+ ...warnings.map(({ path, warning }) => `extension ${path}: ${warning}`),
124
+ ];
125
+ for (const line of said) {
126
+ if (reportedExtensionDiagnostics.has(line))
127
+ continue;
128
+ reportedExtensionDiagnostics.add(line);
129
+ log.warn(`[fastagent] ${line}`);
130
+ }
183
131
  }
184
132
  /**
185
133
  * What a command's `ctx` can do to the session when serving. Each turn binds its own session and disposes it, so
@@ -199,14 +147,28 @@ function servingCommandActions(session) {
199
147
  reload: unavailable("reload"),
200
148
  };
201
149
  }
150
+ const nativeFactories = {
151
+ codemode: createCodemodeExtension(),
152
+ "tool-search": createToolSearchExtension(),
153
+ };
154
+ const nativeExtensions = BUILTIN_EXTENSIONS.map((name) => ({
155
+ name,
156
+ factory: nativeFactories[name],
157
+ replaceable: true,
158
+ builtin: true,
159
+ }));
202
160
  /** The resource posture a fastagent definition asks pi for — ONE definition of it, for both assemblies. */
203
161
  export function definitionResourceLoaderOptions(source) {
204
162
  return {
163
+ extensionFactories: [...nativeExtensions, compactAdmission],
205
164
  // The machine's extensions are its owner's setup, not this agent's.
206
165
  noExtensions: true,
207
- // ...except the definition's OWN extensions/: pi honours additionalExtensionPaths even under noExtensions, which
208
- // is exactly the split wanted here.
209
- ...(source.extensionPaths?.length ? { additionalExtensionPaths: [...source.extensionPaths] } : {}),
166
+ // Explicit paths survive noExtensions, including Pi's built-ins; so they would also survive the machine's own
167
+ // `-builtin:<name>`, which is why only the enabled ones are named. Machine extensions stay out.
168
+ additionalExtensionPaths: [
169
+ ...source.machine.builtinExtensions.map((name) => `builtin:${name}`),
170
+ ...(source.extensionPaths ?? []),
171
+ ],
210
172
  // Not pi's: fastagent already loads the SAME files into segment ② (`loadProjectContextFiles` in
211
173
  // definition.ts). Leaving both on would put every AGENTS.md in the prompt twice.
212
174
  noContextFiles: true,
@@ -295,48 +257,36 @@ export function startCompaction(session, instructions, onAdmitted) {
295
257
  }, (error) => error instanceof Error && NOTHING_TO_COMPACT.test(error.message) ? "nothing_to_compact" : { refused: error });
296
258
  return { admission: Promise.race([admitted, settledFirst]), done };
297
259
  }
298
- /**
299
- * The resources ONE served session runs on: a fresh loader, so fresh extension instances.
300
- *
301
- * Assembled here rather than by pi's `createAgentSessionServices`, which ends by refreshing the model runtime it is
302
- * given — re-reading `models.json` and rebuilding every provider on the ONE runtime all conversations share. That is
303
- * there to fold in providers extensions registered, which serving refuses; per session it would be a full provider
304
- * rebuild per turn.
305
- */
306
- async function servingServices(options) {
260
+ /** A fresh loader and Pi's native model registration, over a session-local runtime. */
261
+ export async function definitionServices(options) {
307
262
  const { cwd, modelRuntime, definition, extensionPaths } = options;
308
263
  const machine = await readMachine(cwd);
309
- const agentDir = getAgentDir();
310
- // The machine's engine settings, as read at boot and without `packages` — a turn never resolves one.
311
- const settingsManager = machine.settingsManager();
312
- const resourceLoader = new DefaultResourceLoader({
313
- ...definitionResourceLoaderOptions({
314
- systemPrompt: () => definition.systemPrompt,
315
- skills: () => definition.skills,
316
- machine,
317
- extensionPaths,
318
- }),
319
- extensionsOverride: (base) => admissionFirst(refuseLoadTimeProviders(base)),
320
- extensionFactories: [compactAdmission],
264
+ const services = await withModelRegistration(modelRuntime, () => createAgentSessionServices({
321
265
  cwd,
322
- agentDir,
323
- settingsManager,
324
- });
325
- await resourceLoader.reload();
326
- const services = { cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics: [] };
327
- reportExtensionErrors(services);
266
+ agentDir: getAgentDir(),
267
+ modelRuntime,
268
+ settingsManager: machine.settingsManager(),
269
+ resourceLoaderOptions: {
270
+ ...definitionResourceLoaderOptions({
271
+ systemPrompt: () => definition.systemPrompt,
272
+ skills: () => definition.skills,
273
+ machine,
274
+ extensionPaths,
275
+ }),
276
+ extensionsOverride: admissionFirst,
277
+ },
278
+ }));
279
+ reportExtensionDiagnostics(services);
328
280
  return services;
329
281
  }
330
282
  /**
331
283
  * The `/name` commands a served session dispatches, named the way pi resolves them (a name two extensions share gets
332
- * a `:N` suffix). Loaded through the same {@link servingServices} a turn binds, so the menu and the dispatch cannot
284
+ * a `:N` suffix). Loaded through the same {@link definitionServices} a turn binds, so the menu and the dispatch cannot
333
285
  * disagree. Loading runs the extensions' factories; no session opens, so `session_start` does not fire, and the
334
286
  * instances are never bound (any action they call throws).
335
287
  */
336
288
  export async function servedExtensionCommands(options) {
337
- if (options.extensionPaths.length === 0)
338
- return [];
339
- const services = await servingServices({ ...options, definition: { skills: [] } });
289
+ const services = await definitionServices({ ...options, definition: { skills: [] } });
340
290
  const { extensions, runtime } = services.resourceLoader.getExtensions();
341
291
  const runner = new ExtensionRunner(extensions, runtime, options.cwd, SessionManager.inMemory(options.cwd), new ModelRegistry(options.modelRuntime));
342
292
  return runner.getRegisteredCommands();
@@ -355,17 +305,19 @@ export function piAgentSessionFactory(options) {
355
305
  const extensionPaths = options.extensionPaths ?? [];
356
306
  const excludedToolNames = options.excludedToolNames ?? [];
357
307
  const tools = options.tools ?? [];
358
- // `ctx.ui.theme` reads pi's global theme, which only pi's own entry points initialize.
359
- if (extensionPaths.length > 0)
360
- initTheme();
361
- let engine;
362
308
  return async (sessionId, inherit) => {
363
- const definition = await options.readDefinition();
364
- engine ??= options.engine();
365
- const { modelRuntime, model } = await engine;
366
- // The record first: a control write that races this turn must find the session and be refused busy.
309
+ // `ctx.ui.theme` reads pi's global theme, which only pi's own entry points initialize. Serving only: chat's theme
310
+ // is InteractiveMode's, and resetting it on every session switch would drop the user's.
311
+ if (!themeReady) {
312
+ initTheme();
313
+ themeReady = true;
314
+ }
315
+ // Publish the record before loading resources: boundary writes must find it while binding is in flight.
367
316
  const sessionManager = await sessions.openOrCreate(sessionId, inherit);
368
- const services = await servingServices({ cwd, modelRuntime, definition, extensionPaths });
317
+ const definition = await options.readDefinition();
318
+ const { modelRuntime } = await options.engine();
319
+ const services = await definitionServices({ cwd, modelRuntime, definition, extensionPaths });
320
+ const model = resolveModel(modelRuntime, options.modelSpec);
369
321
  // What the session RUNS on: the boundary plane records model/thinking overrides as entries, and pi does not read
370
322
  // them back.
371
323
  const settings = resolveSessionSettings(activePath(sessionManager), modelRuntime, {
@@ -381,9 +333,30 @@ export function piAgentSessionFactory(options) {
381
333
  cwd,
382
334
  excludedToolNames,
383
335
  sessionId,
384
- recordActivations: true,
385
336
  });
386
- refuseLateProviders(services);
337
+ // Pi's headless UI drops notifications. Keep dialogs cancelled and hasUI false, but surface diagnostics.
338
+ const runner = session.extensionRunner;
339
+ if (runner) {
340
+ const createContext = runner.createContext.bind(runner);
341
+ runner.createContext = () => {
342
+ const context = createContext();
343
+ // Pi clones own descriptors for before_agent_start; inherited fields would disappear there.
344
+ return Object.defineProperty(context, "ui", {
345
+ value: {
346
+ ...context.ui,
347
+ notify: (message, type) => {
348
+ const key = `${type ?? "info"}\u0000${message}`;
349
+ const repeat = reportedNotifications.has(key);
350
+ if (reportedNotifications.size >= MAX_REPORTED_NOTIFICATIONS)
351
+ reportedNotifications.clear();
352
+ reportedNotifications.add(key);
353
+ const emit = repeat ? log.debug : type === "info" ? log.info : log.warn;
354
+ emit(`[fastagent] session ${sessionId}: ${message}`);
355
+ },
356
+ },
357
+ });
358
+ };
359
+ }
387
360
  // `onError` is THE ONLY LISTENER for a fault pi reports nowhere else: `/skill:<name>` is expanded by reading
388
361
  // `filePath` at prompt time, and when that read fails pi raises `skill_expansion` on the extension error channel
389
362
  // and sends the line to the model unexpanded. The list can outlive the file: it is refreshed per invoke, so a
@@ -8,15 +8,68 @@ export interface FastagentAuthOptions {
8
8
  /** Sink for non-fatal auth anomalies (unreadable/corrupt file). */
9
9
  warn?: (message: string) => void;
10
10
  }
11
+ /**
12
+ * Where a caller's model credentials live: fastagent's JSON file (`authPath`, or the default layers when neither is
13
+ * given), or a {@link CredentialStore} the caller owns (an OS keychain, Electron `safeStorage`). A supplied store
14
+ * replaces the FILES: none is read or written, and no global layer applies. Env variables and `models.json` keys still
15
+ * apply, below a stored credential, exactly as with `authPath`. The store owns what the file store does
16
+ * for its file: `modify` for one provider must run one at a time, because an OAuth refresh happens inside it and a
17
+ * rotated refresh token must never be used twice; and a store it cannot parse must not be answered as empty, or the
18
+ * next write replaces every provider's credential.
19
+ */
20
+ export interface CredentialSourceOptions {
21
+ /** fastagent's credentials file. Mutually exclusive with {@link credentialStore}. */
22
+ authPath?: string;
23
+ /** The caller's own store, read and written instead of any file. Mutually exclusive with {@link authPath}. */
24
+ credentialStore?: CredentialStore;
25
+ }
26
+ /** The one refusal for a caller that names both sources: silently preferring one would hide the other's mistake. */
27
+ export declare function assertOneCredentialSource(options: CredentialSourceOptions): void;
28
+ /** A credential store over fastagent's files, which can also say which file a provider belongs to. */
29
+ export type FastagentCredentialStore = CredentialStore & {
30
+ /**
31
+ * The file this store reads a provider from and writes its refresh to: where it already is, else the primary. The
32
+ * one answer to "which file do I edit", so a report naming a file cannot disagree with the store that reads it.
33
+ */
34
+ layerOf(providerId: string): Promise<string>;
35
+ };
11
36
  /**
12
37
  * A read-write `CredentialStore` over the given credentials file (default {@link GLOBAL_AUTH_PATH}; the directory
13
- * opener passes the project-level `<root>/.secrets/auth.json`), optionally falling back to a second file.
38
+ * opener passes the project-level `<root>/.secrets/auth.json`), optionally falling back to a second file. A path is
39
+ * read like `FASTAGENT_AUTH_PATH` (`~` is the home directory, a relative one resolves against the cwd), so every
40
+ * caller that names a file names the same one.
14
41
  *
15
42
  * The fallback is PER PROVIDER, not per file: logging one provider into a project must not hide the others a person
16
43
  * already has globally. And the layer a credential was READ from is the layer its refresh is written back to —
17
44
  * anything else would conjure a second holder of the same OAuth grant, which is the failure this whole area exists
18
45
  * to avoid. A provider present in neither layer is new, and new credentials belong to the primary.
46
+ *
47
+ * The fallback also yields to `projectAuthenticates`: a provider the project authenticates some other way (an env key,
48
+ * its own models.json) never reaches it — for reading, listing or writing back.
19
49
  */
20
- export declare function fastagentCredentialStore(authPath?: string, options?: FastagentAuthOptions & {
50
+ export declare function fastagentCredentialStore(path?: string, options?: FastagentAuthOptions & {
21
51
  fallbackPath?: string;
22
- }): CredentialStore;
52
+ /** Whether the project authenticates this provider without the fallback; then the fallback is not read for it. */
53
+ projectAuthenticates?: (providerId: string) => Promise<boolean>;
54
+ }): FastagentCredentialStore;
55
+ /** The effective auth file for an agent: override if present, else `<secrets dir>/auth.json`. */
56
+ export declare function resolveAuthPath(dir: string, flag?: string, env?: NodeJS.ProcessEnv): string;
57
+ /**
58
+ * Which credentials files an agent reads: `path` first, then, per provider, `fallback`. One value because it is one
59
+ * decision, read through `agentModels` (agent-models.ts) by everything that reads an agent's credentials.
60
+ */
61
+ export interface AuthLayers {
62
+ path: string;
63
+ fallback?: string;
64
+ }
65
+ /**
66
+ * The {@link AuthLayers} an agent directory reads: `authPath` option > `FASTAGENT_AUTH_PATH` > its own file, and
67
+ * behind it the user-global store, because a login is a PERSON on a machine and not a project — one `login -g` then
68
+ * serves every agent here.
69
+ *
70
+ * No fallback when a path was named: "use this file" is an instruction, not a preference. BOTH knobs
71
+ * {@link resolveAuthPath} reads count as that instruction, `FASTAGENT_SECRETS_DIR` included — it is what the
72
+ * Fly/Railway/AgentCore artifacts set, and a deployed container must read its mounted credentials and nothing else
73
+ * (the artifact is the truth).
74
+ */
75
+ export declare function resolveAuthLayers(agentDir: string, authPath?: string, env?: NodeJS.ProcessEnv): AuthLayers;