@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/mapping.js ADDED
@@ -0,0 +1,504 @@
1
+ // Projects ACP prompt input, session configuration and live updates for the adapter.
2
+ import { acpFileChanges } from "./file-changes.js";
3
+ import { publicValue } from '@anureo/protocol';
4
+ import { HarnessSdkError } from '@anureo/sdk';
5
+ /** Read a native object by reference. Arrays, null and primitives become a fresh empty record. */
6
+ export function record(value) {
7
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
+ }
9
+ /**
10
+ * Make a detached JSON copy and apply publicValue redaction before retaining native payloads.
11
+ * Nullish values become null. JSON serialization and parsing errors propagate synchronously.
12
+ * Callers still own removal of secrets embedded in arbitrary text.
13
+ */
14
+ export function safe(value) {
15
+ return publicValue(JSON.parse(JSON.stringify(value ?? null)));
16
+ }
17
+ /**
18
+ * Flatten native content for message, plan and compaction text. Arrays join block text with
19
+ * newlines, diffs join old and new text with a newline, and unrecognized blocks yield empty text.
20
+ */
21
+ export function text(value) {
22
+ if (typeof value === 'string')
23
+ return value;
24
+ if (Array.isArray(value))
25
+ return value.map(text).join('\n');
26
+ const object = record(value);
27
+ if (object.type === 'text' && typeof object.text === 'string')
28
+ return object.text;
29
+ if (object.type === 'content')
30
+ return text(object.content);
31
+ if (object.type === 'diff')
32
+ return `${typeof object.oldText === 'string' ? object.oldText : ''}\n${typeof object.newText === 'string' ? object.newText : ''}`;
33
+ if (object.type === 'resource')
34
+ return text(object.resource);
35
+ return '';
36
+ }
37
+ /**
38
+ * Translate prompt blocks in order for AcpBackendSession.submitStandardInput and
39
+ * submitCorrelatedInput to send in session/prompt. File links use the URI as the name when
40
+ * omitted. Throws INVALID_PARAMS before dispatch if an image's data or MIME type is empty or missing.
41
+ */
42
+ export function input(blocks) {
43
+ return blocks.map((block) => {
44
+ if (block.type === 'text')
45
+ return { type: 'text', text: block.text };
46
+ if (block.type === 'file') {
47
+ return {
48
+ type: 'resource_link',
49
+ uri: block.uri,
50
+ name: block.name ?? block.uri,
51
+ ...(block.mimeType ? { mimeType: block.mimeType } : {}),
52
+ };
53
+ }
54
+ if (!block.data || !block.mimeType)
55
+ throw new HarnessSdkError('INVALID_PARAMS', 'ACP images require inline data and mimeType');
56
+ return { type: 'image', data: block.data, mimeType: block.mimeType, ...(block.url ? { uri: block.url } : {}) };
57
+ });
58
+ }
59
+ /**
60
+ * Build native MCP server entries in input order for the session request sent by AcpAdapter.openSession.
61
+ * Omitted servers, stdio arguments, HTTP headers and environment variables become empty arrays.
62
+ */
63
+ export function mcp(servers = []) {
64
+ return servers.map((server) => server.type === 'http'
65
+ ? { type: 'http', name: server.name, url: server.url, headers: Object.entries(server.headers ?? {}).map(([name, value]) => ({ name, value })) }
66
+ : { name: server.name, command: server.command, args: server.args ?? [], env: Object.entries(server.env ?? {}).map(([name, value]) => ({ name, value })) });
67
+ }
68
+ /**
69
+ * Project native booleans, select lists and modes for AcpBackendSession.configure to advertise in
70
+ * capabilities and merge into current state. Missing current values supply no patch.
71
+ * setConfiguration uses keys to address the native option or mode, so the model alias points
72
+ * to its original native descriptor.
73
+ */
74
+ export function configuration(data) {
75
+ const options = [];
76
+ const values = {};
77
+ const keys = new Map();
78
+ /** Keep the advertised descriptor, current value and native dispatch key aligned, including aliases. */
79
+ function registerOption(descriptor, nativeOption) {
80
+ options.push(descriptor);
81
+ keys.set(descriptor.id, nativeOption);
82
+ if (descriptor.value !== undefined)
83
+ values[descriptor.id] = descriptor.value;
84
+ }
85
+ for (const item of Array.isArray(data.configOptions) ? data.configOptions : []) {
86
+ const option = record(item);
87
+ const descriptor = configOption(option);
88
+ if (!descriptor)
89
+ continue;
90
+ registerOption(descriptor, option);
91
+ if (option.category === 'model' && descriptor.id !== 'model') {
92
+ registerOption({ ...descriptor, id: 'model' }, option);
93
+ }
94
+ }
95
+ const modes = record(data.modes);
96
+ if (Array.isArray(modes.availableModes)) {
97
+ const modeIds = modes.availableModes.map((mode) => record(mode).id).filter((id) => typeof id === 'string');
98
+ options.push({ id: 'mode', title: 'ACP mode', scope: 'session', schema: { type: 'string', enum: modeIds } });
99
+ keys.set('mode', { id: 'mode', type: 'mode', options: modeIds });
100
+ if (typeof modes.currentModeId === 'string')
101
+ values.mode = modes.currentModeId;
102
+ }
103
+ return { options, values, keys };
104
+ }
105
+ /**
106
+ * Project one execution's native updates into ordered item events and session patches.
107
+ * AcpBackendSession keeps a standard mapper and creates a separate mapper for each correlated
108
+ * turn that receives an update. The session controls reset, completion and resource cleanup.
109
+ * Await each update in source order. Sink and callback failures reject after any prior mutations.
110
+ */
111
+ export class AcpUpdateMapper {
112
+ sink;
113
+ getTurn;
114
+ state;
115
+ setState;
116
+ subagents;
117
+ /**
118
+ * AcpBackendSession supplies the event sink, a turn ID reader that can return undefined while
119
+ * idle, and callbacks backed by snapshot and configure. The mapper applies state patches
120
+ * synchronously before reading or publishing state. Enable subagents only after negotiation.
121
+ */
122
+ constructor(sink, getTurn, state, setState, subagents) {
123
+ this.sink = sink;
124
+ this.getTurn = getTurn;
125
+ this.state = state;
126
+ this.setState = setState;
127
+ this.subagents = subagents;
128
+ }
129
+ /**
130
+ * Forget cached items, completions, summaries and generated plan IDs without publishing.
131
+ * AcpBackendSession.beginStandardSubmission calls this before reusing the standard mapper.
132
+ */
133
+ reset() {
134
+ this.items.clear();
135
+ this.ended.clear();
136
+ this.compaction.clear();
137
+ this.serial = 0;
138
+ }
139
+ /**
140
+ * Flush unended cached items in insertion order, promoting running status to completed.
141
+ * AcpBackendSession.submitStandardInput and nativeEvent await this before publishing
142
+ * turn/completed. Publication failures reject and leave prior mutations and events in place.
143
+ * With no current turn, no items are published.
144
+ */
145
+ async complete() {
146
+ for (const item of this.items.values()) {
147
+ if (this.ended.has(item.id))
148
+ continue;
149
+ await this.upsert({ ...item, status: item.status === 'running' ? 'completed' : item.status }, 'completion');
150
+ }
151
+ }
152
+ /**
153
+ * Await projection/publication of one native update forwarded by AcpBackendSession.update.
154
+ * Configuration, metadata and usage can be published while idle. Other idle updates and unmapped
155
+ * turn updates produce notices. A failed publication leaves state and cache mutations in place.
156
+ * AcpAdapter's notification routing observes rejection and ends the stream.
157
+ */
158
+ async update(update) {
159
+ const kind = update.sessionUpdate;
160
+ const turnId = this.getTurn();
161
+ if (kind === 'config_option_update' || kind === 'current_mode_update') {
162
+ this.setState(update);
163
+ await this.sink.emit({ type: 'session/state', payload: this.state() });
164
+ return;
165
+ }
166
+ if (kind === 'usage_update') {
167
+ await this.sink.emit({
168
+ type: 'usage/updated',
169
+ payload: {
170
+ provider: 'acp',
171
+ scope: 'session',
172
+ source: 'native',
173
+ ...(typeof update.used === 'number' ? { contextUsed: update.used } : {}),
174
+ ...(typeof update.size === 'number' ? { contextWindow: update.size } : {}),
175
+ },
176
+ }, { ...(turnId ? { turnId } : {}) });
177
+ return;
178
+ }
179
+ if (kind === 'session_info_update') {
180
+ await this.updateMetadata(update);
181
+ return;
182
+ }
183
+ if (!turnId || kind === 'available_commands_update') {
184
+ await this.sink.emit({
185
+ type: 'notice',
186
+ payload: {
187
+ severity: 'info',
188
+ code: `ACP_${String(kind ?? 'UPDATE').toUpperCase()}`,
189
+ message: 'ACP session metadata update',
190
+ raw: safe(update),
191
+ },
192
+ });
193
+ return;
194
+ }
195
+ if (['agent_message_chunk', 'agent_thought_chunk', 'user_message_chunk'].includes(String(kind))) {
196
+ await this.updateMessage(update, turnId);
197
+ return;
198
+ }
199
+ if (kind === 'tool_call' || kind === 'tool_call_update') {
200
+ await this.updateToolCall(update);
201
+ return;
202
+ }
203
+ if (kind === 'plan' || kind === 'plan_update' || kind === 'plan_removed') {
204
+ await this.updatePlan(update);
205
+ return;
206
+ }
207
+ if (kind === 'compaction_update' || kind === 'compaction_summary_chunk') {
208
+ await this.updateCompaction(update);
209
+ return;
210
+ }
211
+ if ((kind === 'subagent_spawned' || kind === 'subagent_state_update') && this.subagents) {
212
+ await this.updateSubagent(update);
213
+ return;
214
+ }
215
+ await this.sink.emit({
216
+ type: 'notice',
217
+ payload: { severity: 'info', code: 'ACP_UNKNOWN_UPDATE', message: 'Unmapped ACP update retained', raw: safe(update) },
218
+ }, { turnId });
219
+ }
220
+ items = new Map();
221
+ ended = new Set();
222
+ serial = 0;
223
+ compaction = new Map();
224
+ /**
225
+ * Cache a turn's item before publishing it. An unseen ID starts even in completion mode.
226
+ * Later snapshots emit full patches until the ID is marked ended. Mark it ended before
227
+ * awaiting completion publication so subsequent writes stay silent even if the sink rejects.
228
+ * With no current turn, discard the snapshot without touching the cache.
229
+ */
230
+ async upsert(item, publication = 'snapshot') {
231
+ const turnId = this.getTurn();
232
+ if (!turnId)
233
+ return;
234
+ const previousItem = this.items.get(item.id);
235
+ this.items.set(item.id, item);
236
+ if (this.ended.has(item.id))
237
+ return;
238
+ if (!previousItem) {
239
+ await this.sink.emit({ type: 'item/started', payload: { item } }, { turnId });
240
+ }
241
+ else if (publication === 'snapshot') {
242
+ await this.sink.emit({ type: 'item/delta', payload: { itemId: item.id, kind: 'patch', value: safe(item) } }, { turnId });
243
+ }
244
+ if (publication === 'completion') {
245
+ this.ended.add(item.id);
246
+ await this.sink.emit({ type: 'item/completed', payload: { item } }, { turnId });
247
+ }
248
+ }
249
+ /**
250
+ * Apply a changed title before publishing session/metadata, including while idle.
251
+ * Omitted, invalid and unchanged titles leave state and publication alone. Null clears the title.
252
+ */
253
+ async updateMetadata(update) {
254
+ if (!Object.hasOwn(update, 'title') || (typeof update.title !== 'string' && update.title !== null))
255
+ return;
256
+ const title = update.title;
257
+ if (this.state().metadata?.title === title)
258
+ return;
259
+ this.setState({ sessionTitle: title });
260
+ await this.sink.emit({ type: 'session/metadata', payload: { title } });
261
+ }
262
+ /**
263
+ * Accumulate message chunks by kind and message ID, falling back to one item per kind.
264
+ * Publish a start before the first delta. User items emit full patches and agent/reasoning
265
+ * items emit text deltas. Unsupported content is retained in notices. complete flushes the items.
266
+ */
267
+ async updateMessage(update, turnId) {
268
+ const kind = update.sessionUpdate;
269
+ const nativeId = typeof update.messageId === 'string' ? update.messageId : String(kind);
270
+ const id = `${String(kind)}:${nativeId}`;
271
+ const chunk = text(update.content);
272
+ if (!chunk && record(update.content).type !== 'text') {
273
+ await this.sink.emit({
274
+ type: 'notice',
275
+ payload: { severity: 'info', code: 'ACP_NON_TEXT_CONTENT', message: 'ACP non-text content retained', raw: safe(update.content) },
276
+ }, { turnId });
277
+ }
278
+ let item = this.items.get(id);
279
+ if (!item) {
280
+ item = kind === 'agent_thought_chunk' ? { id, type: 'reasoning', text: '' }
281
+ : kind === 'user_message_chunk' ? { id, type: 'userMessage', content: [] }
282
+ : { id, type: 'agentMessage', text: '', phase: 'final' };
283
+ this.items.set(id, item);
284
+ await this.sink.emit({ type: 'item/started', payload: { item } }, { turnId });
285
+ }
286
+ if (item.type === 'userMessage') {
287
+ const block = userMessageBlock(update.content, chunk);
288
+ if (block) {
289
+ item.content.push(block);
290
+ }
291
+ else {
292
+ await this.sink.emit({
293
+ type: 'notice',
294
+ payload: { severity: 'info', code: 'ACP_UNKNOWN_CONTENT', message: 'ACP user content', raw: safe(update.content) },
295
+ }, { turnId });
296
+ }
297
+ await this.sink.emit({ type: 'item/delta', payload: { itemId: id, kind: 'patch', value: safe(item) } }, { turnId });
298
+ }
299
+ else if (item.type === 'agentMessage' || item.type === 'reasoning') {
300
+ item.text = (item.text ?? '') + chunk;
301
+ await this.sink.emit({ type: 'item/delta', payload: { itemId: id, kind: 'text', text: chunk } }, { turnId });
302
+ }
303
+ }
304
+ /** Publish the merged tool snapshot, closing the item when its mapped status is terminal. */
305
+ async updateToolCall(update) {
306
+ const id = String(update.toolCallId);
307
+ const previousItem = this.items.get(id);
308
+ const previousToolCall = previousItem?.type === 'toolCall' ? previousItem : undefined;
309
+ const item = toolSnapshot(id, update, previousToolCall);
310
+ await this.upsert(item, item.status !== 'running' ? 'completion' : 'snapshot');
311
+ }
312
+ /**
313
+ * Publish standard steps under one stable ID and extension plans under their supplied or
314
+ * generated IDs. Extension removal publishes an empty terminal plan.
315
+ */
316
+ async updatePlan(update) {
317
+ const kind = update.sessionUpdate;
318
+ if (kind === 'plan') {
319
+ const entries = Array.isArray(update.entries) ? update.entries : [];
320
+ await this.upsert({
321
+ id: 'acp-plan',
322
+ type: 'plan',
323
+ steps: entries.map((entry, index) => {
324
+ const value = record(entry);
325
+ return {
326
+ id: String(index),
327
+ text: String(value.content ?? ''),
328
+ status: value.status === 'completed' ? 'completed' : value.status === 'in_progress' ? 'inProgress' : 'pending',
329
+ };
330
+ }),
331
+ });
332
+ return;
333
+ }
334
+ const plan = record(update.plan);
335
+ const id = `plan:${String(update.planId ?? plan.planId ?? ++this.serial)}`;
336
+ const removed = kind === 'plan_removed';
337
+ await this.upsert({
338
+ id,
339
+ type: 'plan',
340
+ steps: removed ? [] : [{ id: 'content', text: text(plan.content ?? plan.markdown ?? plan), status: 'pending' }],
341
+ extensions: { 'x-acp/native-plan': safe(update) },
342
+ }, removed ? 'completion' : 'snapshot');
343
+ }
344
+ /** Accumulate summary chunks, replace explicit summaries, and close only on native terminal status. */
345
+ async updateCompaction(update) {
346
+ const kind = update.sessionUpdate;
347
+ const id = `compaction:${String(update.compactionId)}`;
348
+ let summary = this.compaction.get(id) ?? '';
349
+ if (kind === 'compaction_summary_chunk')
350
+ summary += text(update.content);
351
+ else if ('summary' in update)
352
+ summary = text(update.summary);
353
+ this.compaction.set(id, summary);
354
+ const terminal = update.status === 'completed' || update.status === 'failed' || update.status === 'cancelled';
355
+ await this.upsert({
356
+ id,
357
+ type: 'compaction',
358
+ summary,
359
+ status: update.status === 'failed' ? 'failed' : update.status === 'cancelled' ? 'interrupted' : terminal ? 'completed' : 'running',
360
+ }, terminal ? 'completion' : 'snapshot');
361
+ }
362
+ /** Merge negotiated subagent fields and publish a terminal snapshot when its native state ends. */
363
+ async updateSubagent(update) {
364
+ const id = `subagent:${String(update.subagentSessionId)}`;
365
+ const previousItem = this.items.get(id);
366
+ const state = subagentState(update.state);
367
+ await this.upsert({
368
+ ...(previousItem?.type === 'subagent' ? previousItem : { id, type: 'subagent', subagentId: String(update.subagentSessionId) }),
369
+ state,
370
+ ...(typeof update.name === 'string' ? { name: update.name } : {}),
371
+ ...(typeof update.task === 'string' ? { task: update.task } : {}),
372
+ }, state !== 'running' ? 'completion' : 'snapshot');
373
+ }
374
+ }
375
+ /**
376
+ * Resolve a host value against the agent's advertised session configuration.
377
+ * AcpAdapter.openSession passes the model override here before calling setConfiguration.
378
+ * Returns the requested value when no nonempty string enum constrains it, otherwise accepts
379
+ * an exact match or a unique provider-qualified suffix. Throws CAPABILITY_UNSUPPORTED for
380
+ * a missing session option and INVALID_PARAMS for an unknown or ambiguous enum value.
381
+ *
382
+ * This lets hosts use a bare model name when a peer advertises `provider/model`.
383
+ */
384
+ export function resolveConfigValue(capabilities, key, wanted) {
385
+ const option = (capabilities.config?.options ?? []).find((entry) => entry.id === key && entry.scope === 'session');
386
+ if (!option)
387
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', `Backend does not expose the ${key} config option`);
388
+ const schema = option.schema;
389
+ const values = schema && typeof schema === 'object' && !Array.isArray(schema) && schema.type === 'string' && Array.isArray(schema.enum)
390
+ ? schema.enum.filter((value) => typeof value === 'string')
391
+ : [];
392
+ if (values.length === 0 || values.includes(wanted))
393
+ return wanted;
394
+ const matches = values.filter((value) => value.endsWith(`/${wanted}`) || value.split('/').pop() === wanted);
395
+ if (matches.length === 1)
396
+ return matches[0];
397
+ throw new HarnessSdkError('INVALID_PARAMS', `Unknown ${key} value`, false, false, { key, wanted, accepted: values });
398
+ }
399
+ /**
400
+ * Describe a selectable or boolean native option for session capability advertisement.
401
+ * Skip unsupported types and options without a string ID. Omit absent or invalid current values.
402
+ */
403
+ function configOption(option) {
404
+ if (typeof option.id !== 'string')
405
+ return undefined;
406
+ const id = option.id;
407
+ const current = option.currentValue;
408
+ if (option.type !== 'boolean' && option.type !== 'select')
409
+ return undefined;
410
+ const schema = option.type === 'boolean'
411
+ ? { type: 'boolean' }
412
+ : { type: 'string', enum: selectValues(option.options) };
413
+ return {
414
+ id,
415
+ title: typeof option.name === 'string' ? option.name : id,
416
+ scope: 'session',
417
+ schema,
418
+ ...(typeof current === 'string' || typeof current === 'boolean' ? { value: current } : {}),
419
+ };
420
+ }
421
+ /** Flatten grouped ACP select choices in advertised order; non-array groups contribute no values. */
422
+ function selectValues(value) {
423
+ if (!Array.isArray(value))
424
+ return [];
425
+ return value.flatMap((entry) => {
426
+ const option = record(entry);
427
+ return typeof option.value === 'string' ? [option.value] : selectValues(option.options);
428
+ });
429
+ }
430
+ /** Use the flattened chunk for text and retain image data or file URIs. Return undefined for unsupported content. */
431
+ function userMessageBlock(value, chunk) {
432
+ const content = record(value);
433
+ if (content.type === 'text')
434
+ return { type: 'text', text: chunk };
435
+ if (content.type === 'image' && typeof content.data === 'string') {
436
+ return {
437
+ type: 'image',
438
+ data: content.data,
439
+ mimeType: typeof content.mimeType === 'string' ? content.mimeType : undefined,
440
+ };
441
+ }
442
+ if (content.type === 'resource_link' && typeof content.uri === 'string') {
443
+ return { type: 'file', uri: content.uri };
444
+ }
445
+ return undefined;
446
+ }
447
+ const TOOL_KINDS = {
448
+ execute: 'command',
449
+ edit: 'fileChange',
450
+ delete: 'fileChange',
451
+ move: 'fileChange',
452
+ fetch: 'web',
453
+ };
454
+ /**
455
+ * Build a new tool snapshot, retaining previous values for omitted fields. Defined content
456
+ * replaces file materials. Defined rawOutput, including null, wins over content for output.
457
+ * Serialization failures propagate before the caller caches or publishes the snapshot.
458
+ */
459
+ function toolSnapshot(id, update, previous) {
460
+ let status = 'running';
461
+ if (update.status === 'failed' || update.status === 'completed') {
462
+ status = update.status;
463
+ }
464
+ else if (update.status === undefined) {
465
+ status = previous?.status ?? 'running';
466
+ }
467
+ const baseItem = {
468
+ ...(previous ?? { id, type: 'toolCall', kind: 'other', title: 'ACP tool' }),
469
+ ...(typeof update.title === 'string' ? { title: update.title } : {}),
470
+ ...(typeof update.kind === 'string' ? { kind: TOOL_KINDS[update.kind] ?? 'other', nativeType: update.kind } : {}),
471
+ status,
472
+ ...(update.content !== undefined ? { fileChanges: acpFileChanges(update.content) } : {}),
473
+ ...(update.rawInput !== undefined ? { input: safe(update.rawInput) } : {}),
474
+ };
475
+ const outputPatch = update.rawOutput !== undefined
476
+ ? { output: safe(update.rawOutput) }
477
+ : update.content !== undefined
478
+ ? { output: safe(update.content) }
479
+ : {};
480
+ return {
481
+ ...baseItem,
482
+ ...outputPatch,
483
+ ...(Array.isArray(update.locations) ? { locations: toolLocations(update.locations) } : {}),
484
+ };
485
+ }
486
+ /** Preserve path-bearing tool locations in native order, retaining a line only when numeric. */
487
+ function toolLocations(locations) {
488
+ return locations.flatMap((location) => {
489
+ const value = record(location);
490
+ return typeof value.path === 'string'
491
+ ? [{ path: value.path, ...(typeof value.line === 'number' ? { line: value.line } : {}) }]
492
+ : [];
493
+ });
494
+ }
495
+ /** Treat cancellation and disconnection as interruptions. Unrecognized states remain running. */
496
+ function subagentState(state) {
497
+ if (state === 'completed')
498
+ return 'completed';
499
+ if (['cancelled', 'disconnected'].includes(String(state)))
500
+ return 'interrupted';
501
+ if (state === 'failed')
502
+ return 'failed';
503
+ return 'running';
504
+ }
@@ -0,0 +1,103 @@
1
+ import type * as acp from '@agentclientprotocol/sdk';
2
+ import type { JsonObject, SessionBinding } from '@anureo/protocol';
3
+ /** Host-supplied ACP filesystem callbacks, registered only for provided operations.
4
+ * AcpAdapter validates the session before invoking a callback for a peer request.
5
+ * Hosts enforce path and access policy and return native responses.
6
+ * Callback rejections fail the peer request. */
7
+ export interface AcpFileSystemPort {
8
+ /** Read the requested native range as text; the host applies path policy. */
9
+ readTextFile?: (params: acp.ReadTextFileRequest) => Promise<acp.ReadTextFileResponse>;
10
+ /** Resolve after the requested write completes; undefined is normalized by the ACP SDK. */
11
+ writeTextFile?: (params: acp.WriteTextFileRequest) => Promise<acp.WriteTextFileResponse | void>;
12
+ }
13
+ /** Host-supplied terminal operations for handling ACP peer requests. AcpAdapter checks session
14
+ * ownership and requires a tracked terminal ID for operations on existing terminals.
15
+ * Successful creation makes the session track the returned ID. Explicit release or session disposal calls
16
+ * releaseTerminal. Hosts own process/access policy and must finish each operation before resolving. */
17
+ export interface AcpTerminalPort {
18
+ /** Resolve with a session-local terminal ID after creation; subsequent operations use that ID. */
19
+ createTerminal(params: acp.CreateTerminalRequest): Promise<acp.CreateTerminalResponse>;
20
+ /** Return retained terminal output and truncation/exit metadata without releasing the terminal. */
21
+ terminalOutput(params: acp.TerminalOutputRequest): Promise<acp.TerminalOutputResponse>;
22
+ /** Release terminal resources; successful resolution removes the ID from adapter ownership. */
23
+ releaseTerminal(params: acp.ReleaseTerminalRequest): Promise<acp.ReleaseTerminalResponse | void>;
24
+ /** Wait for terminal completion and return exit information; the terminal still requires release. */
25
+ waitForTerminalExit(params: acp.WaitForTerminalExitRequest): Promise<acp.WaitForTerminalExitResponse>;
26
+ /** Request terminal termination; the terminal remains owned until release. */
27
+ killTerminal(params: acp.KillTerminalRequest): Promise<acp.KillTerminalResponse | void>;
28
+ }
29
+ /** An owned ACP message transport returned by connect. The adapter locks its streams and calls dispose
30
+ * during adapter teardown, including teardown racing connection setup; hosts implement resource release. */
31
+ export interface AcpChannel {
32
+ /** Official ACP message stream; the adapter owns its reader/writer locks. */
33
+ stream: acp.Stream;
34
+ /** Release transport resources. Rejection contributes to adapter release failure during teardown. */
35
+ dispose(): Promise<void>;
36
+ /** Optional redacted diagnostics captured so far; injected transports may also supply this hook. */
37
+ diagnostics?(): string;
38
+ }
39
+ /** One-shot permission policy choices; selection is limited to an option actually offered by the peer. */
40
+ export type AcpPermissionDecision = 'allow-once' | 'reject-once';
41
+ /** Native permission request supplied to a host policy before interactive fallback.
42
+ * IDs belong to the ACP peer; rawInput is unprojected provider data and must be handled accordingly. */
43
+ export interface AcpPermissionContext {
44
+ /** Native ID of the session that owns this request. */
45
+ sessionId: string;
46
+ /** Native tool call awaiting a permission decision. */
47
+ toolCallId: string;
48
+ /** Optional display title supplied by the peer. */
49
+ title?: string;
50
+ /** Optional native tool category, independent of the offered permission kinds. */
51
+ kind?: string;
52
+ /** Peer-provided tool arguments before projection. They can contain private values. */
53
+ rawInput?: unknown;
54
+ /** Options actually offered by the agent; a policy never fabricates an optionId. */
55
+ options: readonly acp.PermissionOption[];
56
+ /** Native request cancellation signal; policies own cancellation of any host work they start. */
57
+ signal: AbortSignal;
58
+ }
59
+ /** Answer a native permission request directly, or return undefined to ask through the session sink.
60
+ * Policy throws/rejections fail the native request; the adapter does not retry the policy. */
61
+ export type AcpPermissionPolicy = AcpPermissionDecision | ((context: AcpPermissionContext) => AcpPermissionDecision | undefined | Promise<AcpPermissionDecision | undefined>);
62
+ /** Configure a spawned ACP agent or an injected message transport and optional host resource policies. */
63
+ export interface AcpAdapterOptions {
64
+ /** Executable for stdio; required when connect is omitted. */
65
+ command?: string;
66
+ /** Arguments passed to the executable; omitted arguments use the process helper's empty list. */
67
+ args?: readonly string[];
68
+ /** Working directory for the child process. Omission inherits the host working directory.
69
+ * Each session supplies its own cwd independently. */
70
+ cwd?: string;
71
+ /** Complete child environment; omission inherits the host environment. */
72
+ env?: Record<string, string | undefined>;
73
+ /** Advertise only supplied read/write operations; the host retains access-policy responsibility. */
74
+ fs?: AcpFileSystemPort;
75
+ /** Advertise terminal support; the host implements all lifecycle operations. */
76
+ terminal?: AcpTerminalPort;
77
+ /** Supply an owned ACP channel for initialization. Takes precedence over spawning command.
78
+ * A rejected factory rejects initialization. The adapter disposes a successfully returned channel. */
79
+ connect?: () => Promise<AcpChannel>;
80
+ /** Deadline in milliseconds for AcpAdapter.call. Defaults to 30,000. The normal session prompt
81
+ * path waits separately. Expiry ends the wait while native work may continue. */
82
+ requestTimeoutMs?: number;
83
+ /** Incoming/outgoing stdio line limit in bytes, excluding newline; defaults to 8 MiB. Not applied to connect streams. */
84
+ maxFrameBytes?: number;
85
+ /** Maximum retained stderr lines for spawned agents. Defaults to 200. Zero disables capture and callbacks. */
86
+ stderrTailLines?: number;
87
+ /** Receive redacted stderr lines, including bounded chunks for oversized lines. Callback throws are ignored. */
88
+ onStderr?: (line: string) => void;
89
+ /**
90
+ * Answer `session/request_permission` without surfacing an AHP interaction.
91
+ * Returning undefined (or a kind the agent did not offer) falls back to the
92
+ * interactive path. Auto decisions are reported as `ACP_PERMISSION_AUTO_ANSWERED`
93
+ * notices so they remain auditable.
94
+ */
95
+ autoRespondPermission?: AcpPermissionPolicy;
96
+ /**
97
+ * Receive nonempty resume/load snapshots after identity/configuration capture and before initial overrides.
98
+ * Historical updates have no turn boundaries; title updates instead enter session metadata.
99
+ * Return synchronously. AcpAdapter skips awaiting the callback, and a synchronous throw rejects
100
+ * session open and removes its local indexes.
101
+ */
102
+ onLoadSnapshot?: (binding: SessionBinding, updates: readonly JsonObject[]) => void;
103
+ }
package/src/options.js ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ import { HarnessSdkError } from '@anureo/sdk';
2
+ /** Convert a native failure into an SDK error for AcpAdapter.call to throw. Preserve existing SDK
3
+ * errors. Use the fallback for unmapped JSON-RPC codes and omit the native message and data.
4
+ * Other failures become ACP_CONNECTION_CLOSED with an unknown outcome. The backend also uses
5
+ * this classification when reporting configuration, review and submission failures. */
6
+ export declare function errorFromNative(error: unknown, fallback?: string): HarnessSdkError;
@@ -0,0 +1,22 @@
1
+ import * as acp from '@agentclientprotocol/sdk';
2
+ import { HarnessSdkError } from '@anureo/sdk';
3
+ /** Convert a native failure into an SDK error for AcpAdapter.call to throw. Preserve existing SDK
4
+ * errors. Use the fallback for unmapped JSON-RPC codes and omit the native message and data.
5
+ * Other failures become ACP_CONNECTION_CLOSED with an unknown outcome. The backend also uses
6
+ * this classification when reporting configuration, review and submission failures. */
7
+ export function errorFromNative(error, fallback = 'ACP_REQUEST_FAILED') {
8
+ if (error instanceof HarnessSdkError)
9
+ return error;
10
+ if (error instanceof acp.RequestError) {
11
+ const code = nativeErrorCodes.get(error.code) ?? fallback;
12
+ return new HarnessSdkError(code, code, false, false, { nativeCode: error.code });
13
+ }
14
+ return new HarnessSdkError('ACP_CONNECTION_CLOSED', 'ACP connection closed', false, true);
15
+ }
16
+ // Keep the private-method fallback separate from protocol codes with shared SDK meanings.
17
+ const nativeErrorCodes = new Map([
18
+ [-32601, 'CAPABILITY_UNSUPPORTED'],
19
+ [-32602, 'INVALID_PARAMS'],
20
+ [-32000, 'AUTH_REQUIRED'],
21
+ [-32002, 'RESOURCE_NOT_FOUND'],
22
+ ]);
package/src/stdio.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { AcpAdapterOptions, AcpChannel } from "./options.js";
2
+ /**
3
+ * Start the owned stdio channel used by AcpAdapter.initialize when connect is omitted.
4
+ * Reject with HARNESS_UNAVAILABLE for a missing command or failed spawn. Resolve after spawning
5
+ * so initialize can negotiate ACP over the returned stream and await channel disposal on teardown.
6
+ * AcpAdapter.withDiagnostics reads the tail captured so far when annotating handshake or
7
+ * connection-closure failures.
8
+ */
9
+ export declare function processStream(options: AcpAdapterOptions): Promise<AcpChannel>;