@automatalabs/pi-acp 0.1.3 → 0.2.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/README.md +24 -3
- package/dist/agent.d.ts +11 -7
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +374 -60
- package/dist/child-process-registry.d.ts +82 -0
- package/dist/child-process-registry.d.ts.map +1 -0
- package/dist/child-process-registry.js +408 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +54 -10
- package/dist/deps.d.ts +3 -3
- package/dist/deps.d.ts.map +1 -1
- package/dist/deps.js +2 -2
- package/dist/errors.d.ts +17 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -5
- package/dist/index.js +5 -6
- package/dist/mcp-bridge.d.ts +235 -17
- package/dist/mcp-bridge.d.ts.map +1 -1
- package/dist/mcp-bridge.js +1331 -144
- package/dist/mcp-sampling-payload.d.ts +25 -0
- package/dist/mcp-sampling-payload.d.ts.map +1 -0
- package/dist/mcp-sampling-payload.js +263 -0
- package/dist/session.d.ts +27 -9
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +301 -145
- package/dist/translate.d.ts.map +1 -1
- package/dist/translate.js +10 -6
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/package.json +3 -3
- package/dist/structured-output.d.ts +0 -14
- package/dist/structured-output.d.ts.map +0 -1
- package/dist/structured-output.js +0 -71
package/dist/agent.js
CHANGED
|
@@ -1,11 +1,100 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { isAbsolute } from "node:path";
|
|
4
|
+
import { DefaultResourceLoader, SettingsManager, createBashToolDefinition, getAgentDir, } from "@earendil-works/pi-coding-agent";
|
|
3
5
|
import { AUTH_METHODS, authenticateMethod } from "./auth.js";
|
|
4
|
-
import { adapterError, isRequestError, unexpectedError } from "./errors.js";
|
|
5
|
-
import { bridgeMcpServers,
|
|
6
|
+
import { adapterError, isChildCleanupError, isRequestError, unexpectedError } from "./errors.js";
|
|
7
|
+
import { bridgeMcpServers, } from "./mcp-bridge.js";
|
|
6
8
|
import { PiSession } from "./session.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
+
import { ChildProcessRegistrySlot, createTrackedBashOperations } from "./child-process-registry.js";
|
|
10
|
+
import { PKG_VERSION } from "./version.js";
|
|
11
|
+
export { PKG_VERSION } from "./version.js";
|
|
12
|
+
/** Retry owner for the narrow interval after Pi exists but before PiSession is
|
|
13
|
+
* publishable. It gives failed-open rollback the same abort/tree barrier and
|
|
14
|
+
* hidden-record ownership as a fully constructed session. */
|
|
15
|
+
class FailedOpenCleanup {
|
|
16
|
+
pi;
|
|
17
|
+
bridge;
|
|
18
|
+
children;
|
|
19
|
+
lifecycle;
|
|
20
|
+
deps;
|
|
21
|
+
resourceDispose;
|
|
22
|
+
constructor(pi, bridge, children, lifecycle, deps) {
|
|
23
|
+
this.pi = pi;
|
|
24
|
+
this.bridge = bridge;
|
|
25
|
+
this.children = children;
|
|
26
|
+
this.lifecycle = lifecycle;
|
|
27
|
+
this.deps = deps;
|
|
28
|
+
}
|
|
29
|
+
get remainingChildren() { return this.children.remainingChildren; }
|
|
30
|
+
startResources(bridgeClose) {
|
|
31
|
+
this.resourceDispose ??= (async () => {
|
|
32
|
+
await this.bridge.drainRefreshes().catch((error) => {
|
|
33
|
+
console.error("pi-acp failed-open refresh drain error:", error);
|
|
34
|
+
});
|
|
35
|
+
const results = await Promise.allSettled([
|
|
36
|
+
Promise.resolve().then(() => this.pi.dispose()),
|
|
37
|
+
bridgeClose,
|
|
38
|
+
]);
|
|
39
|
+
for (const result of results) {
|
|
40
|
+
if (result.status === "rejected")
|
|
41
|
+
console.error("pi-acp failed-open resource disposal error:", result.reason);
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
return this.resourceDispose;
|
|
45
|
+
}
|
|
46
|
+
async dispose() {
|
|
47
|
+
const deadline = new AbortController();
|
|
48
|
+
const timer = new AbortController();
|
|
49
|
+
const expiry = this.deps.sleep(this.deps.graceMs, timer.signal).then(() => {
|
|
50
|
+
const failure = adapterError("child_cleanup_error", {
|
|
51
|
+
details: { remainingChildren: this.children.remainingChildren },
|
|
52
|
+
});
|
|
53
|
+
deadline.abort(failure);
|
|
54
|
+
throw failure;
|
|
55
|
+
});
|
|
56
|
+
expiry.catch(() => undefined);
|
|
57
|
+
// Failed-open cleanup has the same synchronous prefix as a published
|
|
58
|
+
// session: close spawn admission first, logically close every MCP client
|
|
59
|
+
// before aborting the binding lifetime, then start Pi abort before its
|
|
60
|
+
// one-shot resource disposal can run.
|
|
61
|
+
const captured = this.children.closeEpoch(deadline.signal);
|
|
62
|
+
this.bridge.startDisposal();
|
|
63
|
+
if (!this.lifecycle.signal.aborted)
|
|
64
|
+
this.lifecycle.abort(new Error("failed open disposed"));
|
|
65
|
+
this.bridge.abortRefreshes();
|
|
66
|
+
const bridgeClose = this.bridge.close();
|
|
67
|
+
bridgeClose.catch(() => undefined);
|
|
68
|
+
let abort;
|
|
69
|
+
try {
|
|
70
|
+
abort = this.pi.abort();
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
abort = Promise.reject(error);
|
|
74
|
+
}
|
|
75
|
+
abort.catch(() => undefined);
|
|
76
|
+
const settled = Promise.allSettled([abort, captured.drain]);
|
|
77
|
+
let cleanupError;
|
|
78
|
+
await Promise.race([settled, expiry]).then((results) => {
|
|
79
|
+
if (!Array.isArray(results))
|
|
80
|
+
return;
|
|
81
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
82
|
+
if (failure) {
|
|
83
|
+
cleanupError = adapterError("child_cleanup_error", {
|
|
84
|
+
details: { remainingChildren: this.children.remainingChildren },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}, (error) => { cleanupError = error; });
|
|
88
|
+
timer.abort();
|
|
89
|
+
// Pi disposal is defense in depth only after the abort/tree barrier has
|
|
90
|
+
// committed success or failure. MCP physical close was already started
|
|
91
|
+
// by the synchronous disposal prefix above.
|
|
92
|
+
const resources = this.startResources(bridgeClose);
|
|
93
|
+
await resources;
|
|
94
|
+
if (cleanupError)
|
|
95
|
+
throw cleanupError;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
9
98
|
function validateCwd(cwd) {
|
|
10
99
|
if (!isAbsolute(cwd))
|
|
11
100
|
throw adapterError("invalid_cwd");
|
|
@@ -35,7 +124,11 @@ export class PiAcpAgent {
|
|
|
35
124
|
openingControllers = new Set();
|
|
36
125
|
openingTasks = new Set();
|
|
37
126
|
tombstones = new Set();
|
|
127
|
+
cleanupRecords = new Map();
|
|
128
|
+
mcpOwnerToken = {};
|
|
38
129
|
disposed = false;
|
|
130
|
+
disposePromise;
|
|
131
|
+
disposeSucceeded = false;
|
|
39
132
|
constructor(deps) {
|
|
40
133
|
this.deps = deps;
|
|
41
134
|
}
|
|
@@ -50,9 +143,8 @@ export class PiAcpAgent {
|
|
|
50
143
|
agentCapabilities: {
|
|
51
144
|
loadSession: true,
|
|
52
145
|
promptCapabilities: { image: true },
|
|
53
|
-
mcpCapabilities: {},
|
|
146
|
+
mcpCapabilities: { http: true, sse: true },
|
|
54
147
|
sessionCapabilities: { resume: {}, fork: {}, list: {}, close: {} },
|
|
55
|
-
_meta: { "@automatalabs/pi-acp": { outputSchema: true } },
|
|
56
148
|
},
|
|
57
149
|
authMethods: AUTH_METHODS,
|
|
58
150
|
};
|
|
@@ -68,16 +160,29 @@ export class PiAcpAgent {
|
|
|
68
160
|
if (this.live.has(id) || this.opening.has(id))
|
|
69
161
|
throw adapterError("session_already_open");
|
|
70
162
|
}
|
|
71
|
-
reserve(id,
|
|
163
|
+
reserve(id, opening) {
|
|
72
164
|
this.ensureMayOpen(id);
|
|
73
|
-
this.opening.set(id,
|
|
165
|
+
this.opening.set(id, opening);
|
|
74
166
|
}
|
|
75
167
|
beginOpening(requestSignal) {
|
|
76
168
|
if (this.disposed)
|
|
77
169
|
throw adapterError("internal_error");
|
|
78
170
|
const controller = openSignal(requestSignal);
|
|
171
|
+
let settled = false;
|
|
172
|
+
let resolveSettlement;
|
|
173
|
+
const settlement = new Promise((resolve) => { resolveSettlement = resolve; });
|
|
174
|
+
const opening = {
|
|
175
|
+
controller,
|
|
176
|
+
settlement,
|
|
177
|
+
settle: () => {
|
|
178
|
+
if (settled)
|
|
179
|
+
return;
|
|
180
|
+
settled = true;
|
|
181
|
+
resolveSettlement();
|
|
182
|
+
},
|
|
183
|
+
};
|
|
79
184
|
this.openingControllers.add(controller);
|
|
80
|
-
return
|
|
185
|
+
return opening;
|
|
81
186
|
}
|
|
82
187
|
track(task) {
|
|
83
188
|
this.openingTasks.add(task);
|
|
@@ -90,78 +195,213 @@ export class PiAcpAgent {
|
|
|
90
195
|
throw opening.controller.signal.reason ?? adapterError("internal_error");
|
|
91
196
|
}
|
|
92
197
|
}
|
|
198
|
+
async connectMcp(opening, sessionId, cwd, client, mcpServers) {
|
|
199
|
+
const lifecycle = new AbortController();
|
|
200
|
+
const state = { published: false };
|
|
201
|
+
const binding = {
|
|
202
|
+
sessionId,
|
|
203
|
+
cwd,
|
|
204
|
+
client,
|
|
205
|
+
sessionSignal: lifecycle.signal,
|
|
206
|
+
getPi: () => state.pi,
|
|
207
|
+
getTurnSignal: () => state.wrapper?.activeTurnSignal(),
|
|
208
|
+
isPublished: () => state.published,
|
|
209
|
+
emitDiagnostic: (text) => {
|
|
210
|
+
if (state.wrapper)
|
|
211
|
+
state.wrapper.emitMcpDiagnostic(text);
|
|
212
|
+
else
|
|
213
|
+
console.error(text);
|
|
214
|
+
},
|
|
215
|
+
poison: () => state.wrapper?.poison(),
|
|
216
|
+
ownerToken: this.mcpOwnerToken,
|
|
217
|
+
modelRuntime: this.deps.modelRuntime,
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
const bridge = await bridgeMcpServers(mcpServers, opening.controller.signal, this.deps, binding);
|
|
221
|
+
return { bridge, lifecycle, state };
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
lifecycle.abort(new Error("MCP opening failed"));
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
93
228
|
async construct(opening, manager, cwd, client, mcpServers, replay, preconnected) {
|
|
94
229
|
const id = manager.getSessionId();
|
|
95
230
|
if (opening.id === undefined) {
|
|
96
|
-
this.reserve(id, opening
|
|
231
|
+
this.reserve(id, opening);
|
|
97
232
|
opening.id = id;
|
|
98
233
|
}
|
|
99
|
-
let
|
|
234
|
+
let prepared = preconnected;
|
|
235
|
+
let bridge = prepared?.bridge;
|
|
100
236
|
let pi;
|
|
101
237
|
let wrapper;
|
|
238
|
+
let lifecycle = prepared?.lifecycle;
|
|
239
|
+
let bindingState = prepared?.state;
|
|
240
|
+
const childRegistry = new ChildProcessRegistrySlot(this.deps);
|
|
102
241
|
try {
|
|
103
242
|
this.gate(opening);
|
|
104
|
-
|
|
243
|
+
prepared ??= await this.connectMcp(opening, id, cwd, client, mcpServers);
|
|
244
|
+
bridge = prepared.bridge;
|
|
245
|
+
lifecycle = prepared.lifecycle;
|
|
246
|
+
bindingState = prepared.state;
|
|
105
247
|
this.gate(opening);
|
|
106
|
-
const
|
|
248
|
+
const settingsManager = SettingsManager.create(cwd, getAgentDir());
|
|
249
|
+
const instructionFactory = bridge.instructionsExtension;
|
|
250
|
+
const controlExtension = {
|
|
251
|
+
name: "agentprism-pi-acp-control",
|
|
252
|
+
factory: async (api) => {
|
|
253
|
+
if (typeof instructionFactory === "function")
|
|
254
|
+
await instructionFactory(api);
|
|
255
|
+
else
|
|
256
|
+
await instructionFactory.factory(api);
|
|
257
|
+
api.registerTool(createBashToolDefinition(cwd, {
|
|
258
|
+
commandPrefix: settingsManager.getShellCommandPrefix(),
|
|
259
|
+
operations: createTrackedBashOperations(childRegistry, settingsManager.getShellPath(), this.deps, () => wrapper?.childCleanupFailure()),
|
|
260
|
+
}));
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
264
|
+
cwd,
|
|
265
|
+
agentDir: getAgentDir(),
|
|
266
|
+
settingsManager,
|
|
267
|
+
extensionFactories: [bridge.inlineExtension, controlExtension],
|
|
268
|
+
extensionsOverride: (base) => {
|
|
269
|
+
const matches = base.extensions.filter(({ path }) => path === "<inline:agentprism-pi-acp-mcp>");
|
|
270
|
+
const controls = base.extensions.filter(({ path }) => path === "<inline:agentprism-pi-acp-control>");
|
|
271
|
+
if (matches.length !== 1 || controls.length !== 1)
|
|
272
|
+
throw adapterError("extension_setup_error");
|
|
273
|
+
const reserved = matches[0];
|
|
274
|
+
const control = controls[0];
|
|
275
|
+
const configured = base.extensions.filter(({ path }) => !path.startsWith("<inline:"));
|
|
276
|
+
const configuredBash = configured.some(({ tools }) => tools.has("bash"));
|
|
277
|
+
if (configuredBash)
|
|
278
|
+
control.tools.delete("bash");
|
|
279
|
+
// Pi records conflict diagnostics before this override can impose
|
|
280
|
+
// the adapter's deliberate precedence. Remove only the conflicts
|
|
281
|
+
// that this transaction has actually resolved: the reserved MCP
|
|
282
|
+
// extension is moved first, and core bash is omitted when a
|
|
283
|
+
// configured extension already owns it. User/user conflicts and
|
|
284
|
+
// every loader/factory error remain fatal and retain their order.
|
|
285
|
+
const reservedNames = new Set(reserved.tools.keys());
|
|
286
|
+
for (let index = base.errors.length - 1; index >= 0; index -= 1) {
|
|
287
|
+
const issue = base.errors[index];
|
|
288
|
+
const resolvedReserved = issue.path === reserved.path
|
|
289
|
+
&& [...reservedNames].some((name) => issue.error.startsWith(`Tool "${name}" conflicts with `));
|
|
290
|
+
const resolvedBash = configuredBash
|
|
291
|
+
&& issue.path === control.path
|
|
292
|
+
&& issue.error.startsWith('Tool "bash" conflicts with ');
|
|
293
|
+
if (resolvedReserved || resolvedBash)
|
|
294
|
+
base.errors.splice(index, 1);
|
|
295
|
+
}
|
|
296
|
+
const result = { ...base, extensions: [reserved, ...base.extensions.filter((item) => item !== reserved)] };
|
|
297
|
+
if (result.runtime !== base.runtime || result.errors !== base.errors)
|
|
298
|
+
throw adapterError("extension_setup_error");
|
|
299
|
+
return result;
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
await resourceLoader.reload();
|
|
303
|
+
if (resourceLoader.getExtensions().errors.length > 0)
|
|
304
|
+
throw adapterError("extension_setup_error");
|
|
107
305
|
const created = await this.deps.createAgentSession({
|
|
108
306
|
cwd,
|
|
109
307
|
sessionManager: manager,
|
|
110
308
|
modelRuntime: this.deps.modelRuntime,
|
|
111
|
-
|
|
309
|
+
resourceLoader,
|
|
310
|
+
settingsManager,
|
|
112
311
|
});
|
|
113
312
|
pi = created.session;
|
|
114
|
-
|
|
313
|
+
bindingState.pi = pi;
|
|
115
314
|
wrapper = new PiSession({
|
|
116
315
|
sessionId: id,
|
|
117
316
|
session: pi,
|
|
118
317
|
manager,
|
|
119
318
|
client,
|
|
120
319
|
deps: this.deps,
|
|
121
|
-
|
|
320
|
+
mcpBridge: bridge,
|
|
122
321
|
failedMcpResults: bridge.failedResults,
|
|
123
|
-
|
|
124
|
-
|
|
322
|
+
availableModels: [],
|
|
323
|
+
childRegistry,
|
|
324
|
+
lifecycleController: lifecycle,
|
|
325
|
+
onWedged: (sessionId, session, cleanupRetryRequired) => this.terminateWedged(sessionId, session, cleanupRetryRequired),
|
|
125
326
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
327
|
+
bindingState.wrapper = wrapper;
|
|
328
|
+
await pi.bindExtensions({});
|
|
329
|
+
const availableModels = [...await this.deps.modelRuntime.getAvailable()];
|
|
330
|
+
wrapper.publishAvailableModels(availableModels);
|
|
331
|
+
this.gate(opening);
|
|
332
|
+
bridge.bindSession(pi);
|
|
333
|
+
const toolInfos = pi.getAllTools();
|
|
334
|
+
const names = new Set(toolInfos.map(({ name }) => name));
|
|
130
335
|
const missingAlias = bridge.aliases.find((alias) => !names.has(alias));
|
|
131
336
|
if (missingAlias) {
|
|
132
337
|
throw adapterError("mcp_init_error", {
|
|
133
338
|
server: bridge.aliasServers.get(missingAlias) ?? "unknown",
|
|
134
339
|
});
|
|
135
340
|
}
|
|
341
|
+
for (const alias of bridge.aliases) {
|
|
342
|
+
const info = toolInfos.find(({ name }) => name === alias);
|
|
343
|
+
if (info?.sourceInfo.path !== "<inline:agentprism-pi-acp-mcp>") {
|
|
344
|
+
throw adapterError("mcp_init_error", { server: bridge.aliasServers.get(alias) ?? "unknown" });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const bash = toolInfos.find(({ name }) => name === "bash");
|
|
348
|
+
if (!bash || bash.sourceInfo.path === "<builtin:bash>")
|
|
349
|
+
throw adapterError("extension_setup_error");
|
|
136
350
|
if (replay)
|
|
137
351
|
await wrapper.replay(manager.getBranch());
|
|
352
|
+
bridge.assertReady();
|
|
138
353
|
this.gate(opening);
|
|
139
354
|
this.live.set(id, wrapper);
|
|
355
|
+
bindingState.published = true;
|
|
140
356
|
this.opening.delete(id);
|
|
141
357
|
return wrapper;
|
|
142
358
|
}
|
|
143
359
|
catch (error) {
|
|
144
|
-
|
|
145
|
-
|
|
360
|
+
let cleanupError;
|
|
361
|
+
if (wrapper) {
|
|
362
|
+
try {
|
|
363
|
+
await wrapper.dispose();
|
|
364
|
+
}
|
|
365
|
+
catch (candidate) {
|
|
366
|
+
if (isChildCleanupError(candidate)) {
|
|
367
|
+
this.cleanupRecords.set(id, wrapper);
|
|
368
|
+
cleanupError = candidate;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
146
372
|
else {
|
|
147
373
|
if (pi) {
|
|
374
|
+
const rollback = new FailedOpenCleanup(pi, bridge, childRegistry, lifecycle, this.deps);
|
|
148
375
|
try {
|
|
149
|
-
await
|
|
376
|
+
await rollback.dispose();
|
|
150
377
|
}
|
|
151
|
-
catch (
|
|
152
|
-
|
|
378
|
+
catch (candidate) {
|
|
379
|
+
if (isChildCleanupError(candidate)) {
|
|
380
|
+
this.cleanupRecords.set(id, rollback);
|
|
381
|
+
cleanupError = candidate;
|
|
382
|
+
}
|
|
153
383
|
}
|
|
154
384
|
}
|
|
155
|
-
if (bridge)
|
|
156
|
-
|
|
385
|
+
else if (bridge) {
|
|
386
|
+
bridge.startDisposal();
|
|
387
|
+
if (lifecycle && !lifecycle.signal.aborted)
|
|
388
|
+
lifecycle.abort(new Error("failed open disposed"));
|
|
389
|
+
bridge.abortRefreshes();
|
|
390
|
+
await bridge.close();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (cleanupError) {
|
|
394
|
+
opening.cleanupError = cleanupError;
|
|
395
|
+
throw cleanupError;
|
|
157
396
|
}
|
|
158
397
|
throw error;
|
|
159
398
|
}
|
|
160
399
|
finally {
|
|
161
|
-
if (opening.id !== undefined && this.opening.get(opening.id) === opening
|
|
400
|
+
if (opening.id !== undefined && this.opening.get(opening.id) === opening) {
|
|
162
401
|
this.opening.delete(opening.id);
|
|
163
402
|
}
|
|
164
403
|
this.openingControllers.delete(opening.controller);
|
|
404
|
+
opening.settle();
|
|
165
405
|
}
|
|
166
406
|
}
|
|
167
407
|
openingError(error) {
|
|
@@ -180,11 +420,12 @@ export class PiAcpAgent {
|
|
|
180
420
|
}
|
|
181
421
|
const opening = this.beginOpening(context.signal);
|
|
182
422
|
try {
|
|
183
|
-
this.reserve(manager.getSessionId(), opening
|
|
423
|
+
this.reserve(manager.getSessionId(), opening);
|
|
184
424
|
opening.id = manager.getSessionId();
|
|
185
425
|
}
|
|
186
426
|
catch (error) {
|
|
187
427
|
this.openingControllers.delete(opening.controller);
|
|
428
|
+
opening.settle();
|
|
188
429
|
throw error;
|
|
189
430
|
}
|
|
190
431
|
const task = this.construct(opening, manager, context.params.cwd, context.client, context.params.mcpServers, false).then((session) => ({ sessionId: session.sessionId, configOptions: session.configOptions(), modes: null }))
|
|
@@ -195,11 +436,12 @@ export class PiAcpAgent {
|
|
|
195
436
|
validateCwd(context.params.cwd);
|
|
196
437
|
const opening = this.beginOpening(context.signal);
|
|
197
438
|
try {
|
|
198
|
-
this.reserve(context.params.sessionId, opening
|
|
439
|
+
this.reserve(context.params.sessionId, opening);
|
|
199
440
|
opening.id = context.params.sessionId;
|
|
200
441
|
}
|
|
201
442
|
catch (error) {
|
|
202
443
|
this.openingControllers.delete(opening.controller);
|
|
444
|
+
opening.settle();
|
|
203
445
|
throw error;
|
|
204
446
|
}
|
|
205
447
|
const task = (async () => {
|
|
@@ -221,10 +463,11 @@ export class PiAcpAgent {
|
|
|
221
463
|
return { configOptions: session.configOptions(), modes: null };
|
|
222
464
|
}
|
|
223
465
|
catch (error) {
|
|
224
|
-
if (opening.id !== undefined && this.opening.get(opening.id) === opening
|
|
466
|
+
if (opening.id !== undefined && this.opening.get(opening.id) === opening) {
|
|
225
467
|
this.opening.delete(opening.id);
|
|
226
468
|
}
|
|
227
469
|
this.openingControllers.delete(opening.controller);
|
|
470
|
+
opening.settle();
|
|
228
471
|
return this.openingError(error);
|
|
229
472
|
}
|
|
230
473
|
})();
|
|
@@ -245,7 +488,8 @@ export class PiAcpAgent {
|
|
|
245
488
|
validateCwd(context.params.cwd);
|
|
246
489
|
const opening = this.beginOpening(context.signal);
|
|
247
490
|
const task = (async () => {
|
|
248
|
-
let
|
|
491
|
+
let prepared;
|
|
492
|
+
let preparedTransferred = false;
|
|
249
493
|
try {
|
|
250
494
|
let sourcePath;
|
|
251
495
|
if (liveSource) {
|
|
@@ -260,32 +504,43 @@ export class PiAcpAgent {
|
|
|
260
504
|
throw adapterError("unknown_session");
|
|
261
505
|
}
|
|
262
506
|
this.gate(opening);
|
|
263
|
-
bridge = await bridgeMcpServers(context.params.mcpServers ?? [], opening.controller.signal, this.deps);
|
|
264
|
-
this.gate(opening);
|
|
265
507
|
if (this.tombstones.has(context.params.sessionId))
|
|
266
508
|
throw adapterError("session_terminated");
|
|
267
509
|
if (liveSource?.busy)
|
|
268
510
|
throw adapterError("session_busy");
|
|
511
|
+
// Pin the target id before the irreversible journal write so the MCP
|
|
512
|
+
// binding can be fully connected and owned first. SessionManager
|
|
513
|
+
// validates and uses this exact id when forkFrom eventually writes.
|
|
514
|
+
const targetId = randomUUID();
|
|
515
|
+
this.reserve(targetId, opening);
|
|
516
|
+
opening.id = targetId;
|
|
517
|
+
prepared = await this.connectMcp(opening, targetId, context.params.cwd, context.client, context.params.mcpServers ?? []);
|
|
518
|
+
this.gate(opening);
|
|
269
519
|
let manager;
|
|
270
520
|
try {
|
|
271
|
-
manager = this.deps.sessions.forkFrom(sourcePath, context.params.cwd, this.deps.sessionDir);
|
|
521
|
+
manager = this.deps.sessions.forkFrom(sourcePath, context.params.cwd, this.deps.sessionDir, { id: targetId });
|
|
272
522
|
}
|
|
273
523
|
catch {
|
|
274
524
|
throw adapterError("session_corrupt");
|
|
275
525
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const session = await this.construct(opening, manager, context.params.cwd, context.client, context.params.mcpServers ?? [], false, bridge);
|
|
279
|
-
bridge = undefined;
|
|
526
|
+
preparedTransferred = true;
|
|
527
|
+
const session = await this.construct(opening, manager, context.params.cwd, context.client, context.params.mcpServers ?? [], false, prepared);
|
|
280
528
|
return { sessionId: session.sessionId, configOptions: session.configOptions(), modes: null };
|
|
281
529
|
}
|
|
282
530
|
catch (error) {
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
531
|
+
if (prepared && !preparedTransferred) {
|
|
532
|
+
prepared.bridge.startDisposal();
|
|
533
|
+
prepared.lifecycle.abort(new Error("fork failed before construction"));
|
|
534
|
+
prepared.bridge.abortRefreshes();
|
|
535
|
+
await prepared.bridge.close().catch((closeError) => {
|
|
536
|
+
console.error("pi-acp fork MCP rollback error:", closeError);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if (opening.id !== undefined && this.opening.get(opening.id) === opening) {
|
|
286
540
|
this.opening.delete(opening.id);
|
|
287
541
|
}
|
|
288
542
|
this.openingControllers.delete(opening.controller);
|
|
543
|
+
opening.settle();
|
|
289
544
|
return this.openingError(error);
|
|
290
545
|
}
|
|
291
546
|
})();
|
|
@@ -333,18 +588,27 @@ export class PiAcpAgent {
|
|
|
333
588
|
};
|
|
334
589
|
}
|
|
335
590
|
async closeSession(context) {
|
|
336
|
-
const
|
|
337
|
-
if (
|
|
338
|
-
controller.abort(new Error("session closed while opening"));
|
|
591
|
+
const opening = this.opening.get(context.params.sessionId);
|
|
592
|
+
if (opening) {
|
|
593
|
+
opening.controller.abort(new Error("session closed while opening"));
|
|
594
|
+
await opening.settlement;
|
|
595
|
+
if (opening.cleanupError)
|
|
596
|
+
throw opening.cleanupError;
|
|
339
597
|
return {};
|
|
340
598
|
}
|
|
341
|
-
const session = this.live.get(context.params.sessionId);
|
|
599
|
+
const session = this.live.get(context.params.sessionId) ?? this.cleanupRecords.get(context.params.sessionId);
|
|
342
600
|
if (!session)
|
|
343
601
|
return {};
|
|
344
602
|
try {
|
|
345
603
|
await session.dispose();
|
|
604
|
+
this.cleanupRecords.delete(context.params.sessionId);
|
|
346
605
|
}
|
|
347
606
|
catch (error) {
|
|
607
|
+
if (isChildCleanupError(error)) {
|
|
608
|
+
this.tombstones.add(context.params.sessionId);
|
|
609
|
+
this.cleanupRecords.set(context.params.sessionId, session);
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
348
612
|
console.error("pi-acp close error:", error);
|
|
349
613
|
}
|
|
350
614
|
finally {
|
|
@@ -377,22 +641,72 @@ export class PiAcpAgent {
|
|
|
377
641
|
cancel(context) {
|
|
378
642
|
this.live.get(context.params.sessionId)?.cancel();
|
|
379
643
|
}
|
|
380
|
-
async terminateWedged(id, session) {
|
|
644
|
+
async terminateWedged(id, session, cleanupRetryRequired) {
|
|
381
645
|
this.tombstones.add(id);
|
|
382
646
|
if (this.live.get(id) === session)
|
|
383
647
|
this.live.delete(id);
|
|
384
|
-
|
|
648
|
+
if (cleanupRetryRequired)
|
|
649
|
+
this.cleanupRecords.set(id, session);
|
|
650
|
+
try {
|
|
651
|
+
if (cleanupRetryRequired) {
|
|
652
|
+
await session.disposeAfterCleanupFailure();
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
await session.dispose();
|
|
656
|
+
this.cleanupRecords.delete(id);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (isChildCleanupError(error) || session.cleanupRetryRequired) {
|
|
661
|
+
this.cleanupRecords.set(id, session);
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
console.error("pi-acp wedged-session resource disposal error:", error);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
385
667
|
}
|
|
386
|
-
|
|
387
|
-
if (this.
|
|
668
|
+
dispose() {
|
|
669
|
+
if (this.disposePromise)
|
|
670
|
+
return this.disposePromise;
|
|
671
|
+
if (this.disposeSucceeded)
|
|
672
|
+
return Promise.resolve();
|
|
673
|
+
this.disposePromise = this.disposeGeneration()
|
|
674
|
+
.then(() => { this.disposeSucceeded = true; })
|
|
675
|
+
.finally(() => {
|
|
676
|
+
if (!this.disposeSucceeded)
|
|
677
|
+
this.disposePromise = undefined;
|
|
678
|
+
});
|
|
679
|
+
return this.disposePromise;
|
|
680
|
+
}
|
|
681
|
+
async disposeGeneration() {
|
|
682
|
+
if (this.disposed && this.cleanupRecords.size === 0)
|
|
388
683
|
return;
|
|
389
|
-
this.disposed
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
684
|
+
if (!this.disposed) {
|
|
685
|
+
this.disposed = true;
|
|
686
|
+
for (const controller of this.openingControllers)
|
|
687
|
+
controller.abort(new Error("agent disposed"));
|
|
688
|
+
await Promise.allSettled([...this.openingTasks]);
|
|
689
|
+
}
|
|
690
|
+
const records = new Map(this.cleanupRecords);
|
|
691
|
+
for (const [id, session] of this.live)
|
|
692
|
+
records.set(id, session);
|
|
394
693
|
this.live.clear();
|
|
395
|
-
|
|
396
|
-
|
|
694
|
+
const entries = [...records.entries()];
|
|
695
|
+
const results = await Promise.allSettled(entries.map(([, session]) => session.dispose()));
|
|
696
|
+
this.cleanupRecords.clear();
|
|
697
|
+
let sawChildFailure = false;
|
|
698
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
699
|
+
const result = results[index];
|
|
700
|
+
if (result.status === "rejected" && isChildCleanupError(result.reason)) {
|
|
701
|
+
const [id, session] = entries[index];
|
|
702
|
+
this.cleanupRecords.set(id, session);
|
|
703
|
+
sawChildFailure = true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (sawChildFailure) {
|
|
707
|
+
const remainingChildren = [...this.cleanupRecords.values()]
|
|
708
|
+
.reduce((sum, session) => sum + session.remainingChildren, 0);
|
|
709
|
+
throw adapterError("child_cleanup_error", { details: { remainingChildren } });
|
|
710
|
+
}
|
|
397
711
|
}
|
|
398
712
|
}
|