@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.
- package/dist/agent.d.ts +10 -0
- package/dist/channels/agentcore-protocol.d.ts +2 -1
- package/dist/channels/agentcore-service.js +10 -18
- package/dist/channels/agentcore.js +6 -3
- package/dist/channels/control.js +23 -0
- package/dist/channels/kit/preview-kit.d.ts +6 -1
- package/dist/channels/kit/preview-kit.js +21 -4
- package/dist/channels/slack/config-api.d.ts +2 -2
- package/dist/channels/slack/config-api.js +2 -2
- package/dist/channels/slack/onboard.js +2 -2
- package/dist/channels/slack/preview.js +2 -1
- package/dist/channels/slack/register-webhook.js +2 -2
- package/dist/channels/slack/slack-api.js +4 -4
- package/dist/cli/add-feishu.js +4 -2
- package/dist/cli/auth-view.d.ts +15 -6
- package/dist/cli/auth-view.js +24 -8
- package/dist/cli/box-login.d.ts +33 -0
- package/dist/cli/box-login.js +162 -0
- package/dist/cli/commands/deploy/agentcore.js +48 -11
- package/dist/cli/commands/deploy/docker.js +24 -13
- package/dist/cli/commands/deploy/fly.js +30 -7
- package/dist/cli/commands/deploy/railway.js +21 -7
- package/dist/cli/commands/deploy/shared.d.ts +18 -17
- package/dist/cli/commands/deploy/shared.js +20 -21
- package/dist/cli/commands/destroy.js +2 -2
- package/dist/cli/commands/info.js +15 -12
- package/dist/cli/commands/invoke.js +2 -2
- package/dist/cli/commands/login.d.ts +7 -1
- package/dist/cli/commands/login.js +123 -10
- package/dist/cli/commands/logs.js +2 -2
- package/dist/cli/commands/models.d.ts +4 -1
- package/dist/cli/commands/models.js +37 -4
- package/dist/cli/commands/routine-run.js +2 -2
- package/dist/cli/commands/start.d.ts +0 -5
- package/dist/cli/commands/start.js +3 -13
- package/dist/cli/invoke-stream.js +14 -0
- package/dist/cli/kernel.d.ts +2 -0
- package/dist/cli/kernel.js +1 -1
- package/dist/cli/login-relay.d.ts +38 -0
- package/dist/cli/login-relay.js +121 -0
- package/dist/cli/program.js +46 -8
- package/dist/cli/shared.d.ts +6 -5
- package/dist/cli/shared.js +29 -36
- package/dist/deploy/agentcore/plan.d.ts +10 -11
- package/dist/deploy/agentcore/plan.js +8 -14
- package/dist/deploy/agentcore/run.d.ts +8 -1
- package/dist/deploy/agentcore/run.js +20 -14
- package/dist/deploy/agentcore/shell.d.ts +25 -0
- package/dist/deploy/agentcore/shell.js +202 -0
- package/dist/deploy/box-shell.d.ts +43 -0
- package/dist/deploy/box-shell.js +28 -0
- package/dist/deploy/build-context.js +16 -7
- package/dist/deploy/channel-ingress.d.ts +20 -1
- package/dist/deploy/channel-ingress.js +34 -7
- package/dist/deploy/container.d.ts +14 -0
- package/dist/deploy/container.js +45 -1
- package/dist/deploy/docker/plan.d.ts +7 -3
- package/dist/deploy/docker/plan.js +9 -14
- package/dist/deploy/docker/run.d.ts +14 -7
- package/dist/deploy/docker/run.js +58 -35
- package/dist/deploy/fly/plan.d.ts +6 -3
- package/dist/deploy/fly/plan.js +6 -8
- package/dist/deploy/fly/run.d.ts +8 -2
- package/dist/deploy/fly/run.js +15 -25
- package/dist/deploy/preflight.d.ts +9 -7
- package/dist/deploy/preflight.js +92 -20
- package/dist/deploy/railway/plan.d.ts +6 -3
- package/dist/deploy/railway/plan.js +7 -9
- package/dist/deploy/railway/run.d.ts +10 -6
- package/dist/deploy/railway/run.js +29 -13
- package/dist/deploy/runner.d.ts +16 -0
- package/dist/deploy/runner.js +31 -0
- package/dist/deploy/secrets.d.ts +4 -13
- package/dist/deploy/secrets.js +8 -27
- package/dist/dev-supervisor.js +7 -4
- package/dist/engines/pi/agent-models.d.ts +83 -0
- package/dist/engines/pi/agent-models.js +120 -0
- package/dist/engines/pi/agent-session-factory.d.ts +13 -13
- package/dist/engines/pi/agent-session-factory.js +132 -159
- package/dist/engines/pi/auth.d.ts +56 -3
- package/dist/engines/pi/auth.js +68 -109
- package/dist/engines/pi/config.d.ts +0 -25
- package/dist/engines/pi/config.js +3 -37
- package/dist/engines/pi/create.d.ts +34 -21
- package/dist/engines/pi/create.js +90 -57
- package/dist/engines/pi/entry-images.d.ts +14 -0
- package/dist/engines/pi/entry-images.js +23 -0
- package/dist/engines/pi/invoke-session.js +88 -20
- 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 +27 -14
- package/dist/engines/pi/login.js +41 -18
- package/dist/engines/pi/machine.d.ts +23 -0
- package/dist/engines/pi/machine.js +24 -2
- package/dist/engines/pi/models.d.ts +120 -36
- package/dist/engines/pi/models.js +316 -66
- package/dist/engines/pi/open.d.ts +65 -20
- package/dist/engines/pi/open.js +88 -34
- package/dist/engines/pi/report.d.ts +3 -0
- package/dist/engines/pi/report.js +11 -0
- package/dist/engines/pi/service.d.ts +2 -2
- package/dist/engines/pi/service.js +1 -0
- package/dist/engines/pi/session-builder.d.ts +3 -2
- package/dist/engines/pi/session-builder.js +30 -76
- package/dist/engines/pi/session-control.d.ts +4 -4
- package/dist/engines/pi/session-control.js +86 -43
- 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 +115 -32
- package/dist/open-url.d.ts +15 -1
- package/dist/open-url.js +35 -3
- package/dist/paths.d.ts +4 -2
- package/dist/paths.js +6 -2
- package/dist/pi.d.ts +5 -3
- package/dist/pi.js +3 -2
- package/dist/service.d.ts +5 -8
- package/dist/session-remote.js +21 -0
- package/dist/session.d.ts +88 -15
- package/dist/session.js +5 -1
- package/package.json +6 -5
- package/dist/engines/pi/search-tools.d.ts +0 -4
- package/dist/engines/pi/search-tools.js +0 -109
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
import { toUSVString } from "node:util";
|
|
6
6
|
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
7
7
|
import { createCodingTools, createPowerShellTool, createReadOnlyTools, } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
8
|
+
import { resolveModel } from "./config.js";
|
|
9
9
|
import { isAgentcoreRuntime, isDeployedWorkspace } from "../../paths.js";
|
|
10
|
-
import { loadAgentDefinition
|
|
10
|
+
import { loadAgentDefinition } from "./definition.js";
|
|
11
11
|
import { reportFindingsIfChanged } from "./report.js";
|
|
12
|
-
import {
|
|
12
|
+
import { log } from "../../log.js";
|
|
13
|
+
import { BUILTIN_EXTENSIONS, DISCOVERY, readMachine } from "./machine.js";
|
|
14
|
+
import { loadTools, mergeDiscoveredTools } from "./tool.js";
|
|
13
15
|
import { readSecretDeclaration } from "../../declared-secrets.js";
|
|
14
|
-
import { withSearchTool } from "./search-tools.js";
|
|
15
16
|
import { createPiAgentFromSession } from "./invoke-session.js";
|
|
16
17
|
import { piAgentSessionFactory } from "./agent-session-factory.js";
|
|
17
|
-
import { DEFAULT_THINKING_LEVEL
|
|
18
|
+
import { DEFAULT_THINKING_LEVEL } from "./models.js";
|
|
19
|
+
import { agentModels } from "./agent-models.js";
|
|
18
20
|
import { piInMemorySessionRecordStore } from "./session-store.js";
|
|
19
21
|
import { inProcessLease } from "./turn-kit.js";
|
|
20
22
|
// ── §1 tools ─────────────────────────────────────────────────────────────────
|
|
@@ -56,6 +58,32 @@ function omittedBuiltinNames(mounted, cwd) {
|
|
|
56
58
|
const mountedNames = new Set(mounted.map((tool) => tool.name));
|
|
57
59
|
return [...new Set(piRegisteredToolNames(cwd))].filter((name) => !mountedNames.has(name));
|
|
58
60
|
}
|
|
61
|
+
function isDefaultActiveTool(tool) {
|
|
62
|
+
return ((!tool.exposure || tool.exposure === "direct" || tool.exposure === "model-only") && tool.defaultActive !== false);
|
|
63
|
+
}
|
|
64
|
+
/** The built-in extension a `codemode`/`deferred` tool is reached through ({@link DISCOVERY}). */
|
|
65
|
+
function wayIn(tool) {
|
|
66
|
+
return tool.exposure === "codemode" || tool.exposure === "deferred" ? DISCOVERY[tool.exposure].extension : undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Whether this machine leaves the tool's way in enabled (a tool that needs none always has one). */
|
|
69
|
+
function hasWayIn(tool, builtinExtensions) {
|
|
70
|
+
const extension = wayIn(tool);
|
|
71
|
+
return extension === undefined || builtinExtensions.includes(extension);
|
|
72
|
+
}
|
|
73
|
+
function indirectReach(tool, builtinExtensions) {
|
|
74
|
+
if (isDefaultActiveTool(tool))
|
|
75
|
+
return undefined;
|
|
76
|
+
if (!hasWayIn(tool, builtinExtensions))
|
|
77
|
+
return "unreachable";
|
|
78
|
+
if (tool.exposure === "deferred")
|
|
79
|
+
return "tool_search";
|
|
80
|
+
if (tool.exposure === "codemode")
|
|
81
|
+
return "codemode";
|
|
82
|
+
if (tool.exposure === "hidden")
|
|
83
|
+
return "hidden";
|
|
84
|
+
// `defaultActive: false`: only an authored loader (`ToolContext.tools.activate`) brings it in.
|
|
85
|
+
return "inactive";
|
|
86
|
+
}
|
|
59
87
|
/**
|
|
60
88
|
* The full directory-agent tool set: all pi coding tools + `config.tools` + discovered `tools/` (deduped, existing
|
|
61
89
|
* win), plus the authored names and collisions to report.
|
|
@@ -81,24 +109,22 @@ export async function resolveAgentTools(config, agentDir, cwd) {
|
|
|
81
109
|
mountedConfigTools.push(tool);
|
|
82
110
|
}
|
|
83
111
|
const merged = mergeDiscoveredTools(configured, discovered.tools);
|
|
84
|
-
|
|
85
|
-
const tools = withSearchTool(merged.tools);
|
|
86
|
-
// Builtin = a search_tools that was ABSENT before withSearchTool (a reference compare would misfire on the
|
|
87
|
-
// deferred-authored-loader case, where withSearchTool returns a new array without adding one).
|
|
88
|
-
const builtinLoaderMounted = !merged.tools.some((t) => t.name === "search_tools") && tools.some((t) => t.name === "search_tools");
|
|
112
|
+
const tools = merged.tools;
|
|
89
113
|
const toolCollisions = [...discovered.collisions, ...configuredCollisions, ...merged.collisions];
|
|
90
114
|
// `toolNames` is the AUTHOR's active-by-default surface (config.tools + tools/).
|
|
91
115
|
// The discovered tools that were DROPPED (a coding tool or config.tools already owns the name):
|
|
92
116
|
// asking the mounted set instead would read the winner's name as proof the loser is mounted.
|
|
93
117
|
const shadowed = new Set(merged.collisions.map((c) => c.name));
|
|
118
|
+
const { builtinExtensions } = await readMachine(cwd);
|
|
94
119
|
const defaultNames = new Set(CODING_TOOL_NAMES);
|
|
95
|
-
const toolNames = tools
|
|
96
|
-
.filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
|
|
97
|
-
.map((t) => t.name);
|
|
120
|
+
const toolNames = tools.filter((t) => !defaultNames.has(t.name) && isDefaultActiveTool(t)).map((t) => t.name);
|
|
98
121
|
return {
|
|
99
122
|
tools,
|
|
100
123
|
toolNames,
|
|
101
|
-
|
|
124
|
+
indirectTools: tools.flatMap((t) => {
|
|
125
|
+
const reach = defaultNames.has(t.name) ? undefined : indirectReach(t, builtinExtensions);
|
|
126
|
+
return reach ? [{ name: t.name, reach }] : [];
|
|
127
|
+
}),
|
|
102
128
|
toolCollisions,
|
|
103
129
|
toolFailures: discovered.failures,
|
|
104
130
|
// Mounted only, and config.tools are FastagentTools too, so a programmatic tool declares the
|
|
@@ -123,10 +149,14 @@ export async function resolveAgentTools(config, agentDir, cwd) {
|
|
|
123
149
|
/** The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two deviations. */
|
|
124
150
|
export function piBasePrompt(options = {}) {
|
|
125
151
|
const mounted = options.tools ?? [];
|
|
152
|
+
const builtinExtensions = options.builtinExtensions ?? BUILTIN_EXTENSIONS;
|
|
126
153
|
// Deferred tools stay OUT of the list: their schemas are not in the request until activated, so naming them here
|
|
127
154
|
// would invite calls to tools that don't exist yet.
|
|
128
|
-
const tools = mounted.filter(
|
|
129
|
-
|
|
155
|
+
const tools = mounted.filter(isDefaultActiveTool);
|
|
156
|
+
// Only `deferred` tools are tool_search's to load (the session activates it for them); `codemode` ones are listed
|
|
157
|
+
// in codemode's own description, and an inactive direct tool is reached only through an authored activation.
|
|
158
|
+
// A disabled tool-search leaves them no way in, and naming tool_search would invite calls to a tool that is not there.
|
|
159
|
+
const deferredCount = mounted.filter((tool) => tool.exposure === "deferred" && hasWayIn(tool, builtinExtensions)).length;
|
|
130
160
|
const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
|
|
131
161
|
// Segment ① identity: an authored persona (persona.md) replaces the default engine identity line (core.md §2),
|
|
132
162
|
// keeping the tools list + guidelines below.
|
|
@@ -137,7 +167,7 @@ export function piBasePrompt(options = {}) {
|
|
|
137
167
|
? "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files."
|
|
138
168
|
: "You are an AI assistant operating inside pi, an agent harness. Help users using only the tools and context available to you.");
|
|
139
169
|
const deferredNote = deferredCount > 0
|
|
140
|
-
? `\n\n${deferredCount} additional tool(s) are registered but
|
|
170
|
+
? `\n\n${deferredCount} additional tool(s) are registered but not loaded — use tool_search to find and load them before concluding a capability is missing.`
|
|
141
171
|
: "";
|
|
142
172
|
// What takes effect when, and the agent's own path to a new capability, are the same on every host; only how long
|
|
143
173
|
// the storage lives differs, below. The skill it writes lives in the definition directory, which the next
|
|
@@ -187,38 +217,40 @@ function assemblePi(opts) {
|
|
|
187
217
|
// — boundary mutations must contend on it.
|
|
188
218
|
const lease = opts.lease ?? inProcessLease();
|
|
189
219
|
const sessions = opts.sessions ?? piInMemorySessionRecordStore({ cwd });
|
|
190
|
-
|
|
220
|
+
const createModelRuntime = opts.models;
|
|
221
|
+
let registry;
|
|
222
|
+
const modelRuntime = () => {
|
|
223
|
+
registry ??= opts.catalog();
|
|
224
|
+
return registry;
|
|
225
|
+
};
|
|
191
226
|
let engine;
|
|
192
227
|
const resolveEngine = () => {
|
|
193
|
-
engine ??= (
|
|
194
|
-
// The caller's registry when there is one — the directory rung builds it from the agent's own models.json, so a
|
|
195
|
-
// custom endpoint declared there is the one a turn resolves against.
|
|
196
|
-
const modelRuntime = opts.models ?? (await createPiModelRuntime({ auth: opts.auth }));
|
|
197
|
-
// ModelRuntime registers providers by config record, so an injected Provider INSTANCE (a gateway, a self-hosted
|
|
198
|
-
// endpoint, a test fake) goes in through its native seam.
|
|
199
|
-
for (const provider of opts.providers ?? [])
|
|
200
|
-
modelRuntime.registerNativeProvider(provider);
|
|
201
|
-
return { modelRuntime, model: resolveModel(modelRuntime, opts.model) };
|
|
202
|
-
})();
|
|
228
|
+
engine ??= modelRuntime().then((runtime) => ({ modelRuntime: runtime, model: resolveModel(runtime, opts.model) }));
|
|
203
229
|
return engine;
|
|
204
230
|
};
|
|
231
|
+
// Deny omitted coding names so discovery cannot reintroduce tools a lower-level caller excluded.
|
|
232
|
+
const excludedToolNames = omittedBuiltinNames(opts.tools ?? [], cwd);
|
|
205
233
|
const sessionFactory = piAgentSessionFactory({
|
|
206
234
|
sessions,
|
|
207
|
-
engine:
|
|
235
|
+
engine: async () => ({ modelRuntime: await createModelRuntime() }),
|
|
236
|
+
modelSpec: opts.model,
|
|
208
237
|
thinkingLevel: opts.thinkingLevel,
|
|
209
238
|
tools: opts.tools,
|
|
210
239
|
readDefinition: opts.readDefinition,
|
|
211
240
|
cwd,
|
|
212
241
|
...(opts.extensionPaths ? { extensionPaths: opts.extensionPaths } : {}),
|
|
213
|
-
|
|
214
|
-
// omitted coding name so a loader cannot reactivate one later.
|
|
215
|
-
excludedToolNames: omittedBuiltinNames(opts.tools ?? [], cwd),
|
|
242
|
+
excludedToolNames,
|
|
216
243
|
});
|
|
217
244
|
return {
|
|
218
245
|
lease,
|
|
219
246
|
sessionFactory,
|
|
247
|
+
modelRuntime,
|
|
248
|
+
createModelRuntime,
|
|
220
249
|
engine: resolveEngine,
|
|
221
250
|
thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
|
|
251
|
+
tools: opts.tools ?? [],
|
|
252
|
+
excludedToolNames,
|
|
253
|
+
readDefinition: opts.readDefinition,
|
|
222
254
|
extensionPaths: opts.extensionPaths ?? [],
|
|
223
255
|
};
|
|
224
256
|
}
|
|
@@ -229,35 +261,28 @@ export function agentOf(assembly, observer) {
|
|
|
229
261
|
/** L1: assemble from typed parts. */
|
|
230
262
|
export function createPiAgent(options) {
|
|
231
263
|
const { instructions, skills = [] } = options;
|
|
264
|
+
const models = agentModels(undefined, options, { providers: options.providers });
|
|
232
265
|
return agentOf(assemblePi({
|
|
233
266
|
model: options.model,
|
|
234
267
|
thinkingLevel: options.thinkingLevel,
|
|
235
|
-
|
|
236
|
-
|
|
268
|
+
models: models.createRuntime,
|
|
269
|
+
catalog: models.runtime,
|
|
237
270
|
readDefinition: () => ({
|
|
238
271
|
systemPrompt: typeof instructions === "function" ? instructions() : instructions,
|
|
239
272
|
skills,
|
|
240
273
|
}),
|
|
241
|
-
|
|
242
|
-
tools: options.tools ? withSearchTool(options.tools) : options.tools,
|
|
274
|
+
tools: options.tools,
|
|
243
275
|
sessions: options.sessions,
|
|
244
276
|
env: options.env,
|
|
245
277
|
lease: options.lease,
|
|
246
278
|
}), options.observer);
|
|
247
279
|
}
|
|
248
280
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* The directory rung assembles from it and `availableModelsFromDir` lists from it, so a layer added here reaches both.
|
|
281
|
+
* L2, as the value: load the directory (base + AGENTS.md + skills + env) and assemble. `models` is the directory's
|
|
282
|
+
* model environment when the caller already built it (the opener, whose report must describe what runs); otherwise it
|
|
283
|
+
* is built here from the credential options.
|
|
253
284
|
*/
|
|
254
|
-
export function
|
|
255
|
-
return createPiModelRuntime({ agentDir: dir, ...options });
|
|
256
|
-
}
|
|
257
|
-
/** L2, as the value: load the directory (base + AGENTS.md + skills + env) and assemble. */
|
|
258
|
-
export async function assemblePiFromDefinition(dir,
|
|
259
|
-
/** `auth` is the opener's already-resolved layers, which take the place of `authPath`. */
|
|
260
|
-
options) {
|
|
285
|
+
export async function assemblePiFromDefinition(dir, options) {
|
|
261
286
|
// `dir` = the agent-definition dir (persona.md/skills/); `cwd` (default = dir) is the run root where tools operate
|
|
262
287
|
// and whose ancestors are walked for ② context.
|
|
263
288
|
const cwd = options.cwd ?? dir;
|
|
@@ -265,20 +290,27 @@ options) {
|
|
|
265
290
|
// Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to report
|
|
266
291
|
// (skills/diagnostics/collisions).
|
|
267
292
|
const definition = await loadAgentDefinition(dir, { cwd, env });
|
|
268
|
-
|
|
269
|
-
// own search_tools wins).
|
|
270
|
-
const tools = withSearchTool(options.tools ?? piAllCodingTools(cwd));
|
|
293
|
+
const tools = options.tools ?? piAllCodingTools(cwd);
|
|
271
294
|
// Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by the resolved
|
|
272
295
|
// dir).
|
|
273
296
|
reportFindingsIfChanged(definition.dir, definition);
|
|
274
|
-
const
|
|
297
|
+
const { providers } = options;
|
|
298
|
+
const models = options.models ?? agentModels(dir, options, { cwd, env, ...(providers ? { providers } : {}) });
|
|
299
|
+
// Built at boot, so a malformed models.json fails the assembly rather than its first turn. The directory's own
|
|
300
|
+
// models.json is what a turn resolves against, layered over the machine's (models.ts).
|
|
301
|
+
await models.runtime();
|
|
302
|
+
const { builtinExtensions } = await readMachine(cwd);
|
|
303
|
+
const unreachable = tools.filter((tool) => !hasWayIn(tool, builtinExtensions));
|
|
304
|
+
// Said once per assembly: the model cannot call these at all, while the tools themselves loaded fine.
|
|
305
|
+
for (const tool of unreachable) {
|
|
306
|
+
log.warn(`[fastagent] tool "${tool.name}" (exposure: ${tool.exposure}) cannot be reached: pi's settings disable ` +
|
|
307
|
+
`builtin:${wayIn(tool)}, its only way in`);
|
|
308
|
+
}
|
|
275
309
|
const assembly = assemblePi({
|
|
276
310
|
model: options.model,
|
|
277
311
|
thinkingLevel: options.thinkingLevel,
|
|
278
|
-
models:
|
|
279
|
-
|
|
280
|
-
...(options.providers ? { providers: options.providers } : {}),
|
|
281
|
-
}),
|
|
312
|
+
models: models.createRuntime,
|
|
313
|
+
catalog: models.runtime,
|
|
282
314
|
// The directory is the agent, LIVE: re-read the definition on every invoke, so AGENTS.md/skills edits (the
|
|
283
315
|
// author's, or the agent's own self-modification) take effect on the next turn with no process restart — restarts
|
|
284
316
|
// are reserved for code (tools/channels/config, module cache).
|
|
@@ -289,7 +321,7 @@ options) {
|
|
|
289
321
|
systemPrompt: assembleSystemPrompt({
|
|
290
322
|
// Segment ①: an authored persona (persona.md, def.persona) overrides the engine identity, re-read per turn
|
|
291
323
|
// like AGENTS.md so edits go live.
|
|
292
|
-
base: options.base ?? piBasePrompt({ tools, persona: def.persona }),
|
|
324
|
+
base: options.base ?? piBasePrompt({ tools, persona: def.persona, builtinExtensions }),
|
|
293
325
|
// ② project context: AGENTS.md files (agentDir + cwd-ancestor walk) via loadProjectContextFiles.
|
|
294
326
|
contextFiles: def.contextFiles,
|
|
295
327
|
}),
|
|
@@ -298,7 +330,8 @@ options) {
|
|
|
298
330
|
},
|
|
299
331
|
tools,
|
|
300
332
|
sessions: options.sessions,
|
|
301
|
-
|
|
333
|
+
// The catalog's own discovery: the models it registered and the extensions sessions load are one list.
|
|
334
|
+
extensionPaths: [...(await models.extensionPaths())],
|
|
302
335
|
cwd,
|
|
303
336
|
env,
|
|
304
337
|
lease: options.lease,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ImageRef } from "../../agent.ts";
|
|
3
|
+
import type { EntryImage } from "../../session.ts";
|
|
4
|
+
type Message = {
|
|
5
|
+
role: string;
|
|
6
|
+
content?: unknown;
|
|
7
|
+
};
|
|
8
|
+
/** `data.images` of the entry `entryId` holding `message`, absent when it has none. */
|
|
9
|
+
export declare function entryImages(entryId: string, message: Message): {
|
|
10
|
+
images?: EntryImage[];
|
|
11
|
+
};
|
|
12
|
+
/** The image `ref` names in `record`, or undefined when it names none there. The inverse of `entryImages`. */
|
|
13
|
+
export declare function imageAt(record: SessionManager, ref: string): ImageRef | undefined;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A user prompt's and a tool result's images; no other message publishes any, so no ref can reach them. */
|
|
2
|
+
function publishedImages(message) {
|
|
3
|
+
if (message.role !== "user" && message.role !== "toolResult")
|
|
4
|
+
return [];
|
|
5
|
+
const content = message.content;
|
|
6
|
+
return Array.isArray(content) ? content.filter((b) => b.type === "image") : [];
|
|
7
|
+
}
|
|
8
|
+
/** `data.images` of the entry `entryId` holding `message`, absent when it has none. */
|
|
9
|
+
export function entryImages(entryId, message) {
|
|
10
|
+
const images = publishedImages(message).map((b, i) => ({ ref: `${entryId}:${i}`, mimeType: b.mimeType }));
|
|
11
|
+
return images.length > 0 ? { images } : {};
|
|
12
|
+
}
|
|
13
|
+
/** The image `ref` names in `record`, or undefined when it names none there. The inverse of `entryImages`. */
|
|
14
|
+
export function imageAt(record, ref) {
|
|
15
|
+
const at = ref.lastIndexOf(":");
|
|
16
|
+
const index = ref.slice(at + 1);
|
|
17
|
+
// Canonical digits only, so one image has one ref (`:00` is not `:0`).
|
|
18
|
+
if (at <= 0 || !/^(0|[1-9]\d*)$/.test(index))
|
|
19
|
+
return undefined;
|
|
20
|
+
const entry = record.getEntry(ref.slice(0, at));
|
|
21
|
+
const image = entry?.type === "message" ? publishedImages(entry.message)[Number(index)] : undefined;
|
|
22
|
+
return image ? { data: image.data, mimeType: image.mimeType } : undefined;
|
|
23
|
+
}
|
|
@@ -4,12 +4,18 @@ import * as Effect from "effect/Effect";
|
|
|
4
4
|
import * as Fiber from "effect/Fiber";
|
|
5
5
|
import * as Queue from "effect/Queue";
|
|
6
6
|
import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
|
|
7
|
+
import { entryImages } from "./entry-images.js";
|
|
7
8
|
import { cancellableStream } from "../../collect.js";
|
|
8
9
|
import { log } from "../../log.js";
|
|
9
10
|
import { toRetryScheduledEvent } from "./retry-event.js";
|
|
10
11
|
import { PortFailure, port, portAbort, portCleanup, portError } from "../../effect-port.js";
|
|
11
12
|
import { SessionBusy, acquireSession, acquireSessionLease } from "./session-effects.js";
|
|
12
|
-
import { errorToTerminal, inProcessLease,
|
|
13
|
+
import { errorToTerminal, inProcessLease, agentEventProjection, answerOutcome, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
|
|
14
|
+
// Structured output is for scripts. Observation publishes Pi's display result, including bounded nested traces.
|
|
15
|
+
function displayToolResult(result) {
|
|
16
|
+
const { structuredContent: _internal, ...display } = result;
|
|
17
|
+
return display;
|
|
18
|
+
}
|
|
13
19
|
function toSessionEvent(event, runId) {
|
|
14
20
|
const at = Date.now();
|
|
15
21
|
switch (event.type) {
|
|
@@ -32,30 +38,47 @@ function toSessionEvent(event, runId) {
|
|
|
32
38
|
}
|
|
33
39
|
return null;
|
|
34
40
|
}
|
|
35
|
-
case "message_end":
|
|
41
|
+
case "message_end": {
|
|
36
42
|
if (event.message.role !== "assistant")
|
|
37
43
|
return null;
|
|
38
|
-
|
|
44
|
+
const outcome = answerOutcome(event.message);
|
|
45
|
+
return { type: "message_finished", timestamp: at, runId, data: outcome ? { outcome } : {} };
|
|
46
|
+
}
|
|
39
47
|
case "tool_execution_start":
|
|
40
48
|
return {
|
|
41
49
|
type: "tool_started",
|
|
42
50
|
timestamp: at,
|
|
43
51
|
runId,
|
|
44
|
-
data: {
|
|
52
|
+
data: {
|
|
53
|
+
id: event.toolCallId,
|
|
54
|
+
name: event.toolName,
|
|
55
|
+
args: event.args,
|
|
56
|
+
...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
|
|
57
|
+
},
|
|
45
58
|
};
|
|
46
59
|
case "tool_execution_update":
|
|
47
60
|
return {
|
|
48
61
|
type: "tool_progress",
|
|
49
62
|
timestamp: at,
|
|
50
63
|
runId,
|
|
51
|
-
data: {
|
|
64
|
+
data: {
|
|
65
|
+
id: event.toolCallId,
|
|
66
|
+
name: event.toolName,
|
|
67
|
+
partialResult: displayToolResult(event.partialResult),
|
|
68
|
+
...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
|
|
69
|
+
},
|
|
52
70
|
};
|
|
53
71
|
case "tool_execution_end":
|
|
54
72
|
return {
|
|
55
73
|
type: "tool_finished",
|
|
56
74
|
timestamp: at,
|
|
57
75
|
runId,
|
|
58
|
-
data: {
|
|
76
|
+
data: {
|
|
77
|
+
id: event.toolCallId,
|
|
78
|
+
isError: event.isError,
|
|
79
|
+
content: displayToolResult(event.result),
|
|
80
|
+
...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
|
|
81
|
+
},
|
|
59
82
|
};
|
|
60
83
|
case "queue_update":
|
|
61
84
|
return {
|
|
@@ -105,9 +128,10 @@ function reportUserMessages(session, report) {
|
|
|
105
128
|
return Effect.acquireRelease(Effect.sync(() => {
|
|
106
129
|
record.appendMessage = (message) => {
|
|
107
130
|
const entryId = append.call(record, message);
|
|
108
|
-
// The same text `entries()` publishes for this entry (session-control.ts toSessionEntry).
|
|
109
|
-
if (message.role === "user")
|
|
110
|
-
report(entryId, contentText(message.content, ""));
|
|
131
|
+
// The same text and images `entries()` publishes for this entry (session-control.ts toSessionEntry).
|
|
132
|
+
if (message.role === "user") {
|
|
133
|
+
report({ entryId, text: contentText(message.content, ""), ...entryImages(entryId, message) });
|
|
134
|
+
}
|
|
111
135
|
return entryId;
|
|
112
136
|
};
|
|
113
137
|
}), () => Effect.sync(() => {
|
|
@@ -123,10 +147,27 @@ export function createPiAgentFromSession(options) {
|
|
|
123
147
|
const abort = new AbortController();
|
|
124
148
|
onCancelReady(() => abort.abort());
|
|
125
149
|
const runId = crypto.randomUUID();
|
|
150
|
+
const project = agentEventProjection();
|
|
151
|
+
/**
|
|
152
|
+
* Each call's `tool_progress` still waiting in the queue. A newer status replaces its text instead of queuing
|
|
153
|
+
* behind it, so the queue holds at most one per running call, and a consumer that falls behind (a slow `/invoke`
|
|
154
|
+
* client) gets the latest status, not every snapshot in between: pi's bash reports every 100 ms, and a single
|
|
155
|
+
* growing line is up to 50 KB each time. The replaced event keeps its place, which moves a status earlier, never
|
|
156
|
+
* past its own call's `tool_ended`: pi takes no progress from a call once it has returned.
|
|
157
|
+
*/
|
|
158
|
+
const unreadProgress = new Map();
|
|
126
159
|
let settled = false;
|
|
127
160
|
let outcome;
|
|
128
161
|
let abortsInFlight = 0;
|
|
129
162
|
let abortSucceeded = false;
|
|
163
|
+
/**
|
|
164
|
+
* An abort is owed to every model request this invoke makes, including one not yet started. pi's `abort()` stops
|
|
165
|
+
* only an agent run or a compaction already in flight: one that lands while `prompt()` prepares (input handlers,
|
|
166
|
+
* the auth check, `before_agent_start`) finds nothing to stop, and `_runAgentPrompt` then clears pi's own flag.
|
|
167
|
+
* So the run remembers it and aborts each agent run and automatic compaction as it starts (`agent_start`,
|
|
168
|
+
* `compaction_start`: both emitted once pi's controller for it exists).
|
|
169
|
+
*/
|
|
170
|
+
let abortRequested = false;
|
|
130
171
|
const observe = (event, run) => {
|
|
131
172
|
if (!event || !observer)
|
|
132
173
|
return;
|
|
@@ -142,25 +183,38 @@ export function createPiAgentFromSession(options) {
|
|
|
142
183
|
* Every control command obeys one admission rule: wait for this run's binding, then refuse once it has settled.
|
|
143
184
|
* Kept in one place so a fourth command cannot arrive with a fifth spelling of it.
|
|
144
185
|
*/
|
|
186
|
+
const alreadySettled = () => new PortFailure(new Error("run already settled; the command cannot take effect"));
|
|
145
187
|
const command = (run) => Effect.runPromise(Effect.gen(function* () {
|
|
146
188
|
const session = yield* ready;
|
|
147
189
|
if (settled)
|
|
148
|
-
return yield* Effect.fail(
|
|
149
|
-
yield* run(session);
|
|
190
|
+
return yield* Effect.fail(alreadySettled());
|
|
191
|
+
return yield* run(session);
|
|
150
192
|
}));
|
|
151
|
-
// Prompt preparation stays OUTSIDE admission
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
193
|
+
// Prompt preparation stays OUTSIDE admission: pi accepts a message for a finished run without complaint
|
|
194
|
+
// (`_steeringMessages.push`), so a run that ends between the settled check and the enqueue drops the command while
|
|
195
|
+
// it reports success. Image resizing would be such a gap, hundreds of milliseconds of dynamic import and Photon
|
|
196
|
+
// work, and doing it here also keeps it overlapping session acquisition. pi has a gap of its own: it awaits the
|
|
197
|
+
// definition's `input` handlers before it queues, so a `queued` answer is checked against settlement once more.
|
|
198
|
+
// A `handled` one stands either way: the handler took the prompt whatever became of the run.
|
|
155
199
|
const enqueue = async (p, kind) => {
|
|
156
200
|
const opts = await toPiPromptOptions(p, "queued");
|
|
157
|
-
return command((session) =>
|
|
201
|
+
return command((session) => Effect.gen(function* () {
|
|
202
|
+
const disposition = yield* port(() => session[kind](p.text, opts?.images));
|
|
203
|
+
if (disposition === "queued" && settled) {
|
|
204
|
+
// pi has queued it, and announced it (`queue_update`), on a run that takes nothing in any more. Emptying
|
|
205
|
+
// the queue tells the observation plane so, which would otherwise report the refused prompt pending.
|
|
206
|
+
session.clearQueue();
|
|
207
|
+
return yield* Effect.fail(alreadySettled());
|
|
208
|
+
}
|
|
209
|
+
return disposition;
|
|
210
|
+
}));
|
|
158
211
|
};
|
|
159
212
|
const controls = {
|
|
160
213
|
steer: (p) => enqueue(p, "steer"),
|
|
161
214
|
followUp: (p) => enqueue(p, "followUp"),
|
|
162
215
|
abort: () => command((session) => Effect.gen(function* () {
|
|
163
216
|
abortsInFlight++;
|
|
217
|
+
abortRequested = true;
|
|
164
218
|
yield* port(() => session.abort()).pipe(Effect.tap(() => Effect.sync(() => {
|
|
165
219
|
abortSucceeded = true;
|
|
166
220
|
})), Effect.ensuring(Effect.sync(() => {
|
|
@@ -188,9 +242,10 @@ export function createPiAgentFromSession(options) {
|
|
|
188
242
|
let streamedAnswer = false;
|
|
189
243
|
let retriedAfterAnswer;
|
|
190
244
|
let eventFailure;
|
|
191
|
-
const
|
|
192
|
-
void Effect.runPromise(portCleanup(
|
|
245
|
+
const abortWith = (operation) => {
|
|
246
|
+
void Effect.runPromise(portCleanup(operation, () => session.abort()));
|
|
193
247
|
};
|
|
248
|
+
const stop = () => abortWith("event-fault abort");
|
|
194
249
|
yield* Effect.acquireRelease(Effect.try({
|
|
195
250
|
try: () => session.subscribe((event) => {
|
|
196
251
|
if (retriedAfterAnswer !== undefined || eventFailure)
|
|
@@ -198,6 +253,8 @@ export function createPiAgentFromSession(options) {
|
|
|
198
253
|
try {
|
|
199
254
|
if (event.type === "agent_start")
|
|
200
255
|
runStarted = true;
|
|
256
|
+
if ((event.type === "agent_start" || event.type === "compaction_start") && abortRequested)
|
|
257
|
+
abortWith("requested abort");
|
|
201
258
|
// Compaction rewrites session history; the event's assistant message is the turn's fact.
|
|
202
259
|
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
203
260
|
finalAssistant = event.message;
|
|
@@ -220,11 +277,19 @@ export function createPiAgentFromSession(options) {
|
|
|
220
277
|
observe(rich);
|
|
221
278
|
if (!rich)
|
|
222
279
|
return;
|
|
223
|
-
const projected =
|
|
280
|
+
const projected = project(rich);
|
|
224
281
|
if (!projected)
|
|
225
282
|
return;
|
|
226
283
|
if (projected.type === "text" || projected.type === "thinking")
|
|
227
284
|
streamedAnswer = true;
|
|
285
|
+
if (projected.type === "tool_progress") {
|
|
286
|
+
const unread = unreadProgress.get(projected.id);
|
|
287
|
+
if (unread) {
|
|
288
|
+
unread.text = projected.text;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
unreadProgress.set(projected.id, projected);
|
|
292
|
+
}
|
|
228
293
|
Queue.offerUnsafe(queue, projected);
|
|
229
294
|
}
|
|
230
295
|
catch (error) {
|
|
@@ -240,7 +305,7 @@ export function createPiAgentFromSession(options) {
|
|
|
240
305
|
if (OWED_BY_THE_RUN.has(error.event))
|
|
241
306
|
extensionFailure ??= `${error.event} failed (${error.extensionPath}): ${error.error}`;
|
|
242
307
|
})), (unsubscribe) => Effect.sync(unsubscribe));
|
|
243
|
-
yield* reportUserMessages(session, (
|
|
308
|
+
yield* reportUserMessages(session, (data) => observe({ type: "user_message", timestamp: Date.now(), runId, data }));
|
|
244
309
|
const extensionTurns = trackExtensionTurns(session);
|
|
245
310
|
// Completing the gate can run waiting controls synchronously; their queue events must be observed.
|
|
246
311
|
yield* Deferred.succeed(bound, session);
|
|
@@ -316,6 +381,9 @@ export function createPiAgentFromSession(options) {
|
|
|
316
381
|
}
|
|
317
382
|
else if (event.type === "completed")
|
|
318
383
|
outcome = { status: "completed" };
|
|
384
|
+
// Handed over: the consumer owns it now, and the next status queues afresh.
|
|
385
|
+
if (event.type === "tool_progress")
|
|
386
|
+
unreadProgress.delete(event.id);
|
|
319
387
|
yield event;
|
|
320
388
|
}
|
|
321
389
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface LockResult<T> {
|
|
2
|
+
result: T;
|
|
3
|
+
/** New file content to persist under the lock; undefined = no write. */
|
|
4
|
+
next?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Serialized cross-process read-modify-write of one file, replaced whole so its readers need no lock. */
|
|
7
|
+
export declare function withLockedFile<T>(path: string, fn: (current: string | undefined) => Promise<LockResult<T>>, options?: {
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one cross-process read-modify-write for a JSON file several processes share: fastagent's credentials file and a
|
|
3
|
+
* model catalog. It takes the lock pi takes for the same files (pi-coding-agent's `FileAuthStorageBackend`), so a
|
|
4
|
+
* fastagent writer and a pi writer queue behind each other, and it REPLACES the file rather than rewriting it in
|
|
5
|
+
* place, which is what lets every reader of these files read without the lock: a reader sees the old file or the new
|
|
6
|
+
* one, never one cut short.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, resolve } from "node:path";
|
|
10
|
+
import lockfile from "proper-lockfile";
|
|
11
|
+
import { writeFileAtomic } from "../../atomic-write.js";
|
|
12
|
+
import { SECRET_FILE_MODE } from "../../paths.js";
|
|
13
|
+
const WRITE_OPTIONS = { encoding: "utf8", mode: SECRET_FILE_MODE };
|
|
14
|
+
/**
|
|
15
|
+
* The file a write must land IN, which is not always the path it is addressed BY. pi writes these files in place,
|
|
16
|
+
* so it follows a symlink to the target; an atomic rename would replace the link with a regular file instead, and
|
|
17
|
+
* from then on the two tools would be editing different files — the same split the shared lock exists to prevent,
|
|
18
|
+
* arriving one write later. A dangling link (a dotfile manager that has not checked the target out yet) still names
|
|
19
|
+
* where the content belongs, so it is followed too rather than overwritten.
|
|
20
|
+
*/
|
|
21
|
+
function writeTarget(path) {
|
|
22
|
+
if (existsSync(path))
|
|
23
|
+
return realpathSync(path);
|
|
24
|
+
const link = lstatSync(path, { throwIfNoEntry: false });
|
|
25
|
+
// ponytail: one hop. A chain of dangling links would have its second link replaced; resolve iteratively if that
|
|
26
|
+
// ever shows up in a real layout.
|
|
27
|
+
return link?.isSymbolicLink() ? resolve(dirname(path), readlinkSync(path)) : path;
|
|
28
|
+
}
|
|
29
|
+
/** Serialized cross-process read-modify-write of one file, replaced whole so its readers need no lock. */
|
|
30
|
+
export async function withLockedFile(path, fn, options = {}) {
|
|
31
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
32
|
+
if (!existsSync(path)) {
|
|
33
|
+
try {
|
|
34
|
+
// `wx` makes this an exclusive CREATE, which is the one case `writeFileSync` honours `mode` in — so no chmod
|
|
35
|
+
// after it (unlike writeFileAtomic, whose temp may already exist from a crashed writer).
|
|
36
|
+
writeFileSync(path, "{}", { ...WRITE_OPTIONS, flag: "wx" });
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// EEXIST: the path IS taken, by something `existsSync` does not see through — either another process created
|
|
40
|
+
// the file just now (its content must not be clobbered) or the path is a dangling symlink. Both are left
|
|
41
|
+
// alone: the write below resolves where they belong.
|
|
42
|
+
if (error.code !== "EEXIST")
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let compromised;
|
|
47
|
+
const throwIfCompromised = () => {
|
|
48
|
+
if (compromised)
|
|
49
|
+
throw compromised;
|
|
50
|
+
};
|
|
51
|
+
const release = await lockfile.lock(path, {
|
|
52
|
+
// MUST match pi-coding-agent's `core/auth-storage.js`, which locks with `realpath: false`. When the file
|
|
53
|
+
// itself is a symlink (dotfile managers do this), the two settings name DIFFERENT lock files and both locks can
|
|
54
|
+
// be held at once — so `pi` and `fastagent` would refresh the same provider concurrently, and a rotated refresh
|
|
55
|
+
// token logs one of them out. Resolving the link would be the stronger rule, but only if BOTH sides did it, and
|
|
56
|
+
// we do not own the other side.
|
|
57
|
+
realpath: false,
|
|
58
|
+
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
|
|
59
|
+
stale: 30_000,
|
|
60
|
+
onCompromised: (error) => {
|
|
61
|
+
compromised = error;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
throwIfCompromised();
|
|
67
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
68
|
+
const out = await fn(current);
|
|
69
|
+
throwIfCompromised();
|
|
70
|
+
// The last moment a caller that gave up can keep its change off disk: waiting for the lock may have outlasted it
|
|
71
|
+
// (another process held the lock through the caller's timeout or cancel).
|
|
72
|
+
options.signal?.throwIfAborted();
|
|
73
|
+
// Rename, not an in-place rewrite: it is what lets `read` stay unlocked, and it is the only spelling that applies
|
|
74
|
+
// the mode before the content is reachable.
|
|
75
|
+
if (out.next !== undefined) {
|
|
76
|
+
writeFileAtomic(writeTarget(path), out.next, SECRET_FILE_MODE);
|
|
77
|
+
}
|
|
78
|
+
throwIfCompromised();
|
|
79
|
+
result = out.result;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// The primary failure stays the signal; unlock noise must not mask it.
|
|
83
|
+
try {
|
|
84
|
+
await release();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
// Success path: a failed release is a real cleanup failure (the leftover `<file>.lock` stalls the next writer for
|
|
92
|
+
// the staleness window with zero diagnostics), so it surfaces instead of resolving a silently degraded operation.
|
|
93
|
+
try {
|
|
94
|
+
await release();
|
|
95
|
+
}
|
|
96
|
+
catch (releaseError) {
|
|
97
|
+
if (compromised === undefined)
|
|
98
|
+
throw releaseError;
|
|
99
|
+
}
|
|
100
|
+
throwIfCompromised();
|
|
101
|
+
return result;
|
|
102
|
+
}
|