@mono-agent/agent-runtime 0.17.1 → 0.18.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.
@@ -0,0 +1,1124 @@
1
+ // @ts-check
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { isAbsolute } from "node:path";
5
+ import { client, methods, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
6
+ import { createStderrTail } from "../failure.js";
7
+ import { passthroughSandbox } from "../../agent/sandbox-seam.js";
8
+ import {
9
+ ACP_DEFAULT_MAX_LINE_BYTES,
10
+ AcpTransportError,
11
+ createBoundedAcpStdioStream,
12
+ normalizeAcpMaxLineBytes,
13
+ } from "./acp-transport.js";
14
+ import { sanitizeAcpHostValue } from "./acp-privacy.js";
15
+ import {
16
+ AcpClientError,
17
+ decodeAcpProviderSessionId,
18
+ decodeAcpSessionCursor,
19
+ encodeAcpProviderSessionId,
20
+ encodeAcpSessionCursor,
21
+ validateAcpProfileId,
22
+ validateAcpProviderSessionId,
23
+ } from "./acp-session-tokens.js";
24
+
25
+ const OWNERS = new Set(["client", "agent"]);
26
+ const RESUME_STRATEGIES = new Set(["auto", "load", "resume"]);
27
+ const DEFAULT_PROCESS_POLICY = Object.freeze({
28
+ startupTimeoutMs: 10_000,
29
+ requestTimeoutMs: 60_000,
30
+ shutdownGraceMs: 500,
31
+ killGraceMs: 500,
32
+ stderrTailBytes: 8 * 1024,
33
+ maxLineBytes: ACP_DEFAULT_MAX_LINE_BYTES,
34
+ });
35
+
36
+ /**
37
+ * Product-neutral description of one ACP stdio agent profile. The environment
38
+ * is exact/minimal: agent-runtime never spreads process.env into the child.
39
+ *
40
+ * @typedef {Object} AcpProfileDescriptor
41
+ * @property {string} command
42
+ * @property {ReadonlyArray<string>} [args]
43
+ * @property {string} [cwd]
44
+ * @property {Record<string, string>} [env]
45
+ * @property {"client"|"agent"} [configurationOwner]
46
+ * @property {"client"|"agent"} [workspaceOwner]
47
+ * @property {"client"|"agent"} [mcpOwner]
48
+ * @property {string} [workspacePath] Absolute canonical workspace path; required for agent-owned workspaces.
49
+ * @property {Object} [capabilityPolicy]
50
+ * @property {{readTextFile?: boolean, writeTextFile?: boolean}} [capabilityPolicy.filesystem]
51
+ * @property {boolean} [capabilityPolicy.terminal]
52
+ * @property {{terminal?: boolean}} [capabilityPolicy.auth]
53
+ * @property {{form?: boolean, url?: boolean}} [capabilityPolicy.elicitation]
54
+ * @property {{boolean?: boolean}} [capabilityPolicy.sessionConfig]
55
+ * @property {{stdio?: boolean, http?: boolean, sse?: boolean}} [capabilityPolicy.mcp]
56
+ * @property {Object} [sessionConfig]
57
+ * @property {ReadonlyArray<string>} [sessionConfig.additionalDirectories]
58
+ * @property {ReadonlyArray<Record<string, unknown>>} [sessionConfig.mcpServers]
59
+ * @property {"auto"|"load"|"resume"} [sessionConfig.resumeStrategy]
60
+ * @property {string} [sessionConfig.modeId]
61
+ * @property {Record<string, string|boolean>} [sessionConfig.configOptions]
62
+ * @property {Object} [clientCallbacks]
63
+ * @property {(request: AcpHostSessionPayload<import("@agentclientprotocol/sdk").RequestPermissionRequest>, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").RequestPermissionResponse>|import("@agentclientprotocol/sdk").RequestPermissionResponse} [clientCallbacks.requestPermission]
64
+ * @property {(request: AcpHostElicitationPayload, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").CreateElicitationResponse>|import("@agentclientprotocol/sdk").CreateElicitationResponse} [clientCallbacks.createElicitation]
65
+ * @property {(request: AcpHostSessionPayload<import("@agentclientprotocol/sdk").ReadTextFileRequest>, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").ReadTextFileResponse>|import("@agentclientprotocol/sdk").ReadTextFileResponse} [clientCallbacks.readTextFile]
66
+ * @property {(request: AcpHostSessionPayload<import("@agentclientprotocol/sdk").WriteTextFileRequest>, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").WriteTextFileResponse>|import("@agentclientprotocol/sdk").WriteTextFileResponse} [clientCallbacks.writeTextFile]
67
+ * @property {(request: AcpHostSessionPayload<import("@agentclientprotocol/sdk").CreateTerminalRequest>, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").CreateTerminalResponse>|import("@agentclientprotocol/sdk").CreateTerminalResponse} [clientCallbacks.createTerminal]
68
+ * @property {(request: AcpHostSessionPayload<import("@agentclientprotocol/sdk").TerminalOutputRequest>, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").TerminalOutputResponse>|import("@agentclientprotocol/sdk").TerminalOutputResponse} [clientCallbacks.terminalOutput]
69
+ * @property {(request: AcpHostSessionPayload<import("@agentclientprotocol/sdk").WaitForTerminalExitRequest>, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").WaitForTerminalExitResponse>|import("@agentclientprotocol/sdk").WaitForTerminalExitResponse} [clientCallbacks.waitForTerminalExit]
70
+ * @property {(request: AcpHostSessionPayload<import("@agentclientprotocol/sdk").KillTerminalRequest>, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").KillTerminalResponse>|import("@agentclientprotocol/sdk").KillTerminalResponse} [clientCallbacks.killTerminal]
71
+ * @property {(request: AcpHostSessionPayload<import("@agentclientprotocol/sdk").ReleaseTerminalRequest>, context: AcpCallbackContext) => Promise<import("@agentclientprotocol/sdk").ReleaseTerminalResponse>|import("@agentclientprotocol/sdk").ReleaseTerminalResponse} [clientCallbacks.releaseTerminal]
72
+ * @property {(notification: AcpHostSessionPayload<import("@agentclientprotocol/sdk").SessionNotification>, context: AcpCallbackContext) => Promise<void>|void} [clientCallbacks.sessionUpdate]
73
+ * @property {(notification: Omit<import("@agentclientprotocol/sdk").CompleteElicitationNotification, "_meta">, context: AcpCallbackContext) => Promise<void>|void} [clientCallbacks.elicitationComplete]
74
+ * @property {Object} [process]
75
+ * @property {number} [process.startupTimeoutMs]
76
+ * @property {number} [process.requestTimeoutMs]
77
+ * @property {number} [process.shutdownGraceMs]
78
+ * @property {number} [process.killGraceMs]
79
+ * @property {number} [process.stderrTailBytes]
80
+ * @property {number} [process.maxLineBytes]
81
+ * @property {Record<string, unknown>} [metadata] Opaque host metadata; never returned in diagnostics.
82
+ */
83
+
84
+ /**
85
+ * @typedef {Object} AcpCallbackContext
86
+ * @property {string} profileId
87
+ * @property {string} operation
88
+ * @property {string} [providerSessionId] Opaque profile-bound session handle when the callback is session-scoped.
89
+ * @property {AbortSignal} [signal]
90
+ * @property {unknown} [requestId]
91
+ * @property {Record<string, unknown>} [hostContext]
92
+ */
93
+
94
+ /** @template T @typedef {T extends unknown ? Omit<T, "sessionId"> : never} WithoutSessionId */
95
+ /** @template T @typedef {T extends unknown ? Omit<T, "sessionId"|"_meta"> : never} AcpHostSessionPayload */
96
+ /**
97
+ * @typedef {WithoutSessionId<import("@agentclientprotocol/sdk").CreateElicitationRequest>
98
+ * & Pick<import("@agentclientprotocol/sdk").CreateElicitationRequest, "message" | "mode">
99
+ * } AcpHostElicitationPayload
100
+ */
101
+ /**
102
+ * @typedef {Object} AcpListedSession
103
+ * @property {string} providerSessionId Opaque runtime handle for resume/delete.
104
+ * @property {string} cwd
105
+ * @property {ReadonlyArray<string>} [additionalDirectories]
106
+ * @property {string|null} [title]
107
+ * @property {string|null} [updatedAt]
108
+ */
109
+ /**
110
+ * @typedef {Object} AcpSessionListResult
111
+ * @property {string} profileId
112
+ * @property {AcpListedSession[]} sessions
113
+ * @property {string|null} nextCursor Opaque runtime cursor; pass it back unchanged.
114
+ */
115
+ /** @typedef {WithoutSessionId<import("@agentclientprotocol/sdk").RequestPermissionRequest>} AcpPermissionInteractionPayload */
116
+ /**
117
+ * `CreateElicitationRequest` includes a future-mode string index signature.
118
+ * Plain `Omit` preserves that openness but widens its named common fields to
119
+ * unknown, so restore the protocol's concrete common-field types explicitly.
120
+ * @typedef {WithoutSessionId<import("@agentclientprotocol/sdk").CreateElicitationRequest>
121
+ * & Pick<import("@agentclientprotocol/sdk").CreateElicitationRequest, "message" | "mode" | "_meta">} AcpElicitationInteractionPayload
122
+ */
123
+ /**
124
+ * @typedef {{kind: "permission", profileId: string, payload: AcpPermissionInteractionPayload}
125
+ * | {kind: "elicitation", profileId: string, payload: AcpElicitationInteractionPayload}} AcpInteractionRequest
126
+ */
127
+
128
+ /**
129
+ * @typedef {Object} AcpClientHostOptions
130
+ * @property {(profileId: string, context?: Record<string, unknown>) => Promise<AcpProfileDescriptor|null|undefined>|AcpProfileDescriptor|null|undefined} resolveAcpProfile
131
+ * @property {(request: AcpInteractionRequest, context?: AcpCallbackContext) => Promise<unknown>|unknown} [onAcpInteractionRequest]
132
+ * @property {import('../../agent/sandbox-seam.js').RuntimeSandbox} [sandbox]
133
+ * @property {import('../../agent/sandbox-seam.js').SandboxPolicy} [sandboxPolicy]
134
+ * @property {import('../../agent/sandbox-seam.js').RuntimeSandboxEngine} [sandboxEngine]
135
+ * @property {string} [cwd]
136
+ * @property {AbortSignal} [signal]
137
+ * @property {Record<string, unknown>} [context]
138
+ */
139
+
140
+ export {
141
+ AcpClientError,
142
+ encodeAcpProviderSessionId,
143
+ validateAcpProfileId,
144
+ validateAcpProviderSessionId,
145
+ };
146
+
147
+ /** @param {unknown} value @param {string} label @returns {string} */
148
+ function requiredString(value, label) {
149
+ if (typeof value !== "string" || value.trim() !== value || value.length === 0 || value.includes("\0")) {
150
+ throw new AcpClientError("invalid_profile", `${label} must be a non-empty trimmed string without NUL bytes.`);
151
+ }
152
+ return value;
153
+ }
154
+
155
+ /** @param {unknown} value @param {string} label @param {number} fallback @returns {number} */
156
+ function boundedInteger(value, label, fallback) {
157
+ if (value === undefined) return fallback;
158
+ if (!Number.isInteger(value) || Number(value) < 0 || Number(value) > 300_000) {
159
+ throw new AcpClientError("invalid_profile", `${label} must be an integer between 0 and 300000.`);
160
+ }
161
+ return Number(value);
162
+ }
163
+
164
+ /** @param {unknown} value @param {string} label @returns {string[]} */
165
+ function stringArray(value, label) {
166
+ if (value === undefined) return [];
167
+ if (!Array.isArray(value)) throw new AcpClientError("invalid_profile", `${label} must be an array.`);
168
+ return value.map((item) => requiredString(item, `${label} entry`));
169
+ }
170
+
171
+ /** @param {unknown} value @param {string} label @returns {Record<string, string>} */
172
+ function stringRecord(value, label) {
173
+ if (value === undefined) return {};
174
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
175
+ throw new AcpClientError("invalid_profile", `${label} must be an object of strings.`);
176
+ }
177
+ /** @type {Record<string, string>} */
178
+ const result = {};
179
+ for (const [key, item] of Object.entries(value)) {
180
+ requiredString(key, `${label} key`);
181
+ result[key] = requiredString(item, `${label}.${key}`);
182
+ }
183
+ return result;
184
+ }
185
+
186
+ /** @param {unknown} value @param {string} field @param {"client"|"agent"} fallback */
187
+ function ownership(value, field, fallback) {
188
+ if (value === undefined) return fallback;
189
+ if (!OWNERS.has(/** @type {any} */ (value))) {
190
+ throw new AcpClientError("invalid_profile", `${field} must be client or agent.`);
191
+ }
192
+ return /** @type {"client"|"agent"} */ (value);
193
+ }
194
+
195
+ /**
196
+ * @param {AcpProfileDescriptor} descriptor
197
+ * @returns {AcpProfileDescriptor & {args: string[], env: Record<string,string>, configurationOwner: "client"|"agent", workspaceOwner: "client"|"agent", mcpOwner: "client"|"agent", process: {startupTimeoutMs: number, requestTimeoutMs: number, shutdownGraceMs: number, killGraceMs: number, stderrTailBytes: number, maxLineBytes: number}}}
198
+ */
199
+ function normalizeProfile(descriptor) {
200
+ if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) {
201
+ throw new AcpClientError("invalid_profile", "ACP profile resolver must return an object.");
202
+ }
203
+ const command = requiredString(descriptor.command, "ACP profile command");
204
+ if (!isAbsolute(command)) {
205
+ throw new AcpClientError("invalid_profile", "ACP profile command must be absolute.");
206
+ }
207
+ const args = stringArray(descriptor.args, "ACP profile args");
208
+ const env = stringRecord(descriptor.env, "ACP profile env");
209
+ const configurationOwner = ownership(descriptor.configurationOwner, "configurationOwner", "client");
210
+ const workspaceOwner = ownership(descriptor.workspaceOwner, "workspaceOwner", "client");
211
+ const mcpOwner = ownership(descriptor.mcpOwner, "mcpOwner", "client");
212
+ const cwd = descriptor.cwd === undefined ? undefined : requiredString(descriptor.cwd, "ACP profile cwd");
213
+ if (cwd !== undefined && !isAbsolute(cwd)) {
214
+ throw new AcpClientError("invalid_profile", "ACP profile cwd must be absolute.");
215
+ }
216
+ const workspacePath = descriptor.workspacePath === undefined
217
+ ? undefined
218
+ : requiredString(descriptor.workspacePath, "ACP profile workspacePath");
219
+ if (workspacePath !== undefined && !isAbsolute(workspacePath)) {
220
+ throw new AcpClientError("invalid_profile", "ACP profile workspacePath must be absolute.");
221
+ }
222
+ if (workspaceOwner === "agent" && workspacePath === undefined) {
223
+ throw new AcpClientError("invalid_profile", "Agent-owned ACP workspaces require an absolute workspacePath.");
224
+ }
225
+ const sessionConfig = descriptor.sessionConfig === undefined ? {} : descriptor.sessionConfig;
226
+ if (!sessionConfig || typeof sessionConfig !== "object" || Array.isArray(sessionConfig)) {
227
+ throw new AcpClientError("invalid_profile", "ACP sessionConfig must be an object.");
228
+ }
229
+ if (sessionConfig.resumeStrategy !== undefined && !RESUME_STRATEGIES.has(sessionConfig.resumeStrategy)) {
230
+ throw new AcpClientError("invalid_profile", "ACP resumeStrategy must be auto, load, or resume.");
231
+ }
232
+ const processPolicy = descriptor.process || {};
233
+ if (!processPolicy || typeof processPolicy !== "object" || Array.isArray(processPolicy)) {
234
+ throw new AcpClientError("invalid_profile", "ACP process policy must be an object.");
235
+ }
236
+ const stderrTailBytes = boundedInteger(
237
+ processPolicy.stderrTailBytes,
238
+ "process.stderrTailBytes",
239
+ DEFAULT_PROCESS_POLICY.stderrTailBytes,
240
+ );
241
+ if (stderrTailBytes < 1024 || stderrTailBytes > 1024 * 1024) {
242
+ throw new AcpClientError("invalid_profile", "process.stderrTailBytes must be between 1024 and 1048576.");
243
+ }
244
+ let maxLineBytes;
245
+ try {
246
+ maxLineBytes = normalizeAcpMaxLineBytes(processPolicy.maxLineBytes);
247
+ } catch (error) {
248
+ throw asClientError(error);
249
+ }
250
+ return {
251
+ ...descriptor,
252
+ command,
253
+ args,
254
+ env,
255
+ cwd,
256
+ workspacePath,
257
+ configurationOwner,
258
+ workspaceOwner,
259
+ mcpOwner,
260
+ sessionConfig,
261
+ process: {
262
+ startupTimeoutMs: boundedInteger(
263
+ processPolicy.startupTimeoutMs,
264
+ "process.startupTimeoutMs",
265
+ DEFAULT_PROCESS_POLICY.startupTimeoutMs,
266
+ ),
267
+ requestTimeoutMs: boundedInteger(
268
+ processPolicy.requestTimeoutMs,
269
+ "process.requestTimeoutMs",
270
+ DEFAULT_PROCESS_POLICY.requestTimeoutMs,
271
+ ),
272
+ shutdownGraceMs: boundedInteger(
273
+ processPolicy.shutdownGraceMs,
274
+ "process.shutdownGraceMs",
275
+ DEFAULT_PROCESS_POLICY.shutdownGraceMs,
276
+ ),
277
+ killGraceMs: boundedInteger(
278
+ processPolicy.killGraceMs,
279
+ "process.killGraceMs",
280
+ DEFAULT_PROCESS_POLICY.killGraceMs,
281
+ ),
282
+ stderrTailBytes,
283
+ maxLineBytes,
284
+ },
285
+ };
286
+ }
287
+
288
+ /** @param {unknown} error @returns {AcpClientError} */
289
+ function asClientError(error) {
290
+ if (error instanceof AcpClientError) return error;
291
+ if (error instanceof AcpTransportError) {
292
+ return new AcpClientError("protocol", error.message, { transportCode: error.code });
293
+ }
294
+ const code = typeof /** @type {any} */ (error)?.code === "string"
295
+ ? /** @type {any} */ (error).code
296
+ : undefined;
297
+ return new AcpClientError(
298
+ code === "ENOENT" || code === "EACCES" ? "spawn" : "protocol",
299
+ code === "ENOENT"
300
+ ? "ACP profile command was not found."
301
+ : code === "EACCES"
302
+ ? "ACP profile command is not executable."
303
+ : "ACP operation failed.",
304
+ code ? { causeCode: code } : {},
305
+ );
306
+ }
307
+
308
+ /** @param {AbortSignal|undefined} signal */
309
+ function throwIfAborted(signal) {
310
+ if (!signal?.aborted) return;
311
+ throw new AcpClientError("cancelled", "ACP operation was cancelled.");
312
+ }
313
+
314
+ /** @param {Promise<any>} promise @param {number} timeoutMs @param {string} operation @param {() => void} [onTimeout] */
315
+ async function withTimeout(promise, timeoutMs, operation, onTimeout) {
316
+ if (timeoutMs === 0) return promise;
317
+ let timer;
318
+ const timeout = new Promise((_, reject) => {
319
+ timer = setTimeout(() => {
320
+ onTimeout?.();
321
+ reject(new AcpClientError("timeout", `ACP ${operation} timed out.`, { operation, timeoutMs }));
322
+ }, timeoutMs);
323
+ });
324
+ try {
325
+ return await Promise.race([promise, timeout]);
326
+ } finally {
327
+ clearTimeout(timer);
328
+ }
329
+ }
330
+
331
+ /** @param {import('node:child_process').ChildProcess} child */
332
+ function childTermination(child) {
333
+ return new Promise((resolve) => {
334
+ let settled = false;
335
+ const finish = (value) => {
336
+ if (settled) return;
337
+ settled = true;
338
+ resolve(value);
339
+ };
340
+ child.once("error", (error) => finish({ error }));
341
+ child.once("exit", (code, signal) => finish({ code, signal }));
342
+ });
343
+ }
344
+
345
+ /** @param {Promise<any>} exitPromise @param {number} timeoutMs */
346
+ async function waitForExit(exitPromise, timeoutMs) {
347
+ if (timeoutMs === 0) return null;
348
+ let timer;
349
+ try {
350
+ return await Promise.race([
351
+ exitPromise,
352
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); }),
353
+ ]);
354
+ } finally {
355
+ clearTimeout(timer);
356
+ }
357
+ }
358
+
359
+ /** @param {Promise<any>} promise @param {number} timeoutMs */
360
+ async function drainCancelledPrompt(promise, timeoutMs) {
361
+ if (timeoutMs === 0) {
362
+ throw new AcpClientError("cancelled", "ACP prompt was cancelled.");
363
+ }
364
+ let timer;
365
+ const timeout = new Promise((_, reject) => {
366
+ timer = setTimeout(() => {
367
+ reject(new AcpClientError("cancelled", "ACP prompt was cancelled."));
368
+ }, timeoutMs);
369
+ });
370
+ try {
371
+ return await Promise.race([promise, timeout]);
372
+ } finally {
373
+ clearTimeout(timer);
374
+ }
375
+ }
376
+
377
+ /** @param {unknown} value @returns {Record<string, unknown>|undefined} */
378
+ function hostContext(value) {
379
+ return value && typeof value === "object" && !Array.isArray(value)
380
+ ? /** @type {Record<string, unknown>} */ (value)
381
+ : undefined;
382
+ }
383
+
384
+ /**
385
+ * @param {string} profileId
386
+ * @param {AcpClientHostOptions & {operation?: string}} options
387
+ */
388
+ async function resolveProfile(profileId, options) {
389
+ if (typeof options?.resolveAcpProfile !== "function") {
390
+ throw new AcpClientError("profile_resolver_missing", "resolveAcpProfile is required.");
391
+ }
392
+ const descriptor = await options.resolveAcpProfile(profileId, {
393
+ ...hostContext(options.context),
394
+ operation: options.operation || "connect",
395
+ profileId,
396
+ cwd: options.cwd,
397
+ });
398
+ if (descriptor == null) {
399
+ throw new AcpClientError("profile_not_found", `ACP profile '${profileId}' was not found.`, { profileId });
400
+ }
401
+ return normalizeProfile(descriptor);
402
+ }
403
+
404
+ /** @param {any} descriptor */
405
+ function clientCapabilities(descriptor) {
406
+ const policy = descriptor.capabilityPolicy || {};
407
+ const fs = policy.filesystem || {};
408
+ const terminalCallbacks = [
409
+ "createTerminal",
410
+ "terminalOutput",
411
+ "waitForTerminalExit",
412
+ "killTerminal",
413
+ "releaseTerminal",
414
+ ];
415
+ if (policy.terminal === true) {
416
+ for (const name of terminalCallbacks) {
417
+ if (typeof descriptor.clientCallbacks?.[name] !== "function") {
418
+ throw new AcpClientError("invalid_profile", `ACP terminal capability requires clientCallbacks.${name}.`);
419
+ }
420
+ }
421
+ }
422
+ if (fs.readTextFile === true && typeof descriptor.clientCallbacks?.readTextFile !== "function") {
423
+ throw new AcpClientError("invalid_profile", "ACP readTextFile capability requires a callback.");
424
+ }
425
+ if (fs.writeTextFile === true && typeof descriptor.clientCallbacks?.writeTextFile !== "function") {
426
+ throw new AcpClientError("invalid_profile", "ACP writeTextFile capability requires a callback.");
427
+ }
428
+ const capabilities = {
429
+ fs: {
430
+ readTextFile: fs.readTextFile === true,
431
+ writeTextFile: fs.writeTextFile === true,
432
+ },
433
+ terminal: policy.terminal === true,
434
+ };
435
+ if (policy.sessionConfig?.boolean === true) {
436
+ capabilities.session = { configOptions: { boolean: {} } };
437
+ }
438
+ if (policy.auth?.terminal === true) capabilities.auth = { terminal: true };
439
+ if (policy.elicitation?.form === true || policy.elicitation?.url === true) {
440
+ capabilities.elicitation = {
441
+ ...(policy.elicitation.form === true ? { form: {} } : {}),
442
+ ...(policy.elicitation.url === true ? { url: {} } : {}),
443
+ };
444
+ }
445
+ return capabilities;
446
+ }
447
+
448
+ /** @param {any} value @param {any[]} options @returns {any} */
449
+ function permissionResponse(value, options) {
450
+ const outcome = value?.outcome;
451
+ if (outcome?.outcome === "selected") {
452
+ const offered = options.some((option) => option?.optionId === outcome.optionId);
453
+ if (offered) return { outcome: { outcome: "selected", optionId: outcome.optionId } };
454
+ }
455
+ return { outcome: { outcome: "cancelled" } };
456
+ }
457
+
458
+ /** @param {any} value */
459
+ function elicitationResponse(value) {
460
+ if (value?.action === "accept") {
461
+ return value.content === undefined
462
+ ? { action: "accept" }
463
+ : { action: "accept", content: value.content };
464
+ }
465
+ if (value?.action === "decline") return { action: "decline" };
466
+ return { action: "cancel" };
467
+ }
468
+
469
+ /**
470
+ * The protocol session id is private connection state. Hosts answer through
471
+ * the pending callback, so exposing the raw id in UI-facing interaction
472
+ * payloads adds persistence risk without enabling any supported response.
473
+ * Low-level descriptor callbacks still receive the native SDK request.
474
+ * @template {object} T
475
+ * @param {T} value
476
+ * @returns {WithoutSessionId<T>}
477
+ */
478
+ function hostInteractionPayload(value) {
479
+ return /** @type {WithoutSessionId<T>} */ (
480
+ sanitizeAcpHostValue(value, [/** @type {T & {sessionId?: unknown}} */ (value).sessionId])
481
+ );
482
+ }
483
+
484
+ /**
485
+ * Preserve the named common fields that TypeScript widens through ACP's open
486
+ * future-mode index signature.
487
+ * @param {import("@agentclientprotocol/sdk").CreateElicitationRequest} value
488
+ * @returns {AcpElicitationInteractionPayload}
489
+ */
490
+ function hostElicitationPayload(value) {
491
+ return /** @type {AcpElicitationInteractionPayload} */ (hostInteractionPayload(value));
492
+ }
493
+
494
+ /** @template {object} T @param {T & {sessionId: string}} value */
495
+ function descriptorSessionPayload(value) {
496
+ return /** @type {AcpHostSessionPayload<T>} */ (sanitizeAcpHostValue(value, [value.sessionId]));
497
+ }
498
+
499
+ /** @param {unknown} value */
500
+ function callbackSessionId(value) {
501
+ const candidate = /** @type {{sessionId?: unknown}} */ (value);
502
+ return typeof candidate?.sessionId === "string" ? candidate.sessionId : undefined;
503
+ }
504
+
505
+ /** @param {import("@agentclientprotocol/sdk").CreateElicitationRequest} value */
506
+ function descriptorElicitationPayload(value) {
507
+ const rawSessionId = callbackSessionId(value);
508
+ return /** @type {AcpHostElicitationPayload} */ (sanitizeAcpHostValue(value, [rawSessionId]));
509
+ }
510
+
511
+ /** @param {any} descriptor @param {AcpClientHostOptions} options @param {string} profileId @param {string} operation */
512
+ function callbackContext(descriptor, options, profileId, operation, extra = {}) {
513
+ return {
514
+ profileId,
515
+ operation,
516
+ hostContext: hostContext(options.context),
517
+ ...extra,
518
+ };
519
+ }
520
+
521
+ /**
522
+ * Open and initialize one owned ACP v1 stdio bridge process.
523
+ * @param {string} profileId
524
+ * @param {AcpClientHostOptions & {operation?: string}} options
525
+ */
526
+ export async function connectAcpProfile(profileId, options) {
527
+ validateAcpProfileId(profileId);
528
+ throwIfAborted(options?.signal);
529
+ const operation = options?.operation || "connect";
530
+ const descriptor = await resolveProfile(profileId, { ...options, operation });
531
+ throwIfAborted(options?.signal);
532
+ const capabilities = clientCapabilities(descriptor);
533
+ const sandbox = options.sandbox || passthroughSandbox;
534
+ const commandCwd = descriptor.cwd || descriptor.workspacePath || options.cwd || process.cwd();
535
+ if (typeof commandCwd !== "string" || !isAbsolute(commandCwd)) {
536
+ throw new AcpClientError("invalid_profile", "ACP child cwd must be absolute.");
537
+ }
538
+ let prepared;
539
+ try {
540
+ prepared = await sandbox.prepareCommand({
541
+ policy: options.sandboxPolicy,
542
+ engine: options.sandboxEngine,
543
+ command: {
544
+ command: descriptor.command,
545
+ args: descriptor.args,
546
+ cwd: commandCwd,
547
+ env: descriptor.env,
548
+ },
549
+ });
550
+ } catch (error) {
551
+ throw asClientError(error);
552
+ }
553
+ if (options?.signal?.aborted) {
554
+ try {
555
+ await prepared.cleanup?.();
556
+ } finally {
557
+ throwIfAborted(options.signal);
558
+ }
559
+ }
560
+
561
+ const child = spawn(prepared.command, [...(prepared.args || [])], {
562
+ cwd: prepared.cwd || commandCwd,
563
+ env: { ...(prepared.env || {}) },
564
+ shell: false,
565
+ stdio: ["pipe", "pipe", "pipe"],
566
+ windowsHide: true,
567
+ });
568
+ const stderrTail = createStderrTail({ limit: descriptor.process.stderrTailBytes });
569
+ child.stderr?.on("data", (chunk) => stderrTail.push(chunk));
570
+ const exitPromise = childTermination(child);
571
+ const updates = new Map();
572
+ const activePromptSessions = new Set();
573
+ let hostRequestSequence = 0;
574
+ /** @param {AcpCallbackContext} context @param {unknown} [rawSessionId] */
575
+ const safeCallbackContext = (context, rawSessionId) => ({
576
+ ...context,
577
+ ...(typeof rawSessionId === "string"
578
+ ? { providerSessionId: encodeAcpProviderSessionId(profileId, rawSessionId) }
579
+ : {}),
580
+ ...(context.requestId === undefined
581
+ ? {}
582
+ : { requestId: `acp-request:${profileId}:${++hostRequestSequence}` }),
583
+ });
584
+ let closed = false;
585
+
586
+ const app = client({ name: "mono-agent-agent-runtime-acp" });
587
+ app.onRequest(methods.client.session.requestPermission, async (ctx) => {
588
+ let result;
589
+ try {
590
+ const context = callbackContext(descriptor, options, profileId, "permission", {
591
+ signal: ctx.signal,
592
+ requestId: ctx.requestId,
593
+ });
594
+ if (typeof descriptor.clientCallbacks?.requestPermission === "function") {
595
+ result = await descriptor.clientCallbacks.requestPermission(
596
+ descriptorSessionPayload(ctx.params),
597
+ safeCallbackContext(context, ctx.params.sessionId),
598
+ );
599
+ } else if (typeof options.onAcpInteractionRequest === "function") {
600
+ result = await options.onAcpInteractionRequest(
601
+ { kind: "permission", profileId, payload: hostInteractionPayload(ctx.params) },
602
+ safeCallbackContext(context, ctx.params.sessionId),
603
+ );
604
+ }
605
+ } catch {
606
+ result = null;
607
+ }
608
+ return permissionResponse(result, ctx.params.options || []);
609
+ });
610
+ app.onNotification(methods.client.session.update, async (ctx) => {
611
+ const listeners = updates.get(ctx.params.sessionId);
612
+ if (listeners) {
613
+ for (const listener of [...listeners]) {
614
+ try { await listener(ctx.params); } catch { /* observer callbacks do not break the protocol */ }
615
+ }
616
+ }
617
+ try {
618
+ await descriptor.clientCallbacks?.sessionUpdate?.(
619
+ descriptorSessionPayload(ctx.params),
620
+ safeCallbackContext(
621
+ callbackContext(descriptor, options, profileId, "session_update", { signal: ctx.signal }),
622
+ ctx.params.sessionId,
623
+ ),
624
+ );
625
+ } catch { /* profile notification callbacks are observational */ }
626
+ });
627
+ if (descriptor.capabilityPolicy?.filesystem?.readTextFile === true) {
628
+ app.onRequest(methods.client.fs.readTextFile, (ctx) => descriptor.clientCallbacks.readTextFile(
629
+ descriptorSessionPayload(ctx.params),
630
+ safeCallbackContext(
631
+ callbackContext(descriptor, options, profileId, "read_text_file", { signal: ctx.signal, requestId: ctx.requestId }),
632
+ ctx.params.sessionId,
633
+ ),
634
+ ));
635
+ }
636
+ if (descriptor.capabilityPolicy?.filesystem?.writeTextFile === true) {
637
+ app.onRequest(methods.client.fs.writeTextFile, (ctx) => descriptor.clientCallbacks.writeTextFile(
638
+ descriptorSessionPayload(ctx.params),
639
+ safeCallbackContext(
640
+ callbackContext(descriptor, options, profileId, "write_text_file", { signal: ctx.signal, requestId: ctx.requestId }),
641
+ ctx.params.sessionId,
642
+ ),
643
+ ));
644
+ }
645
+ if (descriptor.capabilityPolicy?.terminal === true) {
646
+ const terminalHandlers = [
647
+ [methods.client.terminal.create, "createTerminal", "terminal_create"],
648
+ [methods.client.terminal.output, "terminalOutput", "terminal_output"],
649
+ [methods.client.terminal.waitForExit, "waitForTerminalExit", "terminal_wait_for_exit"],
650
+ [methods.client.terminal.kill, "killTerminal", "terminal_kill"],
651
+ [methods.client.terminal.release, "releaseTerminal", "terminal_release"],
652
+ ];
653
+ for (const [method, callback, callbackOperation] of terminalHandlers) {
654
+ app.onRequest(/** @type {any} */ (method), (ctx) => descriptor.clientCallbacks[callback](
655
+ descriptorSessionPayload(ctx.params),
656
+ safeCallbackContext(
657
+ callbackContext(descriptor, options, profileId, callbackOperation, { signal: ctx.signal, requestId: ctx.requestId }),
658
+ ctx.params.sessionId,
659
+ ),
660
+ ));
661
+ }
662
+ }
663
+ if (descriptor.capabilityPolicy?.elicitation?.form === true || descriptor.capabilityPolicy?.elicitation?.url === true) {
664
+ app.onRequest(methods.client.elicitation.create, async (ctx) => {
665
+ let result;
666
+ try {
667
+ const context = callbackContext(descriptor, options, profileId, "elicitation", {
668
+ signal: ctx.signal,
669
+ requestId: ctx.requestId,
670
+ });
671
+ if (typeof descriptor.clientCallbacks?.createElicitation === "function") {
672
+ result = await descriptor.clientCallbacks.createElicitation(
673
+ descriptorElicitationPayload(ctx.params),
674
+ safeCallbackContext(context, callbackSessionId(ctx.params)),
675
+ );
676
+ } else if (typeof options.onAcpInteractionRequest === "function") {
677
+ result = await options.onAcpInteractionRequest(
678
+ { kind: "elicitation", profileId, payload: hostElicitationPayload(ctx.params) },
679
+ safeCallbackContext(context, callbackSessionId(ctx.params)),
680
+ );
681
+ }
682
+ } catch {
683
+ result = null;
684
+ }
685
+ return elicitationResponse(result);
686
+ });
687
+ app.onNotification(methods.client.elicitation.complete, async (ctx) => {
688
+ try {
689
+ await descriptor.clientCallbacks?.elicitationComplete?.(
690
+ sanitizeAcpHostValue(ctx.params),
691
+ safeCallbackContext(callbackContext(descriptor, options, profileId, "elicitation_complete", { signal: ctx.signal })),
692
+ );
693
+ } catch { /* observational */ }
694
+ });
695
+ }
696
+
697
+ let connection;
698
+ try {
699
+ connection = app.connect(createBoundedAcpStdioStream(child, {
700
+ maxLineBytes: descriptor.process.maxLineBytes,
701
+ }));
702
+ } catch (error) {
703
+ child.kill("SIGTERM");
704
+ const exited = await waitForExit(exitPromise, descriptor.process.killGraceMs);
705
+ if (!exited) {
706
+ child.kill("SIGKILL");
707
+ await waitForExit(exitPromise, descriptor.process.killGraceMs);
708
+ }
709
+ await prepared.cleanup?.();
710
+ throw asClientError(error);
711
+ }
712
+ connection.closed.catch(() => {});
713
+ const context = connection.agent;
714
+
715
+ const processEnded = () => exitPromise.then((termination) => {
716
+ const error = termination?.error;
717
+ if (error) throw asClientError(error);
718
+ throw new AcpClientError("process_exited", "ACP bridge process exited unexpectedly.", {
719
+ exitCode: termination?.code ?? null,
720
+ signal: termination?.signal ?? null,
721
+ stderrBytes: Buffer.byteLength(stderrTail.toString(), "utf8"),
722
+ stderrTruncated: stderrTail.bytesDropped > 0,
723
+ });
724
+ });
725
+
726
+ /** @param {string} method @param {any} params @param {{timeoutMs?: number, signal?: AbortSignal, label?: string}} [requestOptions] */
727
+ const request = async (method, params, requestOptions = {}) => {
728
+ if (closed) throw new AcpClientError("closed", "ACP client is closed.");
729
+ const controller = new AbortController();
730
+ const externalSignal = requestOptions.signal || options.signal;
731
+ const abort = () => controller.abort(externalSignal?.reason);
732
+ externalSignal?.addEventListener("abort", abort, { once: true });
733
+ if (externalSignal?.aborted) abort();
734
+ try {
735
+ const pending = context.request(/** @type {any} */ (method), params, {
736
+ cancellationSignal: controller.signal,
737
+ });
738
+ return await withTimeout(
739
+ Promise.race([pending, processEnded()]),
740
+ requestOptions.timeoutMs ?? descriptor.process.requestTimeoutMs,
741
+ requestOptions.label || method,
742
+ () => controller.abort(new Error("timeout")),
743
+ );
744
+ } catch (error) {
745
+ throw asClientError(error);
746
+ } finally {
747
+ externalSignal?.removeEventListener("abort", abort);
748
+ }
749
+ };
750
+
751
+ let initializeResult;
752
+ try {
753
+ initializeResult = await request(methods.agent.initialize, {
754
+ protocolVersion: PROTOCOL_VERSION,
755
+ clientCapabilities: capabilities,
756
+ clientInfo: {
757
+ name: "mono-agent-agent-runtime-acp",
758
+ title: "mono-agent ACP runtime client",
759
+ version: "1.0.0",
760
+ },
761
+ }, { timeoutMs: descriptor.process.startupTimeoutMs, label: "initialize" });
762
+ if (initializeResult.protocolVersion !== PROTOCOL_VERSION) {
763
+ throw new AcpClientError(
764
+ "protocol_version",
765
+ "ACP agent selected an unsupported protocol version.",
766
+ { expected: PROTOCOL_VERSION, received: initializeResult.protocolVersion },
767
+ );
768
+ }
769
+ } catch (error) {
770
+ connection.close(error);
771
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
772
+ const exited = await waitForExit(exitPromise, descriptor.process.killGraceMs);
773
+ if (!exited) {
774
+ child.kill("SIGKILL");
775
+ await waitForExit(exitPromise, descriptor.process.killGraceMs);
776
+ }
777
+ await prepared.cleanup?.();
778
+ throw asClientError(error);
779
+ }
780
+
781
+ const agentCaps = initializeResult.agentCapabilities || {};
782
+ const sessionCaps = agentCaps.sessionCapabilities || {};
783
+ const hasCapability = (name) => {
784
+ if (name === "load") return agentCaps.loadSession === true;
785
+ if (name === "logout") return agentCaps.auth?.logout != null;
786
+ return sessionCaps[name] != null;
787
+ };
788
+ const requireCapability = (name, method) => {
789
+ if (!hasCapability(name)) {
790
+ throw new AcpClientError("capability_missing", `ACP agent did not advertise ${method}.`, {
791
+ capability: name,
792
+ });
793
+ }
794
+ };
795
+
796
+ const close = async () => {
797
+ if (closed) return;
798
+ closed = true;
799
+ for (const sessionId of [...activePromptSessions]) {
800
+ try { await context.notify(methods.agent.session.cancel, { sessionId }); } catch { /* closing */ }
801
+ }
802
+ connection.close();
803
+ if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) child.stdin.end();
804
+ let exited = child.exitCode !== null || child.signalCode !== null
805
+ ? true
806
+ : Boolean(await waitForExit(exitPromise, descriptor.process.shutdownGraceMs));
807
+ if (!exited) {
808
+ child.kill("SIGTERM");
809
+ exited = Boolean(await waitForExit(exitPromise, descriptor.process.killGraceMs));
810
+ }
811
+ if (!exited) {
812
+ child.kill("SIGKILL");
813
+ await waitForExit(exitPromise, descriptor.process.killGraceMs);
814
+ }
815
+ await prepared.cleanup?.();
816
+ };
817
+
818
+ const addUpdateListener = (sessionId, listener) => {
819
+ const listeners = updates.get(sessionId) || new Set();
820
+ listeners.add(listener);
821
+ updates.set(sessionId, listeners);
822
+ return () => {
823
+ listeners.delete(listener);
824
+ if (listeners.size === 0) updates.delete(sessionId);
825
+ };
826
+ };
827
+
828
+ return {
829
+ profileId,
830
+ descriptor,
831
+ initializeResult,
832
+ clientCapabilities: capabilities,
833
+ hasCapability,
834
+ onSessionUpdate: addUpdateListener,
835
+ request,
836
+ async authenticate(methodId) {
837
+ const advertised = (initializeResult.authMethods || []).some((method) => method?.id === methodId);
838
+ if (!advertised) {
839
+ throw new AcpClientError("capability_missing", "ACP authentication method was not advertised.", { methodId });
840
+ }
841
+ return request(methods.agent.authenticate, { methodId });
842
+ },
843
+ async logout() {
844
+ requireCapability("logout", "logout");
845
+ return request(methods.agent.logout, {});
846
+ },
847
+ async newSession(params) {
848
+ return request(methods.agent.session.new, validateSessionRequest(params, descriptor, initializeResult));
849
+ },
850
+ async loadSession(params) {
851
+ const requestParams = validateSessionRequest(params, descriptor, initializeResult);
852
+ requireCapability("load", "session/load");
853
+ return request(methods.agent.session.load, requestParams);
854
+ },
855
+ async resumeSession(params) {
856
+ const requestParams = validateSessionRequest(params, descriptor, initializeResult);
857
+ requireCapability("resume", "session/resume");
858
+ return request(methods.agent.session.resume, requestParams);
859
+ },
860
+ async closeSession(sessionId) {
861
+ requireCapability("close", "session/close");
862
+ return request(methods.agent.session.close, { sessionId });
863
+ },
864
+ async listSessions(params = {}) {
865
+ const requestParams = validateSessionListRequest(params);
866
+ requireCapability("list", "session/list");
867
+ return request(methods.agent.session.list, requestParams);
868
+ },
869
+ async deleteSession(sessionId) {
870
+ requireCapability("delete", "session/delete");
871
+ return request(methods.agent.session.delete, { sessionId });
872
+ },
873
+ async setSessionMode(sessionId, modeId) {
874
+ return request(methods.agent.session.setMode, { sessionId, modeId });
875
+ },
876
+ async setSessionConfigOption(sessionId, configId, value) {
877
+ return request(methods.agent.session.setConfigOption, {
878
+ sessionId,
879
+ configId,
880
+ value,
881
+ ...(typeof value === "boolean" ? { type: "boolean" } : {}),
882
+ });
883
+ },
884
+ async cancel(sessionId) {
885
+ await context.notify(methods.agent.session.cancel, { sessionId });
886
+ },
887
+ async prompt(sessionId, prompt, promptOptions = {}) {
888
+ const signal = promptOptions.signal;
889
+ if (signal?.aborted) {
890
+ await context.notify(methods.agent.session.cancel, { sessionId }).catch(() => {});
891
+ throw new AcpClientError("cancelled", "ACP prompt was cancelled.");
892
+ }
893
+ const remove = typeof promptOptions.onUpdate === "function"
894
+ ? addUpdateListener(sessionId, promptOptions.onUpdate)
895
+ : () => {};
896
+ activePromptSessions.add(sessionId);
897
+ const abortedMarker = Symbol("ACP prompt aborted");
898
+ let resolveAborted;
899
+ const aborted = new Promise((resolve) => { resolveAborted = resolve; });
900
+ const onAbort = () => {
901
+ context.notify(methods.agent.session.cancel, { sessionId }).catch(() => {});
902
+ resolveAborted(abortedMarker);
903
+ };
904
+ signal?.addEventListener("abort", onAbort, { once: true });
905
+ try {
906
+ const pending = request(methods.agent.session.prompt, { sessionId, prompt }, {
907
+ signal,
908
+ timeoutMs: promptOptions.timeoutMs,
909
+ label: "session/prompt",
910
+ });
911
+ const outcome = await Promise.race([pending, aborted]);
912
+ if (outcome !== abortedMarker) return outcome;
913
+
914
+ // The SDK cancellation signal emits $/cancel_request but intentionally
915
+ // leaves the request pending. Keep the connection and update listener
916
+ // alive for a bounded grace period so the peer can acknowledge
917
+ // session/cancel, emit final updates, and return its PromptResponse.
918
+ try {
919
+ return await drainCancelledPrompt(pending, descriptor.process.shutdownGraceMs);
920
+ } catch {
921
+ throw new AcpClientError("cancelled", "ACP prompt was cancelled.");
922
+ }
923
+ } finally {
924
+ signal?.removeEventListener("abort", onAbort);
925
+ activePromptSessions.delete(sessionId);
926
+ remove();
927
+ }
928
+ },
929
+ close,
930
+ };
931
+ }
932
+
933
+ /** @param {any} params @param {any} descriptor @param {any} initializeResult */
934
+ function validateSessionRequest(params, descriptor, initializeResult) {
935
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
936
+ throw new AcpClientError("invalid_request", "ACP session request must be an object.");
937
+ }
938
+ if (typeof params.cwd !== "string" || !isAbsolute(params.cwd)) {
939
+ throw new AcpClientError("invalid_request", "ACP session cwd must be absolute.");
940
+ }
941
+ const additionalDirectories = params.additionalDirectories === undefined
942
+ ? []
943
+ : params.additionalDirectories;
944
+ if (!Array.isArray(additionalDirectories)
945
+ || additionalDirectories.some((value) => typeof value !== "string" || !isAbsolute(value))) {
946
+ throw new AcpClientError("invalid_request", "ACP additionalDirectories must contain absolute paths.");
947
+ }
948
+ if (additionalDirectories.length > 0
949
+ && initializeResult.agentCapabilities?.sessionCapabilities?.additionalDirectories == null) {
950
+ throw new AcpClientError("capability_missing", "ACP agent did not advertise additionalDirectories.");
951
+ }
952
+ const mcpServers = validateMcpServers(
953
+ params.mcpServers === undefined ? [] : params.mcpServers,
954
+ descriptor,
955
+ initializeResult,
956
+ );
957
+ return { ...params, additionalDirectories, mcpServers };
958
+ }
959
+
960
+ /** @param {any} params */
961
+ function validateSessionListRequest(params) {
962
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
963
+ throw new AcpClientError("invalid_request", "ACP session/list request must be an object.");
964
+ }
965
+ if (params.cwd != null
966
+ && (typeof params.cwd !== "string" || !isAbsolute(params.cwd))) {
967
+ throw new AcpClientError("invalid_request", "ACP session/list cwd must be absolute.");
968
+ }
969
+ return params;
970
+ }
971
+
972
+ /** @param {any[]} servers @param {any} descriptor @param {any} initializeResult */
973
+ function validateMcpServers(servers, descriptor, initializeResult) {
974
+ if (!Array.isArray(servers)) throw new AcpClientError("invalid_request", "ACP mcpServers must be an array.");
975
+ if (descriptor.mcpOwner === "agent") {
976
+ if (servers.length > 0) {
977
+ throw new AcpClientError("ownership_conflict", "Agent-owned MCP configuration cannot receive client MCP servers.");
978
+ }
979
+ return [];
980
+ }
981
+ const policy = descriptor.capabilityPolicy?.mcp || {};
982
+ const agentMcp = initializeResult.agentCapabilities?.mcpCapabilities || {};
983
+ return servers.map((server) => {
984
+ if (!server || typeof server !== "object" || Array.isArray(server)) {
985
+ throw new AcpClientError("invalid_request", "ACP MCP server entry must be an object.");
986
+ }
987
+ const type = server.type || "stdio";
988
+ if (type === "stdio") {
989
+ if (policy.stdio !== true) throw new AcpClientError("capability_missing", "ACP stdio MCP is disabled by profile policy.");
990
+ if (typeof server.command !== "string" || !isAbsolute(server.command)) {
991
+ throw new AcpClientError("invalid_request", "ACP stdio MCP command must be absolute.");
992
+ }
993
+ stringArray(server.args, "ACP MCP args");
994
+ if (!Array.isArray(server.env)) throw new AcpClientError("invalid_request", "ACP MCP env must be an array.");
995
+ return server;
996
+ }
997
+ if (type === "http") {
998
+ if (policy.http !== true || agentMcp.http !== true) {
999
+ throw new AcpClientError("capability_missing", "ACP HTTP MCP was not mutually enabled.");
1000
+ }
1001
+ return server;
1002
+ }
1003
+ if (type === "sse") {
1004
+ if (policy.sse !== true || agentMcp.sse !== true) {
1005
+ throw new AcpClientError("capability_missing", "ACP SSE MCP was not mutually enabled.");
1006
+ }
1007
+ return server;
1008
+ }
1009
+ throw new AcpClientError("capability_missing", "Unsupported ACP MCP transport.");
1010
+ });
1011
+ }
1012
+
1013
+ /** @param {string} profileId @param {any} request */
1014
+ function protocolSessionListRequest(profileId, request) {
1015
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
1016
+ throw new AcpClientError("invalid_request", "ACP session/list request must be an object.");
1017
+ }
1018
+ const { cursor, _meta: _meta, ...rest } = request;
1019
+ return {
1020
+ ...rest,
1021
+ ...(cursor == null ? {} : { cursor: decodeAcpSessionCursor(profileId, cursor) }),
1022
+ };
1023
+ }
1024
+
1025
+ /** Remove extension metadata recursively from operation results. @param {any} value */
1026
+ function withoutMeta(value) {
1027
+ if (Array.isArray(value)) return value.map(withoutMeta);
1028
+ if (!value || typeof value !== "object") return value;
1029
+ const result = {};
1030
+ for (const [key, item] of Object.entries(value)) {
1031
+ if (key !== "_meta") result[key] = withoutMeta(item);
1032
+ }
1033
+ return result;
1034
+ }
1035
+
1036
+ /** @param {any} initializeResult @param {string} profileId */
1037
+ function probeResult(initializeResult, profileId) {
1038
+ return {
1039
+ profileId,
1040
+ protocolVersion: initializeResult.protocolVersion,
1041
+ agentInfo: initializeResult.agentInfo
1042
+ ? withoutMeta(initializeResult.agentInfo)
1043
+ : null,
1044
+ agentCapabilities: withoutMeta(initializeResult.agentCapabilities || {}),
1045
+ authMethods: (initializeResult.authMethods || []).map((method) => ({
1046
+ id: method.id,
1047
+ name: method.name,
1048
+ type: method.type || "agent",
1049
+ })),
1050
+ };
1051
+ }
1052
+
1053
+ /** @param {string} profileId @param {AcpClientHostOptions} options */
1054
+ export async function probeAcpProfile(profileId, options) {
1055
+ const connection = await connectAcpProfile(profileId, { ...options, operation: "probe" });
1056
+ try {
1057
+ return probeResult(connection.initializeResult, profileId);
1058
+ } finally {
1059
+ await connection.close();
1060
+ }
1061
+ }
1062
+
1063
+ /** @param {string} profileId @param {string} methodId @param {AcpClientHostOptions} options */
1064
+ export async function authenticateAcpProfile(profileId, methodId, options) {
1065
+ requiredString(methodId, "ACP authentication method id");
1066
+ const connection = await connectAcpProfile(profileId, { ...options, operation: "authenticate" });
1067
+ try {
1068
+ await connection.authenticate(methodId);
1069
+ return { profileId, methodId, authenticated: true };
1070
+ } finally {
1071
+ await connection.close();
1072
+ }
1073
+ }
1074
+
1075
+ /** @param {string} profileId @param {AcpClientHostOptions} options */
1076
+ export async function logoutAcpProfile(profileId, options) {
1077
+ const connection = await connectAcpProfile(profileId, { ...options, operation: "logout" });
1078
+ try {
1079
+ await connection.logout();
1080
+ return { profileId, loggedOut: true };
1081
+ } finally {
1082
+ await connection.close();
1083
+ }
1084
+ }
1085
+
1086
+ /**
1087
+ * @param {string} profileId
1088
+ * @param {{cwd?: string|null, cursor?: string|null}} [request]
1089
+ * @param {AcpClientHostOptions} [options]
1090
+ * @returns {Promise<AcpSessionListResult>}
1091
+ */
1092
+ export async function listAcpSessions(profileId, request = {}, options = /** @type {any} */ ({})) {
1093
+ const protocolRequest = protocolSessionListRequest(profileId, request);
1094
+ const connection = await connectAcpProfile(profileId, { ...options, operation: "list_sessions" });
1095
+ try {
1096
+ const result = await connection.listSessions(protocolRequest);
1097
+ return {
1098
+ profileId,
1099
+ sessions: (result.sessions || []).map((session) => ({
1100
+ ...sanitizeAcpHostValue(session, [session.sessionId, result.nextCursor]),
1101
+ providerSessionId: encodeAcpProviderSessionId(profileId, session.sessionId),
1102
+ })),
1103
+ nextCursor: typeof result.nextCursor === "string"
1104
+ ? encodeAcpSessionCursor(profileId, result.nextCursor)
1105
+ : null,
1106
+ };
1107
+ } finally {
1108
+ await connection.close();
1109
+ }
1110
+ }
1111
+
1112
+ /** @param {string} providerSessionId @param {AcpClientHostOptions} options */
1113
+ export async function deleteAcpSession(providerSessionId, options) {
1114
+ const { profileId, sessionId } = decodeAcpProviderSessionId(providerSessionId);
1115
+ const connection = await connectAcpProfile(profileId, { ...options, operation: "delete_session" });
1116
+ try {
1117
+ await connection.deleteSession(sessionId);
1118
+ return { profileId, providerSessionId, deleted: true };
1119
+ } finally {
1120
+ await connection.close();
1121
+ }
1122
+ }
1123
+
1124
+ export { PROTOCOL_VERSION as ACP_PROTOCOL_VERSION };