@fastagent-sh/fastagent 0.23.0 → 0.24.1
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.
- package/dist/agent.d.ts +10 -0
- package/dist/channels/kit/preview-kit.d.ts +6 -1
- package/dist/channels/kit/preview-kit.js +21 -4
- package/dist/channels/slack/preview.js +2 -1
- package/dist/cli/commands/info.js +7 -7
- package/dist/cli/commands/login.js +2 -2
- package/dist/cli/commands/models.js +3 -3
- package/dist/cli/invoke-stream.js +14 -0
- package/dist/cli/shared.d.ts +2 -1
- package/dist/cli/shared.js +6 -7
- package/dist/deploy/preflight.js +19 -1
- package/dist/dev-supervisor.js +2 -2
- package/dist/engines/pi/agent-models.d.ts +14 -1
- package/dist/engines/pi/agent-models.js +35 -8
- package/dist/engines/pi/agent-session-factory.d.ts +13 -13
- package/dist/engines/pi/agent-session-factory.js +137 -159
- package/dist/engines/pi/auth.js +6 -94
- package/dist/engines/pi/create.d.ts +16 -9
- package/dist/engines/pi/create.js +71 -38
- package/dist/engines/pi/invoke-session.js +82 -16
- package/dist/engines/pi/locked-file.d.ts +9 -0
- package/dist/engines/pi/locked-file.js +102 -0
- package/dist/engines/pi/login.d.ts +7 -1
- package/dist/engines/pi/login.js +22 -6
- package/dist/engines/pi/machine.d.ts +23 -0
- package/dist/engines/pi/machine.js +24 -2
- package/dist/engines/pi/models.d.ts +47 -42
- package/dist/engines/pi/models.js +154 -67
- package/dist/engines/pi/open.d.ts +29 -12
- package/dist/engines/pi/open.js +52 -21
- package/dist/engines/pi/openai-account-models.d.ts +35 -0
- package/dist/engines/pi/openai-account-models.js +116 -0
- package/dist/engines/pi/report.d.ts +3 -0
- package/dist/engines/pi/report.js +11 -0
- package/dist/engines/pi/session-builder.js +6 -22
- package/dist/engines/pi/session-control.d.ts +4 -4
- package/dist/engines/pi/session-control.js +72 -40
- package/dist/engines/pi/session-settings.d.ts +9 -0
- package/dist/engines/pi/session-settings.js +17 -0
- package/dist/engines/pi/session-store.d.ts +2 -2
- package/dist/engines/pi/tool-context.d.ts +3 -5
- package/dist/engines/pi/tool-context.js +4 -16
- package/dist/engines/pi/tool.d.ts +32 -21
- package/dist/engines/pi/tool.js +21 -25
- package/dist/engines/pi/turn-kit.d.ts +18 -9
- package/dist/engines/pi/turn-kit.js +104 -27
- package/dist/pi.d.ts +1 -1
- package/dist/pi.js +1 -1
- package/dist/session.d.ts +60 -8
- package/dist/session.js +5 -1
- package/package.json +5 -5
- package/dist/engines/pi/search-tools.d.ts +0 -4
- package/dist/engines/pi/search-tools.js +0 -109
|
@@ -3,45 +3,15 @@
|
|
|
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,
|
|
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 {
|
|
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";
|
|
13
|
+
import { registerAccountModels } from "./openai-account-models.js";
|
|
12
14
|
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
15
|
/** fastagent's tools as pi tool definitions, bound to ONE session. */
|
|
46
16
|
function toolDefinitions(tools, bound, sessionId) {
|
|
47
17
|
return tools.map((tool) => ({
|
|
@@ -72,14 +42,22 @@ function toolDefinitions(tools, bound, sessionId) {
|
|
|
72
42
|
},
|
|
73
43
|
}));
|
|
74
44
|
}
|
|
45
|
+
/** The {@link DISCOVERY} tools the mounted tools' exposures need. A disabled extension's name is ignored by Pi. */
|
|
46
|
+
function discoveryToolNames(tools) {
|
|
47
|
+
const names = new Set();
|
|
48
|
+
for (const tool of tools) {
|
|
49
|
+
if (tool.exposure === "codemode" || tool.exposure === "deferred")
|
|
50
|
+
names.add(DISCOVERY[tool.exposure].tool);
|
|
51
|
+
}
|
|
52
|
+
return [...names];
|
|
53
|
+
}
|
|
75
54
|
/**
|
|
76
|
-
* Bind ONE pi session to a record:
|
|
77
|
-
* copies kept off, and deferral applied.
|
|
55
|
+
* Bind ONE pi session to a record: authored tools replace same-name built-ins; Pi owns exposure and discovery.
|
|
78
56
|
*/
|
|
79
57
|
export async function bindPiSession(options) {
|
|
80
|
-
const { services, sessionManager, model, thinkingLevel, tools, cwd
|
|
58
|
+
const { services, sessionManager, model, thinkingLevel, tools, cwd } = options;
|
|
81
59
|
const excludedToolNames = options.excludedToolNames ?? [];
|
|
82
|
-
const
|
|
60
|
+
const history = sessionManager.buildSessionContext().messages;
|
|
83
61
|
const bound = {};
|
|
84
62
|
const sessionId = options.sessionId ?? sessionManager.getSessionId();
|
|
85
63
|
const result = await createAgentSessionFromServices({
|
|
@@ -88,8 +66,6 @@ export async function bindPiSession(options) {
|
|
|
88
66
|
...(options.sessionStartEvent ? { sessionStartEvent: options.sessionStartEvent } : {}),
|
|
89
67
|
model,
|
|
90
68
|
thinkingLevel,
|
|
91
|
-
// pi would otherwise mount its built-ins on top of fastagent's copies, offering duplicate names.
|
|
92
|
-
noTools: "builtin",
|
|
93
69
|
...(excludedToolNames.length > 0 ? { excludeTools: [...excludedToolNames] } : {}),
|
|
94
70
|
customTools: toolDefinitions(tools, bound, sessionId),
|
|
95
71
|
});
|
|
@@ -100,86 +76,59 @@ export async function bindPiSession(options) {
|
|
|
100
76
|
context: {
|
|
101
77
|
cwd,
|
|
102
78
|
sessionManager: agentSessionManager(session, sessionId),
|
|
103
|
-
|
|
104
|
-
tools: sessionToolActivation(session, recordActivations
|
|
105
|
-
? (added) => session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added })
|
|
106
|
-
: undefined),
|
|
79
|
+
tools: sessionToolActivation(session),
|
|
107
80
|
},
|
|
108
81
|
};
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
}
|
|
82
|
+
// The SDK always supplies an initial loadout (settings' defaultTools applied), bypassing AgentSession's native
|
|
83
|
+
// transcript restore. The loadout is the definition's defaults as they are NOW, plus whatever the transcript still
|
|
84
|
+
// declares and is still mounted (a discovery). Removals in the transcript are not honored: fastagent's own activation
|
|
85
|
+
// only adds, so a removal records a turn on which the definition did not mount the tool, not the conversation's
|
|
86
|
+
// choice, and honoring it would keep a tool out of a long-lived chat after a release that briefly lacked it.
|
|
87
|
+
// Replayed through Pi's public APIs, for serving and chat alike.
|
|
88
|
+
const defaults = [...session.getActiveToolNames(), ...discoveryToolNames(tools)];
|
|
89
|
+
const current = getCurrentSystemMessage(history);
|
|
90
|
+
const mounted = new Set(session.getAllTools().map((tool) => tool.name));
|
|
91
|
+
const declared = (current?.toolsAdded ?? []).map((tool) => tool.name);
|
|
92
|
+
const dropped = declared.filter((name) => !mounted.has(name));
|
|
93
|
+
// Debug, not warn: `session_start` handlers register their tools after this point, and Pi activates the
|
|
94
|
+
// default-active ones when they do (`_refreshToolRegistry`), so an absence here is routine on every such bind.
|
|
95
|
+
if (dropped.length)
|
|
96
|
+
log.debug(`[fastagent] session ${sessionId}: not mounted yet or removed: ${dropped.join(", ")}`);
|
|
97
|
+
session.setActiveToolsByName([...defaults, ...declared.filter((name) => mounted.has(name))]);
|
|
130
98
|
return result;
|
|
131
99
|
}
|
|
132
|
-
|
|
133
|
-
|
|
100
|
+
let themeReady = false;
|
|
101
|
+
/** Each distinct load diagnostic is said once per process: serving loads the extensions again for every session. */
|
|
102
|
+
const reportedExtensionDiagnostics = new Set();
|
|
134
103
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
104
|
+
* Extension notifications said once per process at their own level, then at debug: every served turn starts the
|
|
105
|
+
* extensions again, so a notification from `session_start` would otherwise log the same line on every message.
|
|
137
106
|
*/
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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";
|
|
107
|
+
const reportedNotifications = new Set();
|
|
108
|
+
// ponytail: cleared wholesale at this size, so a notification with a counter or id in it cannot grow the set for the
|
|
109
|
+
// life of a `start` process; the price is one repeat at warn level after each clear. An LRU if that ever matters.
|
|
110
|
+
const MAX_REPORTED_NOTIFICATIONS = 1000;
|
|
149
111
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* load error.
|
|
112
|
+
* Announce extensions pi failed to load, and what it warned about while loading the rest: among them a definition
|
|
113
|
+
* extension that registers `codemode` or `tool_search`, which keeps pi's built-in of that name from loading. pi
|
|
114
|
+
* collects both into `LoadExtensionsResult` and carries on; only its own TUI shows them.
|
|
154
115
|
*/
|
|
155
|
-
function
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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;
|
|
116
|
+
function reportExtensionDiagnostics(services) {
|
|
117
|
+
for (const diagnostic of services.diagnostics) {
|
|
118
|
+
if (diagnostic.type === "error")
|
|
119
|
+
throw new Error(diagnostic.message);
|
|
120
|
+
}
|
|
121
|
+
const { errors, warnings = [] } = services.resourceLoader.getExtensions();
|
|
122
|
+
const said = [
|
|
123
|
+
...errors.map(({ path, error }) => `extension ${path} failed to load: ${error}`),
|
|
124
|
+
...warnings.map(({ path, warning }) => `extension ${path}: ${warning}`),
|
|
125
|
+
];
|
|
126
|
+
for (const line of said) {
|
|
127
|
+
if (reportedExtensionDiagnostics.has(line))
|
|
128
|
+
continue;
|
|
129
|
+
reportedExtensionDiagnostics.add(line);
|
|
130
|
+
log.warn(`[fastagent] ${line}`);
|
|
131
|
+
}
|
|
183
132
|
}
|
|
184
133
|
/**
|
|
185
134
|
* What a command's `ctx` can do to the session when serving. Each turn binds its own session and disposes it, so
|
|
@@ -199,14 +148,28 @@ function servingCommandActions(session) {
|
|
|
199
148
|
reload: unavailable("reload"),
|
|
200
149
|
};
|
|
201
150
|
}
|
|
151
|
+
const nativeFactories = {
|
|
152
|
+
codemode: createCodemodeExtension(),
|
|
153
|
+
"tool-search": createToolSearchExtension(),
|
|
154
|
+
};
|
|
155
|
+
const nativeExtensions = BUILTIN_EXTENSIONS.map((name) => ({
|
|
156
|
+
name,
|
|
157
|
+
factory: nativeFactories[name],
|
|
158
|
+
replaceable: true,
|
|
159
|
+
builtin: true,
|
|
160
|
+
}));
|
|
202
161
|
/** The resource posture a fastagent definition asks pi for — ONE definition of it, for both assemblies. */
|
|
203
162
|
export function definitionResourceLoaderOptions(source) {
|
|
204
163
|
return {
|
|
164
|
+
extensionFactories: [...nativeExtensions, compactAdmission],
|
|
205
165
|
// The machine's extensions are its owner's setup, not this agent's.
|
|
206
166
|
noExtensions: true,
|
|
207
|
-
//
|
|
208
|
-
// is
|
|
209
|
-
|
|
167
|
+
// Explicit paths survive noExtensions, including Pi's built-ins; so they would also survive the machine's own
|
|
168
|
+
// `-builtin:<name>`, which is why only the enabled ones are named. Machine extensions stay out.
|
|
169
|
+
additionalExtensionPaths: [
|
|
170
|
+
...source.machine.builtinExtensions.map((name) => `builtin:${name}`),
|
|
171
|
+
...(source.extensionPaths ?? []),
|
|
172
|
+
],
|
|
210
173
|
// Not pi's: fastagent already loads the SAME files into segment ② (`loadProjectContextFiles` in
|
|
211
174
|
// definition.ts). Leaving both on would put every AGENTS.md in the prompt twice.
|
|
212
175
|
noContextFiles: true,
|
|
@@ -295,48 +258,40 @@ export function startCompaction(session, instructions, onAdmitted) {
|
|
|
295
258
|
}, (error) => error instanceof Error && NOTHING_TO_COMPACT.test(error.message) ? "nothing_to_compact" : { refused: error });
|
|
296
259
|
return { admission: Promise.race([admitted, settledFirst]), done };
|
|
297
260
|
}
|
|
298
|
-
/**
|
|
299
|
-
|
|
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) {
|
|
261
|
+
/** A fresh loader and Pi's native model registration, over a session-local runtime. */
|
|
262
|
+
export async function definitionServices(options) {
|
|
307
263
|
const { cwd, modelRuntime, definition, extensionPaths } = options;
|
|
308
264
|
const machine = await readMachine(cwd);
|
|
309
|
-
const
|
|
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],
|
|
265
|
+
const services = await withModelRegistration(modelRuntime, () => createAgentSessionServices({
|
|
321
266
|
cwd,
|
|
322
|
-
agentDir,
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
267
|
+
agentDir: getAgentDir(),
|
|
268
|
+
modelRuntime,
|
|
269
|
+
settingsManager: machine.settingsManager(),
|
|
270
|
+
resourceLoaderOptions: {
|
|
271
|
+
...definitionResourceLoaderOptions({
|
|
272
|
+
systemPrompt: () => definition.systemPrompt,
|
|
273
|
+
skills: () => definition.skills,
|
|
274
|
+
machine,
|
|
275
|
+
extensionPaths,
|
|
276
|
+
}),
|
|
277
|
+
extensionsOverride: admissionFirst,
|
|
278
|
+
},
|
|
279
|
+
}).then((services) => {
|
|
280
|
+
// AFTER the extensions: one that re-registers `openai` replaced the account-catalog wrapper.
|
|
281
|
+
registerAccountModels(modelRuntime);
|
|
282
|
+
return services;
|
|
283
|
+
}));
|
|
284
|
+
reportExtensionDiagnostics(services);
|
|
328
285
|
return services;
|
|
329
286
|
}
|
|
330
287
|
/**
|
|
331
288
|
* 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
|
|
289
|
+
* a `:N` suffix). Loaded through the same {@link definitionServices} a turn binds, so the menu and the dispatch cannot
|
|
333
290
|
* disagree. Loading runs the extensions' factories; no session opens, so `session_start` does not fire, and the
|
|
334
291
|
* instances are never bound (any action they call throws).
|
|
335
292
|
*/
|
|
336
293
|
export async function servedExtensionCommands(options) {
|
|
337
|
-
|
|
338
|
-
return [];
|
|
339
|
-
const services = await servingServices({ ...options, definition: { skills: [] } });
|
|
294
|
+
const services = await definitionServices({ ...options, definition: { skills: [] } });
|
|
340
295
|
const { extensions, runtime } = services.resourceLoader.getExtensions();
|
|
341
296
|
const runner = new ExtensionRunner(extensions, runtime, options.cwd, SessionManager.inMemory(options.cwd), new ModelRegistry(options.modelRuntime));
|
|
342
297
|
return runner.getRegisteredCommands();
|
|
@@ -355,17 +310,19 @@ export function piAgentSessionFactory(options) {
|
|
|
355
310
|
const extensionPaths = options.extensionPaths ?? [];
|
|
356
311
|
const excludedToolNames = options.excludedToolNames ?? [];
|
|
357
312
|
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
313
|
return async (sessionId, inherit) => {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
314
|
+
// `ctx.ui.theme` reads pi's global theme, which only pi's own entry points initialize. Serving only: chat's theme
|
|
315
|
+
// is InteractiveMode's, and resetting it on every session switch would drop the user's.
|
|
316
|
+
if (!themeReady) {
|
|
317
|
+
initTheme();
|
|
318
|
+
themeReady = true;
|
|
319
|
+
}
|
|
320
|
+
// Publish the record before loading resources: boundary writes must find it while binding is in flight.
|
|
367
321
|
const sessionManager = await sessions.openOrCreate(sessionId, inherit);
|
|
368
|
-
const
|
|
322
|
+
const definition = await options.readDefinition();
|
|
323
|
+
const { modelRuntime } = await options.engine();
|
|
324
|
+
const services = await definitionServices({ cwd, modelRuntime, definition, extensionPaths });
|
|
325
|
+
const model = resolveModel(modelRuntime, options.modelSpec);
|
|
369
326
|
// What the session RUNS on: the boundary plane records model/thinking overrides as entries, and pi does not read
|
|
370
327
|
// them back.
|
|
371
328
|
const settings = resolveSessionSettings(activePath(sessionManager), modelRuntime, {
|
|
@@ -381,9 +338,30 @@ export function piAgentSessionFactory(options) {
|
|
|
381
338
|
cwd,
|
|
382
339
|
excludedToolNames,
|
|
383
340
|
sessionId,
|
|
384
|
-
recordActivations: true,
|
|
385
341
|
});
|
|
386
|
-
|
|
342
|
+
// Pi's headless UI drops notifications. Keep dialogs cancelled and hasUI false, but surface diagnostics.
|
|
343
|
+
const runner = session.extensionRunner;
|
|
344
|
+
if (runner) {
|
|
345
|
+
const createContext = runner.createContext.bind(runner);
|
|
346
|
+
runner.createContext = () => {
|
|
347
|
+
const context = createContext();
|
|
348
|
+
// Pi clones own descriptors for before_agent_start; inherited fields would disappear there.
|
|
349
|
+
return Object.defineProperty(context, "ui", {
|
|
350
|
+
value: {
|
|
351
|
+
...context.ui,
|
|
352
|
+
notify: (message, type) => {
|
|
353
|
+
const key = `${type ?? "info"}\u0000${message}`;
|
|
354
|
+
const repeat = reportedNotifications.has(key);
|
|
355
|
+
if (reportedNotifications.size >= MAX_REPORTED_NOTIFICATIONS)
|
|
356
|
+
reportedNotifications.clear();
|
|
357
|
+
reportedNotifications.add(key);
|
|
358
|
+
const emit = repeat ? log.debug : type === "info" ? log.info : log.warn;
|
|
359
|
+
emit(`[fastagent] session ${sessionId}: ${message}`);
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
}
|
|
387
365
|
// `onError` is THE ONLY LISTENER for a fault pi reports nowhere else: `/skill:<name>` is expanded by reading
|
|
388
366
|
// `filePath` at prompt time, and when that read fails pi raises `skill_expansion` on the extension error channel
|
|
389
367
|
// and sends the line to the model unexpanded. The list can outlive the file: it is refreshed per invoke, so a
|
package/dist/engines/pi/auth.js
CHANGED
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
* `Models` collection (models.ts), and which of those files an agent reads. The write path refuses to overwrite a
|
|
4
4
|
* corrupt file, so a torn read never clobbers the other providers' credentials.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync,
|
|
7
|
-
import {
|
|
8
|
-
import { globalHome, SECRETS_DIRNAME,
|
|
9
|
-
import { writeFileAtomic } from "../../atomic-write.js";
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { globalHome, SECRETS_DIRNAME, resolveOverridePath, resolveSecretsDir } from "../../paths.js";
|
|
10
9
|
import { log } from "../../log.js";
|
|
11
|
-
import
|
|
10
|
+
import { withLockedFile } from "./locked-file.js";
|
|
12
11
|
/**
|
|
13
12
|
* The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`), under the user-global machinery home
|
|
14
13
|
* `~/.fastagent/`.
|
|
@@ -25,93 +24,6 @@ function pick(creds, providerId) {
|
|
|
25
24
|
const cred = creds[providerId];
|
|
26
25
|
return cred && (cred.type === "oauth" || cred.type === "api_key") ? cred : undefined;
|
|
27
26
|
}
|
|
28
|
-
const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: SECRET_FILE_MODE };
|
|
29
|
-
/**
|
|
30
|
-
* The file a write must land IN, which is not always the path it is addressed BY. pi writes the auth file in place,
|
|
31
|
-
* so it follows a symlink to the target; an atomic rename would replace the link with a regular file instead, and
|
|
32
|
-
* from then on the two tools would be editing different files — the same split the shared lock exists to prevent,
|
|
33
|
-
* arriving one write later. A dangling link (a dotfile manager that has not checked the target out yet) still names
|
|
34
|
-
* where the credentials belong, so it is followed too rather than overwritten.
|
|
35
|
-
*/
|
|
36
|
-
function writeTarget(authPath) {
|
|
37
|
-
if (existsSync(authPath))
|
|
38
|
-
return realpathSync(authPath);
|
|
39
|
-
const link = lstatSync(authPath, { throwIfNoEntry: false });
|
|
40
|
-
// ponytail: one hop. A chain of dangling links would have its second link replaced; resolve iteratively if that
|
|
41
|
-
// ever shows up in a real layout.
|
|
42
|
-
return link?.isSymbolicLink() ? resolve(dirname(authPath), readlinkSync(authPath)) : authPath;
|
|
43
|
-
}
|
|
44
|
-
/** Serialized cross-process read-modify-write of the credentials file. */
|
|
45
|
-
async function withLockedAuthFile(authPath, fn) {
|
|
46
|
-
mkdirSync(dirname(authPath), { recursive: true });
|
|
47
|
-
if (!existsSync(authPath)) {
|
|
48
|
-
try {
|
|
49
|
-
// `wx` makes this an exclusive CREATE, which is the one case `writeFileSync` honours `mode` in — so no chmod
|
|
50
|
-
// after it (unlike writeFileAtomic, whose temp may already exist from a crashed writer).
|
|
51
|
-
writeFileSync(authPath, "{}", { ...AUTH_FILE_WRITE_OPTIONS, flag: "wx" });
|
|
52
|
-
}
|
|
53
|
-
catch (error) {
|
|
54
|
-
// EEXIST: the path IS taken, by something `existsSync` does not see through — either another process created
|
|
55
|
-
// the file just now (its credentials must not be clobbered) or the path is a dangling symlink. Both are left
|
|
56
|
-
// alone: the write below resolves where they belong.
|
|
57
|
-
if (error.code !== "EEXIST")
|
|
58
|
-
throw error;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
let compromised;
|
|
62
|
-
const throwIfCompromised = () => {
|
|
63
|
-
if (compromised)
|
|
64
|
-
throw compromised;
|
|
65
|
-
};
|
|
66
|
-
const release = await lockfile.lock(authPath, {
|
|
67
|
-
// MUST match pi-coding-agent's `core/auth-storage.js`, which locks with `realpath: false`. When the auth file
|
|
68
|
-
// itself is a symlink (dotfile managers do this), the two settings name DIFFERENT lock files and both locks can
|
|
69
|
-
// be held at once — so `pi` and `fastagent` would refresh the same provider concurrently, and a rotated refresh
|
|
70
|
-
// token logs one of them out. Resolving the link would be the stronger rule, but only if BOTH sides did it, and
|
|
71
|
-
// we do not own the other side.
|
|
72
|
-
realpath: false,
|
|
73
|
-
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
|
|
74
|
-
stale: 30_000,
|
|
75
|
-
onCompromised: (error) => {
|
|
76
|
-
compromised = error;
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
let result;
|
|
80
|
-
try {
|
|
81
|
-
throwIfCompromised();
|
|
82
|
-
const current = existsSync(authPath) ? readFileSync(authPath, "utf8") : undefined;
|
|
83
|
-
const out = await fn(current);
|
|
84
|
-
throwIfCompromised();
|
|
85
|
-
// Rename, not an in-place rewrite: it is what lets `read` stay unlocked, and it is the only spelling that applies
|
|
86
|
-
// the mode before the content is reachable.
|
|
87
|
-
if (out.next !== undefined) {
|
|
88
|
-
writeFileAtomic(writeTarget(authPath), out.next, SECRET_FILE_MODE);
|
|
89
|
-
}
|
|
90
|
-
throwIfCompromised();
|
|
91
|
-
result = out.result;
|
|
92
|
-
}
|
|
93
|
-
catch (error) {
|
|
94
|
-
// The primary failure stays the signal; unlock noise must not mask it.
|
|
95
|
-
try {
|
|
96
|
-
await release();
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
|
|
100
|
-
}
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
// Success path: a failed release is a real cleanup failure (the leftover auth.json.lock stalls the next writer for
|
|
104
|
-
// the staleness window with zero diagnostics), so it surfaces instead of resolving a silently degraded operation.
|
|
105
|
-
try {
|
|
106
|
-
await release();
|
|
107
|
-
}
|
|
108
|
-
catch (releaseError) {
|
|
109
|
-
if (compromised === undefined)
|
|
110
|
-
throw releaseError;
|
|
111
|
-
}
|
|
112
|
-
throwIfCompromised();
|
|
113
|
-
return result;
|
|
114
|
-
}
|
|
115
27
|
/** Decode the credentials JSON, shared by the read and write paths. */
|
|
116
28
|
function decodeCreds(raw) {
|
|
117
29
|
let parsed;
|
|
@@ -223,7 +135,7 @@ export function fastagentCredentialStore(path, options = {}) {
|
|
|
223
135
|
},
|
|
224
136
|
async modify(providerId, fn) {
|
|
225
137
|
const path = await owner(providerId);
|
|
226
|
-
return
|
|
138
|
+
return withLockedFile(path, async (current) => {
|
|
227
139
|
const creds = parseForWrite(current, path); // corrupt → throw → no clobber
|
|
228
140
|
const next = await fn(pick(creds, providerId));
|
|
229
141
|
if (next === undefined)
|
|
@@ -238,7 +150,7 @@ export function fastagentCredentialStore(path, options = {}) {
|
|
|
238
150
|
// stored this provider.
|
|
239
151
|
if (!existsSync(path))
|
|
240
152
|
return;
|
|
241
|
-
await
|
|
153
|
+
await withLockedFile(path, async (current) => {
|
|
242
154
|
const creds = parseForWrite(current, path);
|
|
243
155
|
if (!(providerId in creds))
|
|
244
156
|
return { result: undefined }; // absent: no write
|
|
@@ -19,6 +19,13 @@ import { type Lease, type SessionObserver } from "./turn-kit.ts";
|
|
|
19
19
|
*/
|
|
20
20
|
export declare const CODING_TOOL_NAMES: readonly ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
21
21
|
export declare function piAllCodingTools(cwd: string): MountedTool[];
|
|
22
|
+
/** How the model reaches a mounted tool it is not given up front. */
|
|
23
|
+
export type ToolReach = "tool_search" | "codemode" | "hidden" | "inactive" | "unreachable";
|
|
24
|
+
/** A mounted authored tool outside the default-active surface, and its way in. */
|
|
25
|
+
export interface IndirectTool {
|
|
26
|
+
name: string;
|
|
27
|
+
reach: ToolReach;
|
|
28
|
+
}
|
|
22
29
|
/**
|
|
23
30
|
* The full directory-agent tool set: all pi coding tools + `config.tools` + discovered `tools/` (deduped, existing
|
|
24
31
|
* win), plus the authored names and collisions to report.
|
|
@@ -26,11 +33,8 @@ export declare function piAllCodingTools(cwd: string): MountedTool[];
|
|
|
26
33
|
export declare function resolveAgentTools(config: FastagentConfig, agentDir: string, cwd: string): Promise<{
|
|
27
34
|
tools: MountedTool[];
|
|
28
35
|
toolNames: string[];
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
* `search_tools` loader.
|
|
32
|
-
*/
|
|
33
|
-
deferredToolNames: string[];
|
|
36
|
+
/** Mounted authored tools the model is not given up front, with how each is reached. */
|
|
37
|
+
indirectTools: IndirectTool[];
|
|
34
38
|
toolCollisions: ToolCollision[];
|
|
35
39
|
toolFailures: ModuleLoadFailure[];
|
|
36
40
|
/** Env vars the MOUNTED tools declared they need, BY TOOL NAME — a discovered tool shadowed by a
|
|
@@ -45,6 +49,8 @@ export declare function resolveAgentTools(config: FastagentConfig, agentDir: str
|
|
|
45
49
|
export declare function piBasePrompt(options?: {
|
|
46
50
|
tools?: MountedTool[];
|
|
47
51
|
persona?: string;
|
|
52
|
+
/** The {@link BUILTIN_EXTENSIONS} this machine leaves enabled (`Machine.builtinExtensions`); all by default. */
|
|
53
|
+
builtinExtensions?: readonly string[];
|
|
48
54
|
}): string;
|
|
49
55
|
export interface AssembleSystemPromptOptions {
|
|
50
56
|
/**
|
|
@@ -66,11 +72,10 @@ export declare function assembleSystemPrompt(options: AssembleSystemPromptOption
|
|
|
66
72
|
export interface PiAssembly {
|
|
67
73
|
lease: Lease;
|
|
68
74
|
sessionFactory: PiAgentSessionFactory;
|
|
69
|
-
/**
|
|
70
|
-
* The registry alone, with injected providers registered. `chat` resolves its model against it only after its
|
|
71
|
-
* extensions have loaded, because there an extension may register the provider (serving refuses that).
|
|
72
|
-
*/
|
|
75
|
+
/** Extension-aware catalog for startup reporting and control-plane validation; never bound to a session. */
|
|
73
76
|
modelRuntime: () => Promise<ModelRuntime>;
|
|
77
|
+
/** A fresh runtime for every session, before loading its extensions. */
|
|
78
|
+
createModelRuntime: () => Promise<ModelRuntime>;
|
|
74
79
|
/** The registry and configured model, resolved on first use (a credential read is async). */
|
|
75
80
|
engine: () => Promise<{
|
|
76
81
|
modelRuntime: ModelRuntime;
|
|
@@ -80,6 +85,8 @@ export interface PiAssembly {
|
|
|
80
85
|
thinkingLevel: ThinkingLevel;
|
|
81
86
|
/** The tools every session mounts. */
|
|
82
87
|
tools: MountedTool[];
|
|
88
|
+
/** Pi built-ins the mounted tools leave out, denied in every session so discovery cannot bring them back. */
|
|
89
|
+
excludedToolNames: readonly string[];
|
|
83
90
|
/** The prompt and skills a session runs on, read when a session is bound. */
|
|
84
91
|
readDefinition: PiAgentSessionFactoryOptions["readDefinition"];
|
|
85
92
|
/** The extension entry points every session loads, discovered once with the assembly. */
|