@aibolabs/acp-adapter 0.0.0-stage → 0.1.8

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/transport.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type AcpMessage = { jsonrpc: '2.0'; id?: string | number; method?: string; params?: unknown; result?: unknown; error?: { code: number; message: string; data?: unknown } };
2
+ export declare class AcpTransport {
3
+ constructor(options: { command: string; args?: string[]; cwd?: string; spawnProcess?: typeof import('node:child_process').spawn; requestTimeoutMs?: number; label?: string });
4
+ readonly label: string;
5
+ readonly closed: boolean;
6
+ readonly stderr: string;
7
+ start(): this;
8
+ onRequest(handler: (message: AcpMessage) => boolean | void): () => void;
9
+ onNotification(handler: (message: AcpMessage) => void): () => void;
10
+ request(method: string, params: unknown, timeoutMs?: number): Promise<any>;
11
+ notify(method: string, params: unknown): void;
12
+ respond(id: string | number, result: unknown): void;
13
+ respondError(id: string | number, code: number, message: string): void;
14
+ close(options?: { forceAfterMs?: number }): Promise<void>;
15
+ }
package/transport.mjs ADDED
@@ -0,0 +1,191 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+
4
+ const MAX_FRAME_BYTES = 8 * 1024 * 1024;
5
+ const MAX_PROMPT_BYTES = 32 * 1024 * 1024;
6
+ const MAX_STDERR_BYTES = 64 * 1024;
7
+
8
+ /**
9
+ * NDJSON JSON-RPC transport to an ACP agent process. Bounded frames, write backpressure
10
+ * and a stderr tail; every failure settles pending requests and emits `transport/closed`.
11
+ * `label` names the agent in error messages (for example "Cursor ACP").
12
+ */
13
+ export class AcpTransport {
14
+ constructor({ command, args = [], cwd, spawnProcess = spawn, requestTimeoutMs = 30_000, label = 'ACP' } = {}) {
15
+ if (typeof command !== 'string' || !command) throw new Error('ACP transport requires an agent command');
16
+ this.label = label;
17
+ this.command = command;
18
+ this.args = args;
19
+ this.cwd = cwd;
20
+ this.spawnProcess = spawnProcess;
21
+ this.requestTimeoutMs = requestTimeoutMs;
22
+ this.nextId = 1;
23
+ this.pending = new Map();
24
+ this.requestHandlers = new Set();
25
+ this.notificationHandlers = new Set();
26
+ this.stderr = '';
27
+ this.closed = false;
28
+ this.decoder = new StringDecoder('utf8');
29
+ this.readBuffer = '';
30
+ this.writeQueue = [];
31
+ this.queuedWriteBytes = 0;
32
+ this.writePaused = false;
33
+ }
34
+
35
+ start() {
36
+ if (this.child) throw new Error(`${this.label} process already started`);
37
+ const child = this.spawnProcess(this.command, this.args, {
38
+ cwd: this.cwd,
39
+ env: process.env,
40
+ shell: false,
41
+ stdio: ['pipe', 'pipe', 'pipe'],
42
+ });
43
+ this.child = child;
44
+ child.stdout.on('data', chunk => this.#receiveData(chunk));
45
+ child.stdout.once('end', () => {
46
+ const tail = this.decoder.end();
47
+ if (tail) this.#receiveData(tail);
48
+ if (!this.closed && this.readBuffer.length) this.#receiveLine(this.readBuffer.replace(/\r$/, ''));
49
+ this.readBuffer = '';
50
+ if (!this.closed) this.#finish(new Error(`${this.label} stdout closed`));
51
+ });
52
+ child.stdout.once('error', error => this.#finish(error));
53
+ child.stdin.once('error', error => this.#finish(error));
54
+ child.stdin.on('drain', () => this.#flushWrites());
55
+ child.stderr.on('data', chunk => {
56
+ this.stderr = `${this.stderr}${String(chunk)}`.slice(-MAX_STDERR_BYTES);
57
+ });
58
+ child.once('error', error => this.#finish(error));
59
+ child.once('exit', (code, signal) => {
60
+ const detail = this.stderr.trim().slice(-2_000);
61
+ this.#finish(new Error(`${this.label} exited (${signal ?? code ?? 'unknown'})${detail ? `: ${detail}` : ''}`), false);
62
+ });
63
+ return this;
64
+ }
65
+
66
+ onRequest(handler) { this.requestHandlers.add(handler); return () => this.requestHandlers.delete(handler); }
67
+ onNotification(handler) { this.notificationHandlers.add(handler); return () => this.notificationHandlers.delete(handler); }
68
+
69
+ request(method, params, timeoutMs = this.requestTimeoutMs) {
70
+ const id = this.nextId++;
71
+ return new Promise((resolve, reject) => {
72
+ const timer = setTimeout(() => {
73
+ this.pending.delete(id);
74
+ reject(new Error(`${this.label} ${method} timed out`));
75
+ }, timeoutMs);
76
+ this.pending.set(id, { resolve, reject, timer, method });
77
+ try { this.#write({ jsonrpc: '2.0', id, method, params }); }
78
+ catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
79
+ });
80
+ }
81
+
82
+ notify(method, params) { this.#write({ jsonrpc: '2.0', method, params }); }
83
+ respond(id, result) { this.#write({ jsonrpc: '2.0', id, result }); }
84
+ respondError(id, code, message) { this.#write({ jsonrpc: '2.0', id, error: { code, message } }); }
85
+
86
+ async close({ forceAfterMs = 2_000 } = {}) {
87
+ if (!this.child || this.closed) return;
88
+ this.closed = true;
89
+ this.readBuffer = '';
90
+ this.writeQueue.length = 0;
91
+ this.queuedWriteBytes = 0;
92
+ this.child.stdin.end();
93
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
94
+ this.child.kill('SIGTERM');
95
+ await new Promise(resolve => {
96
+ const timer = setTimeout(() => { this.child?.kill('SIGKILL'); resolve(); }, forceAfterMs);
97
+ this.child?.once('exit', () => { clearTimeout(timer); resolve(); });
98
+ });
99
+ this.#rejectPending(new Error(`${this.label} transport closed`));
100
+ }
101
+
102
+ #write(message) {
103
+ if (!this.child || this.closed || !this.child.stdin.writable) throw new Error(`${this.label} transport is not writable`);
104
+ const frame = `${JSON.stringify(message)}\n`;
105
+ const bytes = Buffer.byteLength(frame);
106
+ const limit = message.method === 'session/prompt' ? MAX_PROMPT_BYTES : MAX_FRAME_BYTES;
107
+ if (bytes > limit) throw new Error(`${this.label} frame exceeds ${limit / 1024 / 1024} MiB`);
108
+ if (this.writePaused) {
109
+ if (this.queuedWriteBytes + bytes > MAX_PROMPT_BYTES) throw new Error(`${this.label} write queue exceeds 32 MiB`);
110
+ this.writeQueue.push(frame);
111
+ this.queuedWriteBytes += bytes;
112
+ return;
113
+ }
114
+ this.writePaused = !this.child.stdin.write(frame);
115
+ }
116
+
117
+ #flushWrites() {
118
+ if (this.closed || !this.child?.stdin.writable) return;
119
+ this.writePaused = false;
120
+ while (!this.writePaused && this.writeQueue.length) {
121
+ const frame = this.writeQueue.shift();
122
+ this.queuedWriteBytes -= Buffer.byteLength(frame);
123
+ this.writePaused = !this.child.stdin.write(frame);
124
+ }
125
+ }
126
+
127
+ #receiveData(chunk) {
128
+ if (this.closed) return;
129
+ this.readBuffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk);
130
+ if (Buffer.byteLength(this.readBuffer) > MAX_FRAME_BYTES && !this.readBuffer.includes('\n')) {
131
+ this.#finish(new Error(`${this.label} frame exceeds 8 MiB`));
132
+ return;
133
+ }
134
+ let newline;
135
+ while (!this.closed && (newline = this.readBuffer.indexOf('\n')) >= 0) {
136
+ const line = this.readBuffer.slice(0, newline).replace(/\r$/, '');
137
+ this.readBuffer = this.readBuffer.slice(newline + 1);
138
+ this.#receiveLine(line);
139
+ }
140
+ if (!this.closed && Buffer.byteLength(this.readBuffer) > MAX_FRAME_BYTES) this.#finish(new Error(`${this.label} frame exceeds 8 MiB`));
141
+ }
142
+
143
+ #receiveLine(line) {
144
+ if (Buffer.byteLength(line) > MAX_FRAME_BYTES) return this.#finish(new Error(`${this.label} frame exceeds 8 MiB`));
145
+ let message;
146
+ try { message = JSON.parse(line); }
147
+ catch { return this.#finish(new Error(`${this.label} emitted malformed JSON`)); }
148
+ if (message?.jsonrpc !== '2.0') return this.#finish(new Error(`${this.label} emitted an invalid JSON-RPC envelope`));
149
+ if (message.id !== undefined && message.method === undefined) {
150
+ const pending = this.pending.get(message.id);
151
+ if (!pending) return;
152
+ this.pending.delete(message.id);
153
+ clearTimeout(pending.timer);
154
+ if (message.error) pending.reject(Object.assign(new Error(message.error.message ?? `${pending.method} failed`), { code: message.error.code, data: message.error.data }));
155
+ else pending.resolve(message.result);
156
+ return;
157
+ }
158
+ if (typeof message.method !== 'string') return;
159
+ if (message.id !== undefined) {
160
+ try {
161
+ for (const handler of this.requestHandlers) if (handler(message) === true) return;
162
+ } catch (error) {
163
+ this.respondError(message.id, -32603, String(error?.message ?? error).slice(0, 2_000));
164
+ return;
165
+ }
166
+ this.respondError(message.id, -32601, 'Method not found');
167
+ return;
168
+ }
169
+ try { for (const handler of this.notificationHandlers) handler(message); }
170
+ catch (error) { this.#finish(error instanceof Error ? error : new Error(String(error))); }
171
+ }
172
+
173
+ #finish(error, terminate = true) {
174
+ if (this.closed && !this.pending.size) return;
175
+ this.closed = true;
176
+ this.readBuffer = '';
177
+ this.writeQueue.length = 0;
178
+ this.queuedWriteBytes = 0;
179
+ if (terminate && this.child && this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGTERM');
180
+ this.#rejectPending(error);
181
+ for (const handler of this.notificationHandlers) {
182
+ try { handler({ jsonrpc: '2.0', method: 'transport/closed', params: { message: error.message } }); }
183
+ catch { /* transport is already terminal */ }
184
+ }
185
+ }
186
+
187
+ #rejectPending(error) {
188
+ for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(error); }
189
+ this.pending.clear();
190
+ }
191
+ }
package/worker.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { AcpExtension, AcpSession } from './session.js';
2
+
3
+ export declare const ACP_AGENT_SCHEMA: 'aibo.acp-agent/v1';
4
+ /** Contents of a configuration-only plugin's `acp.json`. */
5
+ export type AcpAgentConfig = {
6
+ schema: 'aibo.acp-agent/v1';
7
+ label: string;
8
+ /** Must name an `executable` entry of plugin.json `executableDependencies`. */
9
+ command?: string;
10
+ /** SDK 0.1.6: plugin-relative JS entry, executed by the Worker's private Node. Mutually exclusive with command. */
11
+ launch?: { kind: 'node'; entry: string };
12
+ args?: string[];
13
+ /** Native mode IDs for host interaction modes; `edit` is the write mode. */
14
+ modes: { ask?: string; plan?: string; edit?: string };
15
+ authMethodId?: string;
16
+ clientMeta?: Record<string, unknown>;
17
+ persistsEmptySessions?: boolean;
18
+ /** SDK 0.1.7: parameters are discovered after selecting a model. */
19
+ parameterScope?: 'current-model';
20
+ requestPrefix?: string;
21
+ };
22
+ export declare function acpAgentConfig(config: unknown, manifest: Record<string, any>): AcpAgentConfig & { args: string[] };
23
+ export declare function agentManagedProfile(label: string, modes: AcpAgentConfig['modes']): AcpExtension['validateExecutionProfile'];
24
+ export declare function extensionFromConfig(config: AcpAgentConfig & { args: string[] }, manifest: Record<string, any>, manifestUrl?: URL): AcpExtension;
25
+ export declare function settingsInstructions(label: string): (settings: unknown) => string;
26
+ export declare function serveAcpAgent(options: { manifestUrl: URL; configUrl?: URL; extension?: AcpExtension; additionalInstructions?: (settings: unknown) => string }): AcpSession;
package/worker.mjs ADDED
@@ -0,0 +1,253 @@
1
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { serveCapability } from '@aibolabs/capability-runtime/stdio';
5
+ import { createHostToolChannel, hostToolDefinitions, createHostToolMcpBridge } from '@aibolabs/capability-runtime/host-tools';
6
+ import { AcpSession, BASE_CAPABILITIES, object, pluginError } from './session.mjs';
7
+
8
+ export const ACP_AGENT_SCHEMA = 'aibo.acp-agent/v1';
9
+ // `auto` is the edit mode reviewed by the agent's own classifier (approvalReviewer auto-review).
10
+ const INTERACTIONS = ['ask', 'plan', 'edit', 'auto'];
11
+
12
+ /**
13
+ * Validates a plugin's `acp.json` against its manifest. Invalid configuration stops the Worker
14
+ * before the Runtime handshake, so the host reports a startup failure instead of a broken session.
15
+ */
16
+ export function acpAgentConfig(config, manifest) {
17
+ const c = object(config);
18
+ const fail = message => { throw new Error(`Invalid acp.json: ${message}`); };
19
+ if (c.schema !== ACP_AGENT_SCHEMA) fail(`schema must be ${ACP_AGENT_SCHEMA}`);
20
+ if (typeof c.label !== 'string' || !c.label.trim() || c.label.length > 64) fail('label must be 1-64 characters');
21
+ const dependencies = Array.isArray(manifest.executableDependencies) ? manifest.executableDependencies : [];
22
+ // An external command or a plugin-owned JS entry, never both.
23
+ if (c.launch !== undefined) {
24
+ const launch = object(c.launch);
25
+ if (c.command !== undefined || launch.kind !== 'node' || Object.keys(launch).some(key => !['kind', 'entry'].includes(key))) fail('launch must be { kind: node, entry } without command');
26
+ if (typeof launch.entry !== 'string' || launch.entry.length > 1024 || /[\\\\:\x00-\x1f]/.test(launch.entry)
27
+ || launch.entry.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part))
28
+ || !/\.(mjs|cjs|js)$/.test(launch.entry)) fail('launch.entry must be a package-relative JavaScript path');
29
+ } else if (typeof c.command !== 'string' || !dependencies.some(entry => entry?.kind === 'executable' && entry.name === c.command)) fail('command must name an executable declared in plugin.json executableDependencies');
30
+ const args = c.args ?? [];
31
+ if (!Array.isArray(args) || args.length > 32 || !args.every(arg => typeof arg === 'string' && arg.length <= 1024)) fail('args must be at most 32 strings');
32
+ const modes = object(c.modes);
33
+ const mapped = INTERACTIONS.filter(interaction => modes[interaction] !== undefined);
34
+ if (!mapped.length || Object.keys(modes).some(key => !INTERACTIONS.includes(key)) || !mapped.every(interaction => typeof modes[interaction] === 'string' && modes[interaction])) fail('modes must map ask, plan, edit or auto to native mode IDs');
35
+ if (c.authMethodId !== undefined && (typeof c.authMethodId !== 'string' || !c.authMethodId)) fail('authMethodId must be a non-empty string');
36
+ if (c.clientMeta !== undefined && (!c.clientMeta || typeof c.clientMeta !== 'object' || Array.isArray(c.clientMeta) || JSON.stringify(c.clientMeta).length > 8192)) fail('clientMeta must be an object under 8 KiB');
37
+ if (c.parameterScope !== undefined && c.parameterScope !== 'current-model') fail('parameterScope must be current-model for ACP session options');
38
+ if (c.persistsEmptySessions !== undefined && typeof c.persistsEmptySessions !== 'boolean') fail('persistsEmptySessions must be a boolean');
39
+ if (c.requestPrefix !== undefined && !/^[a-z][a-z0-9-]{0,31}$/.test(c.requestPrefix)) fail('requestPrefix must be a lowercase identifier');
40
+ if (c.elicitation !== undefined && c.elicitation !== true) fail('elicitation must be true when present');
41
+ const contribution = manifest.contributions?.find(entry => entry.kind === 'capabilityProvider' && entry.scope === 'session');
42
+ // Answering elicitations is the manifest's user-input.respond operation; without it the agent is never asked.
43
+ if (c.elicitation && !contribution?.operations?.some(operation => operation.capability?.id === `${manifest.pluginId}.user-input.respond`)) fail('elicitation requires the user-input.respond operation in plugin.json');
44
+ const approvalOptions = c.approvalOptions ?? [];
45
+ const controls = manifest.contributions?.find(entry => entry.kind === 'capabilityProvider' && entry.scope === 'session')?.sessionControls ?? [];
46
+ if (!Array.isArray(approvalOptions) || approvalOptions.length > 16) fail('approvalOptions must be at most 16 entries');
47
+ for (const entry of approvalOptions) {
48
+ const option = object(entry);
49
+ if (Object.keys(option).some(key => !['optionId', 'toolKind', 'label', 'sessionControl', 'contextReset'].includes(key))) fail('approvalOptions entries take optionId, toolKind, label, sessionControl and contextReset');
50
+ if (option.contextReset !== undefined && (option.contextReset !== true || option.sessionControl === undefined)) fail('approvalOptions contextReset must be true and needs a sessionControl');
51
+ if (typeof option.optionId !== 'string' || !option.optionId || option.optionId.length > 256) fail('approvalOptions optionId must be a native option ID');
52
+ if (option.toolKind !== undefined && (typeof option.toolKind !== 'string' || !option.toolKind)) fail('approvalOptions toolKind must be an ACP tool kind');
53
+ if (option.label !== undefined && (typeof option.label !== 'string' || !option.label.trim() || option.label.length > 80)) fail('approvalOptions label must be 1-80 characters');
54
+ if (option.label === undefined && option.sessionControl === undefined) fail('approvalOptions entries need a label or a sessionControl');
55
+ if (option.sessionControl !== undefined) {
56
+ const control = controls.find(candidate => candidate.id === option.sessionControl);
57
+ if (!control) fail(`approvalOptions sessionControl ${option.sessionControl} is not declared in plugin.json`);
58
+ if (!modes[controlInteraction(control.profile)]) fail(`approvalOptions sessionControl ${option.sessionControl} has no mapped native mode`);
59
+ if (!controls.some(candidate => candidate.transitions?.includes(option.sessionControl))) fail(`no session control declares a transition to ${option.sessionControl}`);
60
+ }
61
+ }
62
+ return { ...c, args, modes, approvalOptions };
63
+ }
64
+
65
+ /** The `modes` key a session control profile runs in: Auto is edit reviewed by the agent. */
66
+ function controlInteraction(profile) {
67
+ const p = object(profile);
68
+ return p.interactionMode === 'edit' && p.approvalReviewer === 'auto-review' ? 'auto' : p.interactionMode;
69
+ }
70
+
71
+ /**
72
+ * Host execution profiles for agent-managed providers: the native mode owns file, command and
73
+ * network permissions; edit asks the user on request, ask/plan are read-only without approvals.
74
+ */
75
+ export function agentManagedProfile(label, modes) {
76
+ return (profile, permissions) => {
77
+ const p = object(profile);
78
+ if (p.schema !== 'aibo.execution-profile/v1') throw pluginError('invalid_input', `${label} requires execution profile v1`);
79
+ const mode = modes[controlInteraction(p)];
80
+ if (!mode) throw pluginError('invalid_input', `${label} requires a supported interaction mode`);
81
+ if (!permissions.includes('workspace.read')) throw pluginError('permission_denied', `${label} requires workspace.read`);
82
+ if (p.model != null && (typeof p.model !== 'string' || !p.model.trim())) throw pluginError('invalid_input', `${label} model must be a non-empty reference`);
83
+ if (p.reasoningEffort != null && (typeof p.reasoningEffort !== 'string' || !p.reasoningEffort)) throw pluginError('invalid_input', `${label} reasoning selection must be a non-empty ID`);
84
+ if (p.interactionMode === 'edit') {
85
+ if (p.filesystemPolicy !== 'agent-managed' || p.commandPolicy !== 'agent-managed') throw pluginError('unsupported', `${label} edit mode requires provider-managed file and command permissions`);
86
+ if (!['user', 'auto-review'].includes(p.approvalReviewer) || p.approvalPolicy !== 'on-request') throw pluginError('unsupported', `${label} edit mode requires on-request approval`);
87
+ } else if (p.filesystemPolicy !== 'read-only' || p.commandPolicy !== 'disabled' || p.approvalPolicy !== 'never' || p.approvalReviewer !== 'none') {
88
+ throw pluginError('unsupported', `${label} ask and plan modes require read-only files, disabled commands, and no approvals`);
89
+ }
90
+ if (p.networkPolicy !== 'agent-managed') throw pluginError('unsupported', `${label} requires provider-managed network permissions`);
91
+ return { mode, profile: p };
92
+ };
93
+ }
94
+
95
+ /** Builds the session extension a configuration-only plugin runs with. */
96
+ export function extensionFromConfig(config, manifest, manifestUrl) {
97
+ let command = config.command, args = config.args;
98
+ if (config.launch) {
99
+ if (!manifestUrl) throw new Error('Package launch requires manifestUrl');
100
+ const root = realpathSync(path.dirname(fileURLToPath(manifestUrl)));
101
+ const entry = realpathSync(path.join(root, config.launch.entry));
102
+ const relative = path.relative(root, entry);
103
+ if (relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative) || !statSync(entry).isFile()) throw new Error('ACP launch entry escapes plugin package');
104
+ command = process.execPath;
105
+ args = [entry, ...args];
106
+ }
107
+ return {
108
+ label: config.label,
109
+ command,
110
+ args,
111
+ authMethodId: config.authMethodId,
112
+ clientMeta: config.clientMeta,
113
+ parameterScope: config.parameterScope,
114
+ persistsEmptySessions: config.persistsEmptySessions ?? true,
115
+ requestPrefix: config.requestPrefix ?? 'acp',
116
+ recoverySchema: `${manifest.pluginId}.recovery`,
117
+ namespace: manifest.pluginId,
118
+ capabilities: BASE_CAPABILITIES,
119
+ // A plugin without an edit mapping never runs write-authorized turns.
120
+ writableMode: config.modes.edit ?? null,
121
+ writableModes: [config.modes.edit, config.modes.auto].filter(Boolean),
122
+ validateExecutionProfile: agentManagedProfile(config.label, config.modes),
123
+ approvalChoices: approvalChoices(config, manifest),
124
+ elicitation: config.elicitation === true,
125
+ };
126
+ }
127
+
128
+ /** Resolves `approvalOptions` to the native mode and profile patch each session-control effect selects. */
129
+ function approvalChoices(config, manifest) {
130
+ const controls = manifest.contributions.find(entry => entry.kind === 'capabilityProvider' && entry.scope === 'session')?.sessionControls ?? [];
131
+ return (config.approvalOptions ?? []).map(({ optionId, toolKind, label, sessionControl, contextReset }) => {
132
+ const control = sessionControl && controls.find(candidate => candidate.id === sessionControl);
133
+ return { optionId, toolKind, label, ...(control ? { sessionControl, contextReset: contextReset === true, mode: config.modes[controlInteraction(control.profile)], profile: control.profile } : {}) };
134
+ });
135
+ }
136
+
137
+ /** Per-turn additional instructions from the optional `additionalInstructions` setting. */
138
+ export function settingsInstructions(label) {
139
+ return settings => {
140
+ if (settings == null) return '';
141
+ const value = object(settings), values = object(value.values);
142
+ if (value.schema !== 'aibo.agent-settings/v1' || value.version !== 1) throw pluginError('invalid_input', `${label} received invalid agent settings`);
143
+ if (values.additionalInstructions === undefined) return '';
144
+ if (typeof values.additionalInstructions !== 'string' || values.additionalInstructions.length > 8_000) throw pluginError('invalid_input', `${label} received invalid agent settings`);
145
+ return values.additionalInstructions;
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Serves an ACP agent as an Aibo Runtime 2.1 session provider. Optional features use the
151
+ * manifest's `<pluginId>.<feature>` capability IDs. Configuration-only plugins pass `configUrl`
152
+ * (`acp.json`); plugins with agent-specific code pass their own `extension`.
153
+ */
154
+ export function serveAcpAgent({ manifestUrl, configUrl, extension, additionalInstructions }) {
155
+ const manifest = JSON.parse(readFileSync(manifestUrl, 'utf8'));
156
+ const contribution = manifest.contributions.find(entry => entry.kind === 'capabilityProvider' && entry.scope === 'session');
157
+ if (!contribution) throw new Error('plugin.json has no session capability provider');
158
+ const configured = extension ?? extensionFromConfig(acpAgentConfig(JSON.parse(readFileSync(configUrl, 'utf8')), manifest), manifest, manifestUrl);
159
+ // The declared approval.respond variant decides how approvals are answered: by option or by decision.
160
+ const approvalOperation = contribution.operations.find(operation => operation.capability.id === `${manifest.pluginId}.approval.respond`);
161
+ const active = { ...configured, approvalOptions: approvalOperation?.inputSchema?.properties?.optionId !== undefined };
162
+ if (configured.approvalChoices?.length && !active.approvalOptions) throw new Error('Invalid acp.json: approvalOptions require the { requestId, optionId } approval.respond input');
163
+ const label = active.label, feature = name => `${manifest.pluginId}.${name}`;
164
+ const instructions = additionalInstructions ?? settingsInstructions(label);
165
+ let owner, bridge;
166
+ const hostTools = createHostToolChannel();
167
+ const session = new AcpSession({ extension: active, pluginVersion: manifest.version, emit(event) { owner?.tools.emit(event); } });
168
+ async function closeBridge() { const old = bridge; bridge = undefined; session.hostToolsRegistered = false; await old?.close(); }
169
+
170
+ function inputOf(request) {
171
+ if (!request.input || typeof request.input !== 'object' || Array.isArray(request.input)) throw Object.assign(new Error('Object input required'), { kind: 'invalid_input' });
172
+ return request.input;
173
+ }
174
+ function contextOf(request) {
175
+ const context = request.context;
176
+ if (request.scope?.kind !== 'session' || typeof request.scope.id !== 'string' || !request.scope.id || typeof context?.workspaceId !== 'string' || !context.workspaceId || typeof context.workspacePath !== 'string' || !context.workspacePath || !Array.isArray(context.permissions) || !context.permissions.includes('workspace.read')) {
177
+ throw Object.assign(new Error(`${label} requires a trusted session workspace`), { kind: 'permission_denied' });
178
+ }
179
+ return context;
180
+ }
181
+ async function commandDirectory() {
182
+ const output = await session.commands();
183
+ return { ...output, recovery: session.recovery(), capabilities: session.capabilities(),
184
+ commands: output.commands.map(command => ({ ...command, insertionText: `/${command.name} ` })),
185
+ };
186
+ }
187
+
188
+ async function invoke(request, tools) {
189
+ if (owner) throw Object.assign(new Error(`${label} session invocation already running`), { kind: 'busy' });
190
+ const context = contextOf(request);
191
+ const input = inputOf(request);
192
+ const abort = () => { if (request.capability === 'aibo.session.turn' || request.capability === 'aibo.session.turn.write') void session.cancel(); };
193
+ tools.signal.addEventListener('abort', abort, { once: true });
194
+ let endHostTools = () => {};
195
+ owner = { request, tools };
196
+ try {
197
+ endHostTools = hostTools.begin(request, tools, () => session.sessionId);
198
+ if (request.capability === 'aibo.session.open') {
199
+ const definitions = hostToolDefinitions(context);
200
+ if (definitions.length && !bridge) {
201
+ bridge = await createHostToolMcpBridge({ definitions, call: hostTools.call });
202
+ const previousName = input.recovery?.data?.hostMcpServerName;
203
+ if (input.mode === 'resume' && /^aibo-[a-f0-9]{16}$/.test(previousName ?? '')) bridge.configuration.name = previousName;
204
+ }
205
+ const mcpServers = bridge ? [{ ...bridge.configuration, env: Object.entries(bridge.configuration.env).map(([name, value]) => ({ name, value })) }] : [];
206
+ try {
207
+ await session.open({ mode: input.mode, workspaceId: context.workspaceId, workspacePath: context.workspacePath, executionProfile: input.executionProfile, recovery: input.recovery, permissions: context.permissions, mcpServers,
208
+ hostMcpTools: bridge ? definitions.filter(tool => tool.annotations?.readOnlyHint === true && tool.annotations?.destructiveHint === false).map(tool => ({ providerIdentifier: bridge.configuration.name, toolName: tool.name })) : [] });
209
+ // ACP may initialize/list MCP tools lazily when the first prompt starts.
210
+ // Successful session/new or session/load registers this session's servers.
211
+ session.hostToolsRegistered = !!bridge;
212
+ return session.snapshot();
213
+ } catch (error) { await session.close(); await closeBridge(); throw error; }
214
+ }
215
+ if (request.capability === feature('command.list')) return await commandDirectory();
216
+ if (request.capability === feature('model.reasoning')) return await session.configure('reasoning', input);
217
+ if (request.capability === feature('model.context-window')) return await session.configure('context', input);
218
+ if (request.capability === feature('model.select')) return await session.models(input);
219
+ if (request.capability === 'aibo.session.close') { try { return await session.close(); } finally { await closeBridge(); } }
220
+ if (request.capability === 'aibo.session.turn' || request.capability === 'aibo.session.turn.write') {
221
+ if (!context.turnId || typeof input.text !== 'string' || !input.text.trim()) throw Object.assign(new Error(`${label} turn requires text and turn identity`), { kind: 'invalid_input' });
222
+ if (request.capability.endsWith('.write') && !context.permissions.includes('workspace.write')) throw Object.assign(new Error(`${label} write turn requires workspace.write`), { kind: 'permission_denied' });
223
+ const writable = request.capability.endsWith('.write');
224
+ return await session.prompt({ text: input.text, attachments: input.attachments, turnId: context.turnId, additionalInstructions: instructions(context.settings), writable });
225
+ }
226
+ throw Object.assign(new Error(`Unsupported ${label} capability`), { kind: 'unsupported' });
227
+ } finally {
228
+ endHostTools();
229
+ tools.signal.removeEventListener('abort', abort);
230
+ owner = undefined;
231
+ }
232
+ }
233
+
234
+ async function control(request, { invocation }) {
235
+ if (!owner || owner.request.invocationId !== invocation.invocationId) throw Object.assign(new Error(`No matching ${label} invocation`), { kind: 'invalid_input' });
236
+ const input = inputOf(request);
237
+ if (request.capability === 'aibo.session.tool.respond') return hostTools.respond(input);
238
+ if (request.capability === feature('command.list')) return await commandDirectory();
239
+ if (request.capability.startsWith(feature('model.'))) throw Object.assign(new Error(`${label} model configuration requires an idle session`), { kind: 'busy' });
240
+ if (request.capability === 'aibo.session.cancel') return session.cancel();
241
+ if (request.capability === feature('approval.respond')) return session.respondApproval(input.requestId, input.optionId !== undefined ? { optionId: input.optionId } : input.decision);
242
+ if (request.capability === feature('user-input.respond')) return session.respondUserInput(input.requestId, input.answers);
243
+ throw Object.assign(new Error(`Unsupported ${label} control`), { kind: 'unsupported' });
244
+ }
245
+
246
+ serveCapability({
247
+ protocol: '2.1', pluginId: manifest.pluginId, pluginVersion: manifest.version, contributionId: contribution.id,
248
+ operations: contribution.operations.map(operation => ({ capability: operation.capability.id, version: operation.capability.version, operationId: operation.id })),
249
+ invoke, control,
250
+ });
251
+ process.stdin.on('end', () => { void session.close(); void closeBridge(); });
252
+ return session;
253
+ }