@anureo/adapter-acp 0.0.0-stage → 0.3.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/src/adapter.js ADDED
@@ -0,0 +1,944 @@
1
+ // Negotiates ACP and coordinates native requests, notifications and session lifecycles.
2
+ import { randomUUID } from 'node:crypto';
3
+ import * as acp from '@agentclientprotocol/sdk';
4
+ import { HarnessSdkError, sdkError, waitForRequest, } from '@anureo/sdk';
5
+ import { AHP_PROTOCOL_CONTRACT, assertRequest, negotiateVersion, assertResult, assertDefinition, } from '@anureo/protocol';
6
+ import { AcpUpdateMapper, record, safe, input, mcp, configuration, resolveConfigValue } from "./mapping.js";
7
+ import { acpFileChanges } from "./file-changes.js";
8
+ import { errorFromNative } from "./request-error.js";
9
+ import { extensions } from "./extensions.js";
10
+ import { processStream } from "./stdio.js";
11
+ import { stopOutcome } from "./stop.js";
12
+ /** Connect an ACP agent to the SDK adapter port. Hosts normally pass a factory result to createClient,
13
+ * which orders initialization, session acquisition and disposal through its output wrapper.
14
+ * Direct adapter users must initialize before opening and await disposal even after setup failure. */
15
+ export class AcpAdapter {
16
+ options;
17
+ /** Construct the adapter without I/O; initialize acquires the transport and negotiates capabilities. */
18
+ constructor(options) {
19
+ this.options = options;
20
+ }
21
+ /** OutputSession.open queries this after negotiation to select a locally reachable MCP output bridge.
22
+ * Injected transports cannot attest reachability and return undefined. */
23
+ outputBridgeTransport() {
24
+ if (!this.options.command || this.options.connect)
25
+ return undefined;
26
+ return this.capabilities.mcp?.http ? 'http' : 'stdio';
27
+ }
28
+ /** Native capabilities from the initialize response when available, or an empty descriptor before negotiation. */
29
+ get nativeCapabilities() { return this.info?.agentCapabilities ?? {}; }
30
+ /** Access the connected native request context; throws ACP_CONNECTION_CLOSED before connection setup. */
31
+ get agent() { if (!this.connection)
32
+ throw new HarnessSdkError('ACP_CONNECTION_CLOSED'); return this.connection.agent; }
33
+ /** Whether the adapter has installed a connection and has yet to observe transport loss. */
34
+ get connected() { return !!this.connection && !this.connectionLost; }
35
+ /** Negotiate ACP and return detached AHP capabilities. Calls after a successful handshake reuse its result.
36
+ * Client.initialize awaits this through withStructuredOutput and retains the capabilities for later opens.
37
+ * Handshake request failures include a redacted stderr tail when available. Direct callers must dispose
38
+ * after setup failure and should finish initialization before opening sessions. */
39
+ async initialize(params) {
40
+ if (this.closing)
41
+ throw new HarnessSdkError('RUNTIME_DISPOSED');
42
+ assertRequest("initialize", params);
43
+ negotiateVersion(params.protocolVersions);
44
+ if (this.info)
45
+ return this.initializedResult();
46
+ const channel = this.options.connect ? await this.options.connect() : await processStream(this.options);
47
+ if (this.closing) {
48
+ await channel.dispose();
49
+ throw new HarnessSdkError('RUNTIME_DISPOSED');
50
+ }
51
+ const terminal = this.installConnection(channel);
52
+ try {
53
+ this.info = await this.call('initialize', { protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: 'anureo-harness-sdk', version: '0.1.0' }, clientCapabilities: {
54
+ fs: { readTextFile: !!this.options.fs?.readTextFile, writeTextFile: !!this.options.fs?.writeTextFile }, terminal: !!terminal,
55
+ session: { configOptions: { boolean: {} }, compaction: {} }, plan: {},
56
+ elicitation: params.clientCapabilities?.interactions?.includes('elicitation') ? { form: {}, url: {} } : undefined,
57
+ _meta: { ahp: { version: 1, clientRequests: ['_ahp/user_input', '_ahp/dynamic_tool'] } },
58
+ } });
59
+ }
60
+ catch (error) {
61
+ throw this.withDiagnostics(error instanceof HarnessSdkError ? error : sdkError(error, 'ACP_HANDSHAKE_FAILED', true));
62
+ }
63
+ if (this.info.protocolVersion !== acp.PROTOCOL_VERSION) {
64
+ await this.dispose();
65
+ throw new HarnessSdkError('PROTOCOL_VERSION_UNSUPPORTED');
66
+ }
67
+ this.configureCapabilities();
68
+ return this.initializedResult();
69
+ }
70
+ async call(method, params) {
71
+ try {
72
+ return await waitForRequest(() => this.agent.request(method, params), {}, this.options.requestTimeoutMs ?? 30_000);
73
+ }
74
+ catch (error) {
75
+ throw errorFromNative(error);
76
+ }
77
+ }
78
+ /** Open, resume/load or fork one native session. Opens run one at a time so early updates
79
+ * can establish the native identity. OutputSession.open supplies the sink and awaits the configured
80
+ * backend before attachment. Supply protocol-valid parameters, including the fork source and boundary
81
+ * in fork mode. Dispatch or attachment failures remove local indexes and release the open slot.
82
+ * Successful backends remain adapter-tracked until teardown. Their owner must dispose them. */
83
+ async openSession(params, sink) {
84
+ // Early session/update frames can identify only one opening session at a time.
85
+ const previousOpenCompleted = this.sessionOpenQueueTail;
86
+ let releaseOpenSlot;
87
+ this.sessionOpenQueueTail = new Promise((resolve) => { releaseOpenSlot = resolve; });
88
+ await previousOpenCompleted;
89
+ const session = new AcpBackendSession(this, params, sink);
90
+ this.openingSession = session;
91
+ try {
92
+ const request = this.prepareSessionOpen(params, session);
93
+ const response = record(await this.call(request.method, request.params));
94
+ this.acceptOpenResponse(session, params.binding.sessionId, response);
95
+ const patch = initialConfiguration(params, session.capabilities);
96
+ if (Object.keys(patch).length)
97
+ await session.setConfiguration(patch);
98
+ const metadata = session.snapshot().metadata;
99
+ if (metadata)
100
+ await sink.emit({ type: 'session/metadata', payload: metadata });
101
+ return session;
102
+ }
103
+ catch (error) {
104
+ this.forgetFailedOpen(session, params.binding.sessionId);
105
+ throw error;
106
+ }
107
+ finally {
108
+ this.openingSession = undefined;
109
+ releaseOpenSlot();
110
+ }
111
+ }
112
+ /** Attempt all tracked session releases, then close the native connection and channel, and clear indexes.
113
+ * Reject RESOURCE_RELEASE_FAILED after all attempts if any fail. Repeated calls after closing begins
114
+ * return immediately; disposal does not prove native executions stopped. */
115
+ async dispose() {
116
+ if (this.closing)
117
+ return;
118
+ this.closing = true;
119
+ const results = await Promise.allSettled([...this.sessions.values()].map(session => session.dispose()));
120
+ const transport = await Promise.allSettled([Promise.resolve().then(() => this.connection?.close()), Promise.resolve().then(() => this.channel?.dispose())]);
121
+ this.sessions.clear();
122
+ this.nativeSessions.clear();
123
+ assertResourcesReleased([...results, ...transport]);
124
+ }
125
+ connection;
126
+ channel;
127
+ info;
128
+ /** Adapter's AHP session index. Disposed sessions remain indexed until adapter teardown. */
129
+ sessions = new Map();
130
+ nativeSessions = new Map();
131
+ openingSession;
132
+ sessionOpenQueueTail = Promise.resolve();
133
+ deliveries = new Map();
134
+ closing = false;
135
+ connectionLost = false;
136
+ sourceFailure;
137
+ /** Peer-negotiated features. Backend requests can withdraw a method the peer rejects as unsupported. */
138
+ extension = { methods: {}, fork: [], revert: [], subagents: false, instructions: false, userInput: false, dynamicTool: false };
139
+ /** Negotiated baseline. Each backend clones it before applying session configuration. */
140
+ capabilities = {};
141
+ /** Install initialize's transport and handlers before sending the handshake. When the SDK reports
142
+ * closure, reject outstanding response deliveries and fail live sessions unless disposal is underway. */
143
+ installConnection(channel) {
144
+ this.channel = channel;
145
+ const wrapped = this.wrapStream(channel);
146
+ const { app, terminal } = this.createClientApplication();
147
+ this.connection = app.connect(wrapped);
148
+ void this.connection.closed.then(async () => {
149
+ this.connectionLost = true;
150
+ const error = this.withDiagnostics(this.sourceFailure ?? new HarnessSdkError('ACP_CONNECTION_CLOSED', 'ACP source connection closed', false, true));
151
+ for (const delivery of this.deliveries.values())
152
+ delivery.reject(error);
153
+ this.deliveries.clear();
154
+ if (!this.closing)
155
+ await this.failSessions(error);
156
+ });
157
+ return terminal;
158
+ }
159
+ /** Return detached negotiated capabilities so callers can retain their initialization result. */
160
+ initializedResult() {
161
+ return {
162
+ protocolVersion: 1,
163
+ protocolContract: AHP_PROTOCOL_CONTRACT,
164
+ runtime: { name: this.info?.agentInfo?.name ?? 'acp', version: this.info?.agentInfo?.version ?? 'unknown', adapter: 'acp/0.1.0' },
165
+ capabilities: structuredClone(this.capabilities),
166
+ };
167
+ }
168
+ /** Notify currently indexed sessions of observation loss. A rejected sink must not stop fan-out. */
169
+ failSessions(error) {
170
+ return Promise.all([...this.sessions.values()].map(session => session.sink.fail(error).catch(() => { })));
171
+ }
172
+ /** Build openSession's native request. For resume/load, register the known ID and begin snapshot
173
+ * capture before dispatch so updates received before the reply reach the correct backend. */
174
+ prepareSessionOpen(params, session) {
175
+ if (params.instructions && !this.extension.instructions)
176
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'ACP peer did not negotiate instructions');
177
+ const common = {
178
+ cwd: params.cwd,
179
+ mcpServers: mcp(params.mcpServers),
180
+ ...(params.instructions ? { _meta: { ahp: { version: 1, instructions: params.instructions } } } : {}),
181
+ };
182
+ if (params.mode === 'new')
183
+ return { method: 'session/new', params: common };
184
+ if (params.mode === 'resume') {
185
+ const nativeRef = params.binding.nativeRef;
186
+ if (!nativeRef || nativeRef.provider !== 'acp')
187
+ throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
188
+ session.nativeId = nativeRef.value;
189
+ this.nativeSessions.set(session.nativeId, session);
190
+ session.hydrating = true;
191
+ const method = this.nativeCapabilities.sessionCapabilities?.resume != null ? 'session/resume' : 'session/load';
192
+ return { method, params: { ...common, sessionId: session.nativeId } };
193
+ }
194
+ // The session/open schema requires fork data for this mode. The generated type leaves it optional.
195
+ const fork = params.fork;
196
+ const nativeRef = fork?.nativeRef ?? this.sessions.get(fork.sourceSessionId)?.binding.nativeRef;
197
+ const method = this.extension.methods.fork;
198
+ if (!nativeRef || !method)
199
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
200
+ return { method, params: { ...common, sessionId: nativeRef.value, boundary: fork.boundary } };
201
+ }
202
+ /** Reconcile the reply with any early notification identity before openSession applies overrides.
203
+ * Index the backend and finish snapshot capture before invoking the host's snapshot callback. */
204
+ acceptOpenResponse(session, sessionId, response) {
205
+ if (typeof response.sessionId === 'string') {
206
+ if (session.nativeId && response.sessionId !== session.nativeId)
207
+ throw new HarnessSdkError('ACP_PROTOCOL_ERROR', 'ACP session identity changed while opening');
208
+ session.nativeId = response.sessionId;
209
+ }
210
+ if (!session.nativeId)
211
+ throw new HarnessSdkError('ACP_PROTOCOL_ERROR', 'ACP session response lacked an identity');
212
+ this.nativeSessions.set(session.nativeId, session);
213
+ this.sessions.set(sessionId, session);
214
+ session.configure(response);
215
+ session.hydrating = false;
216
+ if (session.loadedSnapshot.length)
217
+ this.options.onLoadSnapshot?.(session.binding, session.loadedSnapshot);
218
+ }
219
+ /** Remove the indexes that openSession may have populated before failure, including through early updates. */
220
+ forgetFailedOpen(session, sessionId) {
221
+ for (const [nativeId, backend] of this.nativeSessions) {
222
+ if (backend === session)
223
+ this.nativeSessions.delete(nativeId);
224
+ }
225
+ this.sessions.delete(sessionId);
226
+ }
227
+ /** Attach redacted channel diagnostics before initialize rejects a handshake failure or the
228
+ * SDK closure handler reports observation loss. Preserve the error when no tail is available. */
229
+ withDiagnostics(error) {
230
+ const tail = this.channel?.diagnostics?.();
231
+ if (!tail)
232
+ return error;
233
+ return new HarnessSdkError(error.code, error.message, error.retryable, error.outcomeUnknown, {
234
+ ...error.details,
235
+ diagnostics: { stderrTail: tail },
236
+ });
237
+ }
238
+ /** Resolve the native ID for SDK callbacks and routeNotification. Only a session/update may
239
+ * identify the current opening backend. Reject other unknown IDs as invalid native parameters. */
240
+ backend(nativeId, source = 'request') {
241
+ const existing = this.nativeSessions.get(nativeId);
242
+ if (existing)
243
+ return existing;
244
+ if (source === 'update' && this.openingSession && (!this.openingSession.nativeId || this.openingSession.nativeId === nativeId)) {
245
+ this.openingSession.nativeId = nativeId;
246
+ // Index by native ID as soon as a session/update identifies the opening session.
247
+ this.nativeSessions.set(nativeId, this.openingSession);
248
+ return this.openingSession;
249
+ }
250
+ throw new acp.RequestError(-32602, 'Unknown ACP session');
251
+ }
252
+ /** Register a native request with the session sink after establishing standard turn correlation.
253
+ * The sink invokes the responder with an answer or cancellation. Resolve the native response first,
254
+ * but keep the responder pending until wrapStream confirms its write, so settlement cannot claim early delivery. */
255
+ async interaction(session, requestId, request, transform) {
256
+ await session.ensureStandardStarted();
257
+ const delivery = deferred();
258
+ this.deliveries.set(requestId, delivery);
259
+ return new Promise((resolve, reject) => {
260
+ let answered = false;
261
+ void session.sink.requestInteraction(request, async (response) => {
262
+ answered = true;
263
+ resolve(transform(response));
264
+ await delivery.promise;
265
+ }).catch(() => { if (!answered)
266
+ reject(new acp.RequestError(-32603, 'Interaction could not be completed')); });
267
+ });
268
+ }
269
+ /** Consume session updates and negotiated lifecycle events before the official SDK handles remaining frames.
270
+ * Await sink publication to preserve source order; routing failures end stream observation in wrapStream. */
271
+ async routeNotification(frame) {
272
+ // Preserve unknown notifications without requiring a patched SDK union of update variants.
273
+ if (!('method' in frame) || 'id' in frame)
274
+ return 'forward-to-sdk';
275
+ if (frame.method === 'session/update') {
276
+ const value = record(frame.params);
277
+ const session = this.backend(String(value.sessionId), 'update');
278
+ await session.update(record(value.update));
279
+ return 'consumed';
280
+ }
281
+ if (frame.method === '_ahp/event' && (Object.keys(this.extension.methods).length || this.extension.nativeLifecycle)) {
282
+ const value = record(frame.params);
283
+ const session = this.backend(String(value.sessionId));
284
+ const event = value.event;
285
+ if (this.extension.nativeLifecycle)
286
+ await session.nativeEvent(event, value);
287
+ else
288
+ await session.extensionEvent(event, value);
289
+ return 'consumed';
290
+ }
291
+ if (frame.method !== 'elicitation/complete') {
292
+ const scope = record(frame.params).sessionId;
293
+ const recipients = typeof scope === 'string' ? [this.backend(scope)] : [...this.sessions.values()];
294
+ for (const session of recipients)
295
+ await session.sink.emit({ type: 'notice', payload: { severity: 'info', code: 'ACP_UNKNOWN_NOTIFICATION', message: 'Unknown ACP notification retained', raw: safe({ method: frame.method, params: frame.params }) } });
296
+ return 'consumed';
297
+ }
298
+ return 'forward-to-sdk';
299
+ }
300
+ /** Route notifications in read order before forwarding other frames to the SDK. A successful
301
+ * response write acknowledges delivery to the interaction responder. Stream errors fail observation. */
302
+ wrapStream(channel) {
303
+ const rawReader = channel.stream.readable.getReader();
304
+ const rawWriter = channel.stream.writable.getWriter();
305
+ return {
306
+ writable: new WritableStream({ write: async (frame) => {
307
+ try {
308
+ await rawWriter.write(frame);
309
+ if ('id' in frame && !('method' in frame)) {
310
+ this.deliveries.get(frame.id)?.resolve();
311
+ this.deliveries.delete(frame.id);
312
+ }
313
+ }
314
+ catch (error) {
315
+ this.connectionLost = true;
316
+ const failure = this.sourceFailure = sdkError(error, 'ACP_CONNECTION_CLOSED', true);
317
+ for (const delivery of this.deliveries.values())
318
+ delivery.reject(failure);
319
+ void this.failSessions(failure);
320
+ throw error;
321
+ }
322
+ }, close: () => rawWriter.close(), abort: (reason) => rawWriter.abort(reason) }),
323
+ readable: new ReadableStream({ pull: async (controller) => {
324
+ try {
325
+ for (;;) {
326
+ const next = await rawReader.read();
327
+ if (next.done) {
328
+ controller.close();
329
+ return;
330
+ }
331
+ const frame = next.value;
332
+ if (await this.routeNotification(frame) === 'consumed')
333
+ continue;
334
+ controller.enqueue(frame);
335
+ return;
336
+ }
337
+ }
338
+ catch (error) {
339
+ this.connectionLost = true;
340
+ const failure = this.sourceFailure = sdkError(error, 'ACP_CONNECTION_CLOSED', true);
341
+ controller.error(error);
342
+ void this.failSessions(failure);
343
+ }
344
+ }, cancel: (reason) => rawReader.cancel(reason) }),
345
+ };
346
+ }
347
+ /** Configure the ACP SDK callbacks for installConnection before initialize advertises their
348
+ * capabilities. The SDK invokes them for incoming peer requests and notifications. */
349
+ createClientApplication() {
350
+ const app = acp.client({ name: 'anureo-harness-sdk-acp' })
351
+ .onRequest('session/request_permission', (context) => this.requestPermission(context))
352
+ .onRequest('elicitation/create', (context) => this.requestElicitation(context))
353
+ .onNotification('elicitation/complete', async (context) => {
354
+ for (const session of this.sessions.values())
355
+ await session.sink.emit({ type: 'notice', payload: { severity: 'info', code: 'ACP_ELICITATION_COMPLETE', message: 'External authorization completed', raw: safe(context.params) } });
356
+ })
357
+ .onRequest('_ahp/user_input', (value) => record(value), (context) => this.extensionInteraction('userInput', context.requestId, context.params))
358
+ .onRequest('_ahp/dynamic_tool', (value) => record(value), (context) => this.extensionInteraction('dynamicTool', context.requestId, context.params));
359
+ const terminal = this.registerPorts(app);
360
+ return { app, terminal };
361
+ }
362
+ /** Handle the ACP SDK's elicitation callback. Cancel unsupported scopes or modes immediately.
363
+ * For a session request, keep URL details in the private payload and return the sink's answer to the peer. */
364
+ requestElicitation(context) {
365
+ const native = record(context.params);
366
+ if (typeof native.sessionId !== 'string' || !['form', 'url'].includes(String(native.mode)))
367
+ return { action: 'cancel' };
368
+ const session = this.backend(native.sessionId);
369
+ const request = {
370
+ kind: 'elicitation',
371
+ interactionId: `acp-elicitation:${String(context.requestId)}`,
372
+ turnId: session.activeTurn,
373
+ mode: native.mode,
374
+ message: String(native.message ?? ''),
375
+ ...(native.mode === 'form'
376
+ ? { schema: safe(native.requestedSchema) }
377
+ : { privatePayload: { url: String(native.url ?? ''), elicitationId: String(native.elicitationId ?? '') } }),
378
+ };
379
+ return this.interaction(session, context.requestId, request, (response) => {
380
+ if (response?.kind !== 'elicitation')
381
+ return { action: 'cancel' };
382
+ return {
383
+ action: response.action,
384
+ ...(response.content ? { content: response.content } : {}),
385
+ };
386
+ });
387
+ }
388
+ /** Answer the ACP SDK's permission request with an offered option after publishing the policy
389
+ * notice. If policy declines or has no matching option, await the interactive session response. */
390
+ async requestPermission(context) {
391
+ const session = this.backend(context.params.sessionId);
392
+ const policy = this.options.autoRespondPermission;
393
+ if (policy !== undefined) {
394
+ const decision = typeof policy === 'function'
395
+ ? await policy({
396
+ sessionId: context.params.sessionId,
397
+ toolCallId: context.params.toolCall.toolCallId,
398
+ title: context.params.toolCall.title ?? undefined,
399
+ kind: context.params.toolCall.kind ?? undefined,
400
+ rawInput: context.params.toolCall.rawInput,
401
+ options: context.params.options,
402
+ signal: context.signal,
403
+ })
404
+ : policy;
405
+ const optionId = decision ? selectPermissionOption(context.params.options, decision) : undefined;
406
+ if (optionId !== undefined) {
407
+ await session.sink.emit({ type: 'notice', payload: {
408
+ severity: 'info', code: 'ACP_PERMISSION_AUTO_ANSWERED', message: 'Permission request answered by adapter policy',
409
+ raw: safe({ toolCallId: context.params.toolCall.toolCallId, optionId, decision }),
410
+ } }, { ...(session.activeTurn ? { turnId: session.activeTurn } : {}) });
411
+ return { outcome: { outcome: 'selected', optionId } };
412
+ }
413
+ }
414
+ return this.interaction(session, context.requestId, {
415
+ kind: 'approval', interactionId: `acp-permission:${String(context.requestId)}`, turnId: session.activeTurn,
416
+ itemId: context.params.toolCall.toolCallId,
417
+ options: context.params.options.map((option) => ({ id: option.optionId, label: option.name, semantic: option.kind === 'allow_once' ? 'allowOnce' : option.kind === 'allow_always' ? 'allowSession' : 'deny' })),
418
+ ...(context.params.toolCall.rawInput !== undefined ? { input: safe(context.params.toolCall.rawInput) } : {}),
419
+ }, (response) => response?.kind === 'approval' ? { outcome: { outcome: 'selected', optionId: response.optionId } } : { outcome: { outcome: 'cancelled' } });
420
+ }
421
+ /** Register the filesystem and terminal callbacks supplied by the host. The ACP SDK handler
422
+ * validates session and terminal ownership before returning the port's response to the peer. */
423
+ registerPorts(app) {
424
+ if (this.options.fs?.readTextFile)
425
+ app.onRequest('fs/read_text_file', (ctx) => { this.backend(ctx.params.sessionId); return this.options.fs.readTextFile(ctx.params); });
426
+ if (this.options.fs?.writeTextFile)
427
+ app.onRequest('fs/write_text_file', (ctx) => { this.backend(ctx.params.sessionId); return this.options.fs.writeTextFile(ctx.params); });
428
+ const terminal = this.options.terminal;
429
+ if (terminal) {
430
+ app.onRequest('terminal/create', async (ctx) => { const session = this.backend(ctx.params.sessionId); const result = await terminal.createTerminal(ctx.params); session.terminals.add(result.terminalId); return result; });
431
+ app.onRequest('terminal/output', (ctx) => { this.backend(ctx.params.sessionId).requireTerminal(ctx.params.terminalId); return terminal.terminalOutput(ctx.params); });
432
+ app.onRequest('terminal/wait_for_exit', (ctx) => { this.backend(ctx.params.sessionId).requireTerminal(ctx.params.terminalId); return terminal.waitForTerminalExit(ctx.params); });
433
+ app.onRequest('terminal/kill', (ctx) => { this.backend(ctx.params.sessionId).requireTerminal(ctx.params.terminalId); return terminal.killTerminal(ctx.params); });
434
+ app.onRequest('terminal/release', async (ctx) => { const session = this.backend(ctx.params.sessionId); session.requireTerminal(ctx.params.terminalId); const result = await terminal.releaseTerminal(ctx.params); session.terminals.delete(ctx.params.terminalId); return result; });
435
+ }
436
+ return terminal;
437
+ }
438
+ /** After initialize validates the peer version, store the baseline capability projection and
439
+ * negotiated extension methods. Session construction clones the baseline before applying overrides. */
440
+ configureCapabilities() {
441
+ this.extension = extensions(this.info);
442
+ const native = this.nativeCapabilities;
443
+ this.capabilities = {
444
+ session: { resume: native.loadSession === true || native.sessionCapabilities?.resume != null, fork: this.extension.fork, revert: this.extension.revert, history: !!this.extension.methods.history },
445
+ input: { delivery: ['native'], interrupt: { targets: ['session'], reportsPendingInputs: false } },
446
+ turn: { inputModalities: ['text', ...(native.promptCapabilities?.image ? ['image'] : []), ...(native.promptCapabilities?.embeddedContext ? ['file'] : [])] },
447
+ interactions: ['approval', 'elicitation', ...(this.extension.userInput ? ['userInput'] : []), ...(this.extension.dynamicTool ? ['dynamicTool'] : [])],
448
+ goal: { actions: [...(this.extension.methods.goalSet ? ['set'] : []), ...(this.extension.methods.goalClear ? ['clear'] : [])] },
449
+ compact: !!this.extension.methods.compact, review: !!this.extension.methods.review, subagents: { events: this.extension.subagents ? 'nested' : 'none' },
450
+ mcp: { stdio: true, http: native.mcpCapabilities?.http === true, elicitation: ['form', 'url'] }, config: { options: [] },
451
+ extensions: Object.keys(this.extension.methods).length || this.extension.subagents || this.extension.userInput || this.extension.dynamicTool ? [{ name: 'x-acp/ahp', version: '1' }] : [],
452
+ };
453
+ }
454
+ /** Handle registered _ahp user-input/tool requests only after negotiation; validate before sink registration.
455
+ * Native callers receive a response or cancellation; unavailable extensions reject as method-not-found. */
456
+ async extensionInteraction(kind, id, params) {
457
+ if (!this.extension[kind])
458
+ throw new acp.RequestError(-32601, 'Extension was not negotiated');
459
+ const session = this.backend(String(params.sessionId));
460
+ const request = { ...record(params.request), kind, interactionId: `acp-${kind}:${String(id)}`, turnId: session.activeTurn };
461
+ assertDefinition('InteractionRequested', request);
462
+ return this.interaction(session, id, request, (response) => response ?? { cancelled: true });
463
+ }
464
+ }
465
+ /** Native session implementation returned by AcpAdapter.openSession and adopted by OutputSession.open.
466
+ * Execution receipts and sink lifecycle events are separate observations; disposal releases handles
467
+ * without asserting that native execution stopped. Routing/state methods support adapter collaboration. */
468
+ export class AcpBackendSession {
469
+ adapter;
470
+ params;
471
+ sink;
472
+ /** Create session-local projection and configuration state. AcpAdapter.openSession assigns the
473
+ * native identity. The mapper reads this backend's current turn and applies peer metadata/config
474
+ * updates through configure before publishing snapshots to the supplied sink. */
475
+ constructor(adapter, params, sink) {
476
+ this.adapter = adapter;
477
+ this.params = params;
478
+ this.sink = sink;
479
+ this.capabilities = structuredClone(adapter.capabilities);
480
+ this.mapper = new AcpUpdateMapper(sink, () => this.activeTurn, () => this.snapshot(), (data) => this.configure(data), adapter.extension.subagents);
481
+ }
482
+ /** Attach the native ACP identity to the requested AHP binding; identity is established before openSession returns. */
483
+ get binding() { return { ...this.params.binding, nativeRef: { provider: 'acp', value: this.nativeId } }; }
484
+ /** Expose the current attachment snapshot to OutputSession.open; collection fields are session-owned. */
485
+ get state() { return this.snapshot(); }
486
+ /** Build state for attachment and sink publication. Configuration and capabilities are session-owned references. */
487
+ snapshot() {
488
+ return {
489
+ ...(this.sessionTitle !== undefined ? { metadata: { title: this.sessionTitle } } : {}),
490
+ binding: this.binding,
491
+ config: this.config,
492
+ capabilities: this.capabilities,
493
+ capabilityRevision: this.capabilityRevision,
494
+ status: this.activeTurn ? 'running' : 'ready',
495
+ activeTurnIds: [...this.nativeStartedTurnIds, ...(this.standardTurnStarted && this.activeTurn ? [this.activeTurn] : [])],
496
+ };
497
+ }
498
+ /** AcpAdapter.routeNotification forwards negotiated v2 events here. Bind submissions through the sink,
499
+ * flush a turn's mapper before its terminal event, and reject invalid payloads instead of publishing them. */
500
+ async nativeEvent(raw, scope) {
501
+ const event = record(raw), payload = record(event.payload);
502
+ if (event.type === 'submission/bound') {
503
+ assertDefinition('SubmissionBound', payload);
504
+ await this.sink.bindSubmission(payload.submissionId, payload.turnId);
505
+ return;
506
+ }
507
+ assertDefinition('AhpEventPayload', raw);
508
+ if (raw.type === 'turn/started')
509
+ this.nativeStartedTurnIds.add(raw.payload.turnId);
510
+ if (raw.type === 'turn/completed') {
511
+ await this.nativeMappers.get(raw.payload.turnId)?.complete();
512
+ this.nativeMappers.delete(raw.payload.turnId);
513
+ this.nativeStartedTurnIds.delete(raw.payload.turnId);
514
+ }
515
+ await this.sink.emit(raw, { ...(typeof scope.turnId === 'string' ? { turnId: scope.turnId } : {}) });
516
+ }
517
+ /** Publish legacy _ahp events from AcpAdapter.routeNotification, binding review/input on native start.
518
+ * Release matching terminal admission before publication; conflicting execution identities reject routing. */
519
+ async extensionEvent(raw, scope) {
520
+ assertDefinition('AhpEventPayload', raw);
521
+ if (raw.type === 'turn/started') {
522
+ if (this.standardTurnStarted && this.activeTurn !== raw.payload.turnId)
523
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'ACP execution identity changed');
524
+ this.activeTurn = raw.payload.turnId;
525
+ await this.sink.emit(raw);
526
+ if (this.standardSubmissionId && !this.standardTurnStarted) {
527
+ this.standardTurnStarted = true;
528
+ await this.sink.bindSubmission(this.standardSubmissionId, raw.payload.turnId);
529
+ }
530
+ return;
531
+ }
532
+ if (raw.type === 'turn/completed' && this.activeTurn === raw.payload.turnId) {
533
+ this.activeTurn = undefined;
534
+ this.standardSubmissionId = undefined;
535
+ this.standardTurnStarted = false;
536
+ }
537
+ await this.sink.emit(raw, { ...(typeof scope.turnId === 'string' ? { turnId: scope.turnId } : this.activeTurn ? { turnId: this.activeTurn } : {}) });
538
+ }
539
+ /** Publish a standard turn start before update, interaction registration or terminal prompt handling
540
+ * continues. Bind after start publication, setting the guard before either await to prevent duplicate starts. */
541
+ async ensureStandardStarted() {
542
+ if (!this.standardSubmissionId || !this.activeTurn || this.standardTurnStarted)
543
+ return;
544
+ this.standardTurnStarted = true;
545
+ await this.sink.emit({ type: 'turn/started', payload: { turnId: this.activeTurn } });
546
+ await this.sink.bindSubmission(this.standardSubmissionId, this.activeTurn);
547
+ }
548
+ /** Merge native configuration or a session-title patch from open responses and AcpUpdateMapper callbacks.
549
+ * Configuration changes refresh descriptors and revision; title-only changes leave the revision unchanged. */
550
+ configure(data) {
551
+ if (Object.hasOwn(data, 'sessionTitle')) {
552
+ this.sessionTitle = data.sessionTitle;
553
+ return;
554
+ }
555
+ this.configurationData = { ...this.configurationData, ...data };
556
+ if (typeof data.currentModeId === 'string')
557
+ this.configurationData.modes = { ...record(this.configurationData.modes), currentModeId: data.currentModeId };
558
+ const descriptor = configuration(this.configurationData);
559
+ this.configKeys = descriptor.keys;
560
+ this.capabilities.config = { options: descriptor.options };
561
+ this.config = { ...this.config, ...descriptor.values };
562
+ if (typeof data.currentModeId === 'string')
563
+ this.config.mode = data.currentModeId;
564
+ this.capabilityRevision++;
565
+ }
566
+ /** AcpAdapter routes session/update here: retain bounded load snapshots without replay, or await live projection.
567
+ * Correlated updates require an observed start; unsupported uncorrelated execution updates reject routing. */
568
+ async update(data) {
569
+ if (this.hydrating) {
570
+ this.captureLoadedUpdate(data);
571
+ return;
572
+ }
573
+ if (this.adapter.extension.nativeLifecycle) {
574
+ const native = record(record(data._meta).ahp);
575
+ const turnId = typeof native.turnId === 'string' ? native.turnId : undefined;
576
+ if (turnId) {
577
+ await this.correlatedMapper(turnId).update(data);
578
+ return;
579
+ }
580
+ if (!isSessionUpdate(data, 'correlated'))
581
+ throw new HarnessSdkError('NATIVE_CORRELATION_UNSUPPORTED');
582
+ }
583
+ if (!isSessionUpdate(data))
584
+ await this.ensureStandardStarted();
585
+ await this.mapper.update(data);
586
+ }
587
+ /** Reject peer operations on terminal IDs not successfully created for this session or already released. */
588
+ requireTerminal(id) { if (!this.terminals.has(id))
589
+ throw new acp.RequestError(-32602, 'Terminal does not belong to this session'); }
590
+ /** Apply a configuration patch in key order and return the resulting session state. Reject unknown
591
+ * keys before dispatch. AcpAdapter.openSession awaits initial overrides before attachment, and execute
592
+ * returns this state for host patches. Publish each confirmed field. A later native failure rejects
593
+ * with CONFIG_PARTIAL and the confirmed keys, whose changes remain applied. */
594
+ async setConfiguration(patch) {
595
+ for (const key of Object.keys(patch))
596
+ if (!this.configKeys.has(key))
597
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
598
+ const appliedKeys = [];
599
+ for (const [key, value] of Object.entries(patch)) {
600
+ try {
601
+ const option = this.configKeys.get(key);
602
+ if (option.type === 'mode')
603
+ await this.adapter.call('session/set_mode', { sessionId: this.nativeId, modeId: String(value) });
604
+ else {
605
+ const response = await this.adapter.call('session/set_config_option', { sessionId: this.nativeId, configId: String(option.id), type: option.type === 'boolean' ? 'boolean' : 'select', value });
606
+ this.configure(record(response));
607
+ }
608
+ }
609
+ catch (cause) {
610
+ const error = errorFromNative(cause);
611
+ if (!appliedKeys.length)
612
+ throw error;
613
+ throw new HarnessSdkError('CONFIG_PARTIAL', 'Some configuration changes were applied', error.retryable, error.outcomeUnknown, { appliedKeys, failedKey: key, cause: { code: error.code } });
614
+ }
615
+ this.config[key] = value;
616
+ appliedKeys.push(key);
617
+ // Native config methods commit independently; publish each confirmed state.
618
+ await this.sink.emit({ type: 'session/state', payload: this.snapshot() });
619
+ }
620
+ return this.snapshot();
621
+ }
622
+ /** OutputSession.execute dispatches validated session operations here and consumes the native result/failure.
623
+ * Session cancellation only acknowledges notification delivery; completion comes from the prompt/event.
624
+ * Close reports executionStopped=unconfirmed, and unsupported exact-turn operations reject. */
625
+ async execute(method, params) {
626
+ let result;
627
+ switch (method) {
628
+ case 'input/submit': return await this.submitInput(params);
629
+ case 'turn/interrupt':
630
+ case 'turn/steer': throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
631
+ case 'session/interrupt':
632
+ await this.adapter.agent.notify('session/cancel', { sessionId: this.nativeId });
633
+ result = { accepted: true };
634
+ break;
635
+ case 'session/config/set':
636
+ result = await this.setConfiguration(params.patch);
637
+ break;
638
+ case 'session/close': {
639
+ if (this.adapter.nativeCapabilities.sessionCapabilities?.close != null)
640
+ await this.adapter.call('session/close', { sessionId: this.nativeId });
641
+ this.nativeClosed = true;
642
+ result = { closed: true, observationClosed: true, executionStopped: 'unconfirmed' };
643
+ break;
644
+ }
645
+ case 'session/revert': return this.extension('revert', method, params);
646
+ case 'history/read': return this.extension('history', method, params);
647
+ case 'goal/set': return this.extension('goalSet', method, params);
648
+ case 'goal/clear': return this.extension('goalClear', method, params);
649
+ case 'compact/start': return this.extension('compact', method, params);
650
+ case 'review/start': return this.submitReview(params);
651
+ case 'interaction/respond':
652
+ case 'interaction/cancel': throw new HarnessSdkError('INTERACTION_EXPIRED');
653
+ }
654
+ return result;
655
+ }
656
+ /**
657
+ * Share one cleanup promise across calls. Mark closed before attempting every retained terminal
658
+ * release and optional native close; clear local handles and reject aggregated release failures.
659
+ * OutputSession.dispose awaits this during teardown. OutputSession.open also awaits it when a
660
+ * backend arrives after teardown, and AcpAdapter.dispose calls it for every tracked backend.
661
+ */
662
+ async dispose() {
663
+ return this.releasing ??= (async () => {
664
+ this.closed = true;
665
+ const operations = [...this.terminals].map(terminalId => Promise.resolve().then(() => this.adapter.options.terminal?.releaseTerminal({ sessionId: this.nativeId, terminalId })));
666
+ if (!this.nativeClosed && this.adapter.connected && this.adapter.nativeCapabilities.sessionCapabilities?.close != null)
667
+ operations.push(this.adapter.call('session/close', { sessionId: this.nativeId }));
668
+ const results = await Promise.allSettled(operations);
669
+ this.terminals.clear();
670
+ this.nativeMappers.clear();
671
+ assertResourcesReleased(results);
672
+ })();
673
+ }
674
+ // Native lifecycle tracks turn IDs and a separate mapper per turn.
675
+ nativeMappers = new Map();
676
+ nativeStartedTurnIds = new Set();
677
+ // Standard ACP and legacy extensions share one turn. activeTurn can exist
678
+ // before a start notification; standardTurnStarted marks start handling,
679
+ // not completed submission binding.
680
+ standardTurnStarted = false;
681
+ standardSubmissionId;
682
+ /** Standard/legacy lane identity; v2 executions are tracked independently. */
683
+ activeTurn;
684
+ mapper;
685
+ releasing;
686
+ nativeClosed = false;
687
+ /** Native identity assigned by openSession, including early update correlation. */
688
+ nativeId = '';
689
+ /** While resume/load is pending, historical updates are captured instead of published. */
690
+ hydrating = false;
691
+ /** Bounded historical updates delivered to onLoadSnapshot after opening. */
692
+ loadedSnapshot = [];
693
+ /** Successfully created terminal IDs awaiting explicit or disposal release. */
694
+ terminals = new Set();
695
+ /** Session-local negotiated capabilities, refreshed by native configuration and extension failures. */
696
+ capabilities;
697
+ config = {};
698
+ configKeys = new Map();
699
+ configurationData = {};
700
+ capabilityRevision = 1;
701
+ sessionTitle;
702
+ closed = false;
703
+ snapshotBytes = 0;
704
+ /** Save pre-attachment updates for the host snapshot callback. Capture title as attachment metadata
705
+ * and bound the remaining history by count and estimated size before retaining its safe projection. */
706
+ captureLoadedUpdate(data) {
707
+ if (data.sessionUpdate === 'session_info_update') {
708
+ if (typeof data.title === 'string' || data.title === null)
709
+ this.sessionTitle = data.title;
710
+ return;
711
+ }
712
+ this.snapshotBytes += JSON.stringify(data).length * 3;
713
+ if (this.loadedSnapshot.length >= 2048 || this.snapshotBytes > 16 * 1024 * 1024) {
714
+ throw new HarnessSdkError('BACKPRESSURE_EXCEEDED', 'ACP load snapshot exceeds its limit');
715
+ }
716
+ this.loadedSnapshot.push(safe(data));
717
+ }
718
+ /** Return the per-turn mapper used by update. A native start must have been observed before
719
+ * projection. nativeEvent flushes and removes the mapper on completion. */
720
+ correlatedMapper(turnId) {
721
+ if (!this.nativeStartedTurnIds.has(turnId))
722
+ throw new HarnessSdkError('PROTOCOL_ERROR', 'ACP update arrived without its execution start');
723
+ let mapper = this.nativeMappers.get(turnId);
724
+ if (!mapper) {
725
+ mapper = new AcpUpdateMapper(this.sink, () => turnId, () => this.snapshot(), value => this.configure(value), this.adapter.extension.subagents);
726
+ this.nativeMappers.set(turnId, mapper);
727
+ }
728
+ return mapper;
729
+ }
730
+ /** Dispatch a legacy review in the single execution lane. The receipt confirms dispatch.
731
+ * Native start notifications establish binding. Definite pre-start rejection releases admission;
732
+ * unknown outcome retains it, and method-not-found withdraws review capability before rejecting. */
733
+ async submitReview(params) {
734
+ const method = this.adapter.extension.methods.review;
735
+ if (!method)
736
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
737
+ this.beginStandardSubmission(params.submissionId);
738
+ try {
739
+ await this.adapter.call(method, { ...params, sessionId: this.nativeId, turnId: this.activeTurn });
740
+ // The legacy extension has no revised input receipt. Only its native
741
+ // lifecycle notification can associate the review with an execution.
742
+ return { submissionId: params.submissionId, disposition: 'dispatched' };
743
+ }
744
+ catch (cause) {
745
+ const error = errorFromNative(cause, 'ACP_REVIEW_REJECTED');
746
+ if (!this.standardTurnStarted && !error.outcomeUnknown) {
747
+ this.activeTurn = undefined;
748
+ this.standardSubmissionId = undefined;
749
+ }
750
+ if (error.code === 'CAPABILITY_UNSUPPORTED') {
751
+ this.withdrawExtension('review');
752
+ await this.sink.emit({ type: 'session/state', payload: this.snapshot() });
753
+ }
754
+ throw error;
755
+ }
756
+ }
757
+ /** Reserve the uncorrelated ACP lane before review or prompt dispatch; reject overlapping submissions. */
758
+ beginStandardSubmission(submissionId) {
759
+ if (this.activeTurn)
760
+ throw new HarnessSdkError('NATIVE_CORRELATION_UNSUPPORTED');
761
+ this.activeTurn = randomUUID();
762
+ this.standardSubmissionId = submissionId;
763
+ this.standardTurnStarted = false;
764
+ this.mapper.reset();
765
+ return this.activeTurn;
766
+ }
767
+ /** Release only this prompt's lane: terminal observers may already have admitted its successor. */
768
+ releaseStandardTurn(turnId) {
769
+ if (this.activeTurn !== turnId)
770
+ return;
771
+ this.activeTurn = undefined;
772
+ this.standardSubmissionId = undefined;
773
+ this.standardTurnStarted = false;
774
+ }
775
+ /** Reject closed/unsupported delivery before choosing negotiated native correlation or the single standard ACP lane. */
776
+ async submitInput(start) {
777
+ if (this.closed)
778
+ throw new HarnessSdkError('SESSION_CLOSED');
779
+ if (start.delivery !== 'native')
780
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
781
+ return this.adapter.extension.nativeLifecycle ? this.submitCorrelatedInput(start) : this.submitStandardInput(start);
782
+ }
783
+ /** Return the peer's validated acceptance; v2 lifecycle notifications independently bind and complete turns. */
784
+ async submitCorrelatedInput(start) {
785
+ try {
786
+ const result = record(await this.adapter.agent.request('session/prompt', { sessionId: this.nativeId, prompt: input(start.input), _meta: { ahp: { version: 2, submissionId: start.submissionId } } }));
787
+ const value = record(record(result._meta).ahp).acceptance;
788
+ assertDefinition('SubmissionAcceptance', value);
789
+ return value;
790
+ }
791
+ catch (cause) {
792
+ throw errorFromNative(cause, 'ACP_INPUT_REJECTED');
793
+ }
794
+ }
795
+ /** Await the uncorrelated prompt's terminal response and publish mapped completion before returning acceptance. */
796
+ async submitStandardInput(start) {
797
+ // Standard ACP has no per-execution correlation. It can safely expose only
798
+ // one outstanding prompt; this is a backend-specific evidence limitation.
799
+ const turnId = this.beginStandardSubmission(start.submissionId);
800
+ try {
801
+ const completed = await this.adapter.agent.request('session/prompt', { sessionId: this.nativeId, prompt: input(start.input),
802
+ ...(Object.keys(this.adapter.extension.methods).length ? { _meta: { ahp: { version: 1, turnId } } } : {}) });
803
+ await this.ensureStandardStarted();
804
+ await this.mapper.complete();
805
+ const outcome = stopOutcome(completed.stopReason);
806
+ if (outcome.notice)
807
+ await this.sink.emit({ type: 'notice', payload: { severity: 'warning', code: outcome.notice.code, message: outcome.notice.message } }, { turnId });
808
+ // A result observer can immediately submit again after terminal publication.
809
+ // Release native admission first, and never let this prompt's finally clear its successor.
810
+ this.releaseStandardTurn(turnId);
811
+ await this.sink.emit({ type: 'turn/completed', payload: { turnId, status: outcome.status } });
812
+ return { submissionId: start.submissionId, accepted: true, disposition: 'started', turnId };
813
+ }
814
+ catch (cause) {
815
+ const error = errorFromNative(cause, 'ACP_INPUT_REJECTED');
816
+ if (this.standardTurnStarted) {
817
+ if (error.outcomeUnknown)
818
+ await this.sink.fail(error);
819
+ else
820
+ await this.sink.emit({ type: 'turn/completed', payload: { turnId, status: 'failed', error: { code: error.code, message: error.code, retryable: false, outcomeUnknown: false } } });
821
+ }
822
+ throw error;
823
+ }
824
+ finally {
825
+ this.releaseStandardTurn(turnId);
826
+ }
827
+ }
828
+ /** Dispatch a negotiated extension, enrich history tool file changes, and validate the native result.
829
+ * Method-not-found withdraws the affected live capability and publishes state before rejecting. */
830
+ async extension(key, method, params) {
831
+ const nativeMethod = this.adapter.extension.methods[key];
832
+ if (!nativeMethod)
833
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
834
+ try {
835
+ const response = await this.adapter.call(nativeMethod, { ...params, sessionId: this.nativeId });
836
+ const result = key === 'history' ? projectHistoryFileChanges(response) : response;
837
+ assertResult(method, result);
838
+ return result;
839
+ }
840
+ catch (error) {
841
+ if (error instanceof HarnessSdkError && error.code === 'CAPABILITY_UNSUPPORTED') {
842
+ this.withdrawExtension(key);
843
+ await this.sink.emit({ type: 'session/state', payload: this.snapshot() });
844
+ }
845
+ throw error;
846
+ }
847
+ }
848
+ /** Remove the rejected method for future dispatch and update this session's advertised capability.
849
+ * The caller publishes the revised session state before surfacing the native rejection. */
850
+ withdrawExtension(key) {
851
+ delete this.adapter.extension.methods[key];
852
+ switch (key) {
853
+ case 'steer':
854
+ delete this.capabilities.input.steer;
855
+ break;
856
+ case 'history':
857
+ this.capabilities.session.history = false;
858
+ break;
859
+ case 'revert':
860
+ this.capabilities.session.revert = [];
861
+ break;
862
+ case 'compact':
863
+ this.capabilities.compact = false;
864
+ break;
865
+ case 'review':
866
+ this.capabilities.review = false;
867
+ break;
868
+ case 'goalSet':
869
+ case 'goalClear': {
870
+ const removedAction = key === 'goalSet' ? 'set' : 'clear';
871
+ const goal = this.capabilities.goal;
872
+ goal.actions = goal.actions?.filter(action => action !== removedAction);
873
+ break;
874
+ }
875
+ }
876
+ }
877
+ }
878
+ /** Create an ACP adapter without starting I/O. Pass it to createClient for negotiation and owned cleanup,
879
+ * or initialize/open/dispose it directly in that order; connect takes precedence over command. */
880
+ export function createAcpAdapter(options) { return new AcpAdapter(options); }
881
+ /** Check the settled release attempts after both dispose methods clear local ownership.
882
+ * Keep provider errors private while preserving the number of failed releases. */
883
+ function assertResourcesReleased(results) {
884
+ const errors = results.filter(result => result.status === 'rejected').map(() => ({ code: 'ACP_RELEASE_FAILED' }));
885
+ if (errors.length)
886
+ throw new HarnessSdkError('RESOURCE_RELEASE_FAILED', 'ACP resources were not fully released', false, true, { errors });
887
+ }
888
+ /** Fill absent tool-call file changes from legacy ACP history output before extension result validation.
889
+ * Preserve explicit file changes and leave malformed page shapes for the result validator. */
890
+ function projectHistoryFileChanges(result) {
891
+ if (!result || typeof result !== 'object')
892
+ return result;
893
+ const page = record(result);
894
+ if (!Array.isArray(page.items))
895
+ return result;
896
+ return {
897
+ ...page,
898
+ items: page.items.map((entry) => {
899
+ const row = record(entry);
900
+ const item = record(row.item);
901
+ if (item.type !== 'toolCall' || item.fileChanges !== undefined || !Array.isArray(item.output))
902
+ return entry;
903
+ return { ...row, item: { ...item, fileChanges: acpFileChanges(item.output) } };
904
+ }),
905
+ };
906
+ }
907
+ /** Build the initial patch after native descriptors arrive. Explicit model and permission mode
908
+ * override matching config keys, and model aliases resolve against the attached session. */
909
+ function initialConfiguration(params, capabilities) {
910
+ return {
911
+ ...params.config,
912
+ ...(params.model ? { model: resolveConfigValue(capabilities, 'model', params.model) } : {}),
913
+ ...(params.permissionMode ? { permissionMode: params.permissionMode } : {}),
914
+ };
915
+ }
916
+ /** Identify notifications that do not need turn correlation. v2 can send session-scoped usage,
917
+ * while standard ACP treats a usage notification as evidence of activity in the reserved turn. */
918
+ function isSessionUpdate(data, mode = 'standard') {
919
+ switch (String(data.sessionUpdate)) {
920
+ case 'session_info_update':
921
+ case 'config_option_update':
922
+ case 'current_mode_update':
923
+ case 'available_commands_update':
924
+ return true;
925
+ case 'usage_update':
926
+ return mode === 'correlated';
927
+ default:
928
+ return false;
929
+ }
930
+ }
931
+ /** Track native interaction-response delivery separately from the response value; interaction awaits write acknowledgment. */
932
+ function deferred() {
933
+ let resolve, reject;
934
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
935
+ void promise.catch(() => { });
936
+ return { promise, resolve, reject };
937
+ }
938
+ /** requestPermission uses this to select an offered one-shot kind or matching legacy ID.
939
+ * When neither matches, it defers to the interactive path. */
940
+ function selectPermissionOption(options, decision) {
941
+ const kind = decision === 'allow-once' ? 'allow_once' : 'reject_once';
942
+ return options.find((option) => option.kind === kind)?.optionId
943
+ ?? options.find((option) => option.optionId === decision)?.optionId;
944
+ }