@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/session.mjs ADDED
@@ -0,0 +1,721 @@
1
+ import { imageInput } from './image-input.mjs';
2
+ import { AcpTransport } from './transport.mjs';
3
+ import { modelParameters, selectValues } from './config.mjs';
4
+ import { elicitationForm } from './elicitation.mjs';
5
+
6
+ // Reply that dismisses a pending request of each kind: permissions select no option, elicitations cancel.
7
+ const CANCELLED = { outcome: { outcome: 'cancelled' } };
8
+ const cancelReply = pending => pending?.cancelled ?? CANCELLED;
9
+
10
+ /** Candidate capabilities; native negotiation narrows these before returning them to the host. */
11
+ export const BASE_CAPABILITIES = [
12
+ 'session.create', 'session.resume', 'session.close', 'turn.send', 'turn.cancel',
13
+ 'stream.text', 'approval.respond', 'command.list',
14
+ ];
15
+
16
+ export function pluginError(kind, message) { return Object.assign(new Error(message), { kind }); }
17
+ export function object(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
18
+ export function bounded(value, max = 12_000) {
19
+ const text = typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value);
20
+ return text.length > max ? `${text.slice(0, max)}…` : text;
21
+ }
22
+ const count = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
23
+ function toolPayload(label, tool, update = {}) {
24
+ const content = update.content ?? tool.content ?? [];
25
+ const text = Array.isArray(content) ? content.map(part => part?.content?.text ?? part?.text ?? (part?.type === 'diff' ? `${part.path ?? ''}\n${part.newText ?? ''}` : '')).filter(Boolean).join('\n') : bounded(content);
26
+ const rawInput = object(update.rawInput ?? tool.rawInput);
27
+ return {
28
+ itemId: tool.toolCallId,
29
+ itemType: tool.kind ?? 'other',
30
+ summary: tool.title ?? `${label} tool`,
31
+ ...(text ? { [update.status === 'completed' ? 'output' : 'delta']: bounded(text) } : {}),
32
+ ...(typeof rawInput.command === 'string' ? { command: bounded(rawInput.command, 4_000) } : {}),
33
+ ...(typeof rawInput.cwd === 'string' ? { cwd: bounded(rawInput.cwd, 4_000) } : {}),
34
+ status: update.status ?? tool.status ?? 'pending',
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Agent-specific behaviour plugged into {@link AcpSession}. Only `label`, `command`,
40
+ * `recoverySchema`, `namespace`, `writableMode` and `validateExecutionProfile` are required.
41
+ * @typedef {object} AcpExtension
42
+ */
43
+ const EXTENSION_DEFAULTS = {
44
+ args: [],
45
+ clientName: 'aibo-acp',
46
+ clientMeta: undefined,
47
+ authMethodId: undefined,
48
+ requestPrefix: 'acp',
49
+ capabilities: BASE_CAPABILITIES,
50
+ // Agents that persist empty sessions can always be loaded; see `open`.
51
+ persistsEmptySessions: true,
52
+ commandCategory: () => 'agent',
53
+ parameterized: (_config, result) => result.configOptions.some(option => ['thought_level', 'model_config'].includes(option.category)),
54
+ subagentFromTool: () => null,
55
+ handleRequest: () => false,
56
+ handleNotification: () => false,
57
+ // Native permission options with host labels or session-control effects; see `approvalOptions`.
58
+ approvalChoices: [],
59
+ // Declares ACP form elicitation and answers it through host questions (user-input.respond).
60
+ elicitation: false,
61
+ };
62
+
63
+ /**
64
+ * Generic ACP client session mapped onto Aibo session capabilities: initialize and
65
+ * authenticate, new/load, mode and model configuration confirmed by the agent, prompts,
66
+ * tool and permission events, cancellation and recovery.
67
+ */
68
+ export class AcpSession {
69
+ constructor({ extension, transportFactory, emit = () => {}, pluginVersion = '0.1.0', cancelGraceMs = 5_000, commandWaitMs = 10_000 } = {}) {
70
+ this.extension = { ...EXTENSION_DEFAULTS, ...extension };
71
+ this.label = this.extension.label;
72
+ this.transportFactory = transportFactory ?? (options => new AcpTransport({ command: this.extension.command, args: this.extension.args, label: `${this.label} ACP`, ...options }));
73
+ this.emit = emit;
74
+ this.pluginVersion = pluginVersion;
75
+ this.cancelGraceMs = cancelGraceMs;
76
+ this.commandWaitMs = commandWaitMs;
77
+ this.commandCatalog = null;
78
+ this.earlyCommands = new Map();
79
+ this.commandWaiters = new Set();
80
+ this.pendingInteractions = new Map();
81
+ this.tools = new Map();
82
+ this.hostPermissionReplies = new Set();
83
+ this.completedTools = new Set();
84
+ this.subagents = new Map();
85
+ // Aibo usage snapshot for this process: live context from usage_update, session totals from prompt results.
86
+ this.usage = {};
87
+ this.phase = 'stopped';
88
+ this.modelConfig = null;
89
+ this.configOptions = [];
90
+ this.parameterized = false;
91
+ // The narrow surface extension hooks use; private state stays in the session.
92
+ const session = this;
93
+ this.hooks = {
94
+ get label() { return session.label; },
95
+ get sessionId() { return session.sessionId; },
96
+ get turnId() { return session.turnId; },
97
+ subagents: this.subagents,
98
+ event: (type, payload, correlation) => this.#event(type, payload, correlation),
99
+ respond: (id, result) => this.transport.respond(id, result),
100
+ /** Registers a pending interaction; answers `cancelled` once 32 are waiting. */
101
+ await: (requestId, rpcId, interaction) => {
102
+ if (this.pendingInteractions.size >= 32) { this.transport.respond(rpcId, cancelReply(interaction)); return false; }
103
+ this.pendingInteractions.set(requestId, { ...interaction, rpcId, turnId: this.turnId });
104
+ return true;
105
+ },
106
+ updateSubagent: (subagent, changes) => this.#updateSubagent(subagent, changes),
107
+ };
108
+ }
109
+
110
+ async open({ mode, workspaceId, workspacePath, executionProfile, recovery, permissions, mcpServers = [], hostMcpTools = [] }) {
111
+ if (this.phase === 'failed' || this.transport?.closed) await this.close();
112
+ if (this.phase !== 'stopped') {
113
+ if (this.sessionId && this.workspaceId === workspaceId) return this.snapshot();
114
+ throw pluginError('busy', `${this.label} session is already open`);
115
+ }
116
+ const policy = this.extension.validateExecutionProfile(executionProfile, permissions);
117
+ const restored = mode === 'resume' ? this.#validateRecovery(recovery, workspaceId, workspacePath) : null;
118
+ this.phase = 'starting';
119
+ this.workspaceId = workspaceId;
120
+ this.workspacePath = workspacePath;
121
+ this.profile = policy.profile;
122
+ this.hostMcpTools = hostMcpTools;
123
+ this.hostMcpServerName = mcpServers[0]?.name;
124
+ this.modeId = policy.mode;
125
+ // Some agents do not persist a newly-created session until it receives a prompt.
126
+ // An explicitly empty binding can then be recreated after a mode change; older or
127
+ // prompted bindings must still load, never silently lose history.
128
+ this.hasPrompt = restored ? restored.hasPrompt !== false : false;
129
+ const loadNative = restored && (this.extension.persistsEmptySessions || this.hasPrompt);
130
+ const transport = this.transportFactory({ cwd: workspacePath }).start();
131
+ this.transport = transport;
132
+ this.removeRequest = transport.onRequest(message => this.transport === transport && this.#handleRequest(message));
133
+ this.removeNotification = transport.onNotification(message => { if (this.transport === transport) this.#handleNotification(message); });
134
+ try {
135
+ this.phase = 'initializing';
136
+ const initialized = await transport.request('initialize', {
137
+ protocolVersion: 1,
138
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, ...(this.extension.elicitation ? { elicitation: { form: {} } } : {}), ...(this.extension.clientMeta ? { _meta: this.extension.clientMeta } : {}) },
139
+ clientInfo: { name: this.extension.clientName, version: this.pluginVersion },
140
+ });
141
+ if (initialized?.protocolVersion !== 1) throw pluginError('incompatible_version', `${this.label} ACP protocol v1 is required`);
142
+ const authMethodId = this.extension.authMethodId;
143
+ if (authMethodId && !initialized.authMethods?.some(method => method.id === authMethodId)) throw pluginError('provider_unavailable', `${this.label} did not advertise ${authMethodId} authentication`);
144
+ this.agentCapabilities = object(initialized.agentCapabilities);
145
+ if (authMethodId) {
146
+ this.phase = 'authenticating';
147
+ await transport.request('authenticate', { methodId: authMethodId });
148
+ }
149
+ this.phase = loadNative ? 'loading' : 'opening';
150
+ let result;
151
+ if (loadNative) {
152
+ if (this.agentCapabilities.loadSession !== true) throw pluginError('unsupported', `This ${this.label} CLI cannot restore ACP sessions`);
153
+ result = await transport.request('session/load', { sessionId: restored.nativeSessionId, cwd: workspacePath, mcpServers }, 90_000);
154
+ this.sessionId = restored.nativeSessionId;
155
+ } else {
156
+ result = await transport.request('session/new', { cwd: workspacePath, mcpServers }, 90_000);
157
+ if (typeof result?.sessionId !== 'string' || !result.sessionId) throw pluginError('invalid_output', `${this.label} did not return a session ID`);
158
+ this.sessionId = result.sessionId;
159
+ }
160
+ if (this.earlyCommands.has(this.sessionId)) this.#readCommands(this.earlyCommands.get(this.sessionId));
161
+ this.earlyCommands.clear();
162
+ this.#readModelConfig(result);
163
+ await this.#selectMode(result, policy.mode);
164
+ const requestedModel = policy.profile.model ?? restored?.modelId;
165
+ if (requestedModel != null) await this.#setModel(requestedModel);
166
+ const sameModel = !policy.profile.model || policy.profile.model === restored?.modelId;
167
+ const level = policy.profile.reasoningEffort ?? (sameModel ? restored?.reasoningEffort : null);
168
+ if (level != null) await this.#setParameter('reasoning', level);
169
+ if (sameModel && restored?.contextWindow != null) await this.#setParameter('context', restored.contextWindow);
170
+ this.phase = 'ready';
171
+ this.#event('session.started', { mode: this.modeId });
172
+ return this.snapshot();
173
+ } catch (error) {
174
+ await this.close();
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ async prompt({ text, turnId, attachments = [], additionalInstructions = '', writable = false }) {
180
+ if (this.phase !== 'ready' || !this.sessionId) throw pluginError('busy', `${this.label} session is not ready`);
181
+ // A mode the agent changed on its own while idle is put back before the next prompt.
182
+ if (this.nativeMode !== this.modeId) await this.#restoreMode();
183
+ const writeMode = this.#writable(this.modeId);
184
+ if (writable !== writeMode) throw pluginError('permission_denied', writable ? `${this.label} write turn requires edit mode` : `${this.label} edit mode requires a write-authorized turn`);
185
+ const images = imageInput(attachments, this.agentCapabilities?.promptCapabilities?.image === true, this.label);
186
+ this.phase = 'prompting';
187
+ this.turnId = turnId;
188
+ this.messageText = '';
189
+ this.reasoningText = '';
190
+ this.messageItemId = null;
191
+ this.reasoningItemId = null;
192
+ this.tools.clear();
193
+ this.hostPermissionReplies.clear();
194
+ this.completedTools.clear();
195
+ this.subagents.clear();
196
+ this.expectedMode = null;
197
+ this.modeViolation = null;
198
+ this.#event('turn.started', {});
199
+ // Agents parse leading slash commands before processing ordinary prompt text.
200
+ // Prefixing settings would turn a native command into a model request.
201
+ const promptText = !/^\s*\/\S+/.test(text) && additionalInstructions.trim() ? `${additionalInstructions.trim()}\n\n${text}` : text;
202
+ this.hasPrompt = true;
203
+ try {
204
+ const result = await this.transport.request('session/prompt', {
205
+ sessionId: this.sessionId,
206
+ prompt: [{ type: 'text', text: promptText }, ...images],
207
+ }, 12 * 60 * 60 * 1_000);
208
+ if (this.messageText) this.#event('message.completed', { itemId: this.messageItemId, text: this.messageText }, { itemId: this.messageItemId });
209
+ if (this.reasoningText) this.#event('reasoning.completed', { itemId: this.reasoningItemId, summary: this.reasoningText }, { itemId: this.reasoningItemId });
210
+ this.#addTurnUsage(result?.usage);
211
+ const stopReason = result?.stopReason;
212
+ const status = this.modeViolation ? 'failed' : stopReason === 'end_turn' || stopReason === 'refusal' ? 'completed'
213
+ : ['cancelled', 'max_tokens', 'max_turn_requests'].includes(stopReason) ? 'interrupted' : 'failed';
214
+ this.#finishSubagents(
215
+ status === 'interrupted' ? 'interrupted' : status === 'failed' ? 'failed' : 'unavailable',
216
+ status === 'interrupted' ? 'Parent turn was interrupted.' : status === 'failed' ? 'Parent turn failed.' : `${this.label} did not provide a final task notification.`,
217
+ );
218
+ this.#event(status === 'failed' ? 'turn.failed' : 'turn.completed', { status, stopReason: stopReason ?? null, ...(this.modeViolation ? { message: this.modeViolation } : {}) });
219
+ return { status, recovery: this.recovery() };
220
+ } catch (error) {
221
+ if (this.phase === 'cancelling' && this.modeViolation) {
222
+ this.#finishSubagents('failed', 'Parent turn failed.');
223
+ this.#event('turn.failed', { status: 'failed', message: this.modeViolation });
224
+ return { status: 'failed', recovery: this.recovery() };
225
+ }
226
+ if (this.phase === 'cancelling') {
227
+ this.#finishSubagents('interrupted', 'Parent turn was cancelled.');
228
+ this.#event('turn.completed', { status: 'interrupted', stopReason: 'cancelled', forced: true });
229
+ return { status: 'interrupted', recovery: this.recovery() };
230
+ }
231
+ this.#finishSubagents('failed', `${this.label} task status became unavailable after a transport failure.`);
232
+ this.#event('turn.failed', { status: 'failed', message: String(error?.message ?? error).slice(0, 2_000) });
233
+ if (this.transport && !this.transport.closed) await this.transport.close();
234
+ throw error;
235
+ } finally {
236
+ clearTimeout(this.cancelTimer);
237
+ if (this.phase !== 'stopped') this.phase = this.transport?.closed ? 'failed' : 'ready';
238
+ this.turnId = null;
239
+ this.expectedMode = null;
240
+ this.pendingInteractions.clear();
241
+ }
242
+ }
243
+
244
+ async cancel() {
245
+ if (!this.sessionId || this.phase !== 'prompting') return { accepted: true };
246
+ for (const [requestId, pending] of this.pendingInteractions) {
247
+ this.transport.respond(pending.rpcId, cancelReply(pending));
248
+ this.#event(pending.kind === 'question' ? 'user_input.resolved' : 'approval.resolved', { requestId, decision: 'cancel' }, { requestId, ...(pending.kind === 'permission' ? { approvalId: pending.rpcId } : {}) });
249
+ }
250
+ this.pendingInteractions.clear();
251
+ this.phase = 'cancelling';
252
+ this.transport.notify('session/cancel', { sessionId: this.sessionId });
253
+ clearTimeout(this.cancelTimer);
254
+ this.cancelTimer = setTimeout(() => {
255
+ if (this.phase === 'cancelling') void this.transport?.close();
256
+ }, this.cancelGraceMs);
257
+ this.cancelTimer.unref?.();
258
+ return { accepted: true };
259
+ }
260
+
261
+ /**
262
+ * Answers an approval with a host decision (`accept` / `cancel`) or, for option approvals, the
263
+ * `{ optionId }` of one offered option. Standard permission requests only ever select the once
264
+ * options; extension approvals encode their own outcome.
265
+ */
266
+ respondApproval(requestId, answer) {
267
+ const pending = this.pendingInteractions.get(requestId);
268
+ if (!pending || pending.turnId !== this.turnId || !(pending.kind === 'permission' || pending.approve)) throw pluginError('invalid_input', `${this.label} approval request is no longer pending`);
269
+ const chosen = typeof answer === 'object' && answer !== null ? pending.offered?.find(option => option.id === answer.optionId) : null;
270
+ if (typeof answer === 'object' && answer !== null && !chosen) throw pluginError('invalid_input', `${this.label} approval option is not offered`);
271
+ const decision = chosen ? (chosen.kind === 'allow' ? 'accept' : 'cancel') : answer;
272
+ // The host has already committed this option's session control; the agent's mode report must match it.
273
+ const transition = chosen && pending.transitions?.get(chosen.id);
274
+ if (transition) this.expectedMode = { mode: transition.mode, profile: { ...this.profile, ...transition.profile } };
275
+ if (pending.kind === 'permission') {
276
+ const kind = decision === 'accept' ? 'allow_once' : 'reject_once';
277
+ const option = chosen ? pending.options.find(candidate => candidate.optionId === chosen.id) : pending.options.find(candidate => candidate.kind === kind);
278
+ if (!option) this.transport.respond(pending.rpcId, { outcome: { outcome: 'cancelled' } });
279
+ else this.transport.respond(pending.rpcId, { outcome: { outcome: 'selected', optionId: option.optionId } });
280
+ } else {
281
+ this.transport.respond(pending.rpcId, pending.approve(chosen ? { optionId: chosen.id } : decision));
282
+ }
283
+ this.pendingInteractions.delete(requestId);
284
+ this.#event('approval.resolved', { requestId, decision }, { requestId, approvalId: pending.rpcId });
285
+ return { resolved: true, recovery: this.recovery(), capabilities: this.capabilities() };
286
+ }
287
+
288
+ /** User input comes from extension requests; their `answer` validates and encodes the reply. */
289
+ respondUserInput(requestId, answers) {
290
+ const pending = this.pendingInteractions.get(requestId);
291
+ if (!pending || pending.turnId !== this.turnId || pending.kind !== 'question' || !pending.answer) throw pluginError('invalid_input', `${this.label} question is no longer pending`);
292
+ this.transport.respond(pending.rpcId, pending.answer(answers));
293
+ this.pendingInteractions.delete(requestId);
294
+ this.#event('user_input.resolved', { requestId }, { requestId });
295
+ return { resolved: true, recovery: this.recovery(), capabilities: this.capabilities() };
296
+ }
297
+
298
+ capabilities() {
299
+ const capabilities = this.extension.capabilities.filter(capability => capability !== 'session.resume' || this.agentCapabilities?.loadSession === true);
300
+ if (this.extension.elicitation && !capabilities.includes('user-input.respond')) capabilities.push('user-input.respond');
301
+ if (this.hostToolsRegistered) capabilities.push('host-tools');
302
+ if (this.agentCapabilities?.promptCapabilities?.image === true) capabilities.push('image.input');
303
+ if (!this.modelConfig) return capabilities;
304
+ capabilities.push('model.select');
305
+ if (!this.parameterized) return capabilities;
306
+ // A parameterized picker exposes parameters per model, so it may claim both before the current
307
+ // model has any; otherwise only parameters the agent actually returned are claimed.
308
+ const parameters = this.parameters(), picker = this.extension.parameterizedPicker === true;
309
+ if (picker || parameters.levels.length) capabilities.push('model.reasoning');
310
+ if (picker || parameters.context) capabilities.push('model.context-window');
311
+ return capabilities;
312
+ }
313
+
314
+ async commands() {
315
+ if (!this.sessionId || !this.transport || this.transport.closed) throw pluginError('invalid_session', `${this.label} command directory requires an open session`);
316
+ const transport = this.transport, sessionId = this.sessionId;
317
+ if (this.commandCatalog === null) {
318
+ await new Promise(resolve => {
319
+ const done = () => { clearTimeout(timer); this.commandWaiters.delete(done); resolve(); };
320
+ const timer = setTimeout(done, this.commandWaitMs);
321
+ this.commandWaiters.add(done);
322
+ });
323
+ }
324
+ if (this.transport !== transport || this.sessionId !== sessionId || transport.closed) throw pluginError('invalid_session', `${this.label} session changed while loading commands`);
325
+ return { commands: (this.commandCatalog ?? []).map(command => ({ ...command })) };
326
+ }
327
+
328
+ #readCommands(available) {
329
+ if (!Array.isArray(available)) return;
330
+ const seen = new Set();
331
+ this.commandCatalog = available.slice(0, 512).flatMap(command => {
332
+ if (typeof command?.name !== 'string' || !/^[^\s/\x00-\x1f\x7f][^\s\x00-\x1f\x7f]{0,255}$/.test(command.name)) return [];
333
+ const key = command.name.toLowerCase();
334
+ if (seen.has(key)) return [];
335
+ seen.add(key);
336
+ const category = this.extension.commandCategory(command);
337
+ return [{ name: command.name, description: typeof command.description === 'string' ? command.description.slice(0, 4000) : null,
338
+ source: category, category, execution: 'prompt',
339
+ ...(typeof command.input?.hint === 'string' ? { argumentHint: command.input.hint.slice(0, 1000) } : {}),
340
+ }];
341
+ });
342
+ for (const done of this.commandWaiters) done();
343
+ }
344
+
345
+ parameters() { return modelParameters(this.configOptions ?? [], this.modelConfig?.current); }
346
+
347
+ async configure(kind, input) {
348
+ if (this.phase !== 'ready' || !this.sessionId) throw pluginError('busy', `${this.label} configuration requires an idle session`);
349
+ if (!['reasoning', 'context'].includes(kind)) throw pluginError('invalid_input', `Unknown ${this.label} configuration`);
350
+ if (input.action === 'set') {
351
+ const transport = this.transport;
352
+ this.phase = 'configuring';
353
+ try { await this.#setParameter(kind, kind === 'reasoning' ? input.level : input.contextWindow); }
354
+ finally { if (this.transport === transport && this.phase === 'configuring') this.phase = 'ready'; }
355
+ } else if (input.action !== 'list') throw pluginError('invalid_input', `Unknown ${this.label} configuration action`);
356
+ const parameters = this.parameters();
357
+ return kind === 'reasoning'
358
+ ? { current: parameters.current, levels: parameters.levels.map(({ values, ...level }) => level), recovery: this.recovery(parameters), capabilities: this.capabilities() }
359
+ : { current: parameters.context?.currentValue ?? null, contextWindows: parameters.contextWindows, recovery: this.recovery(parameters), capabilities: this.capabilities() };
360
+ }
361
+
362
+ async #setParameter(kind, value) {
363
+ const parameters = this.parameters();
364
+ const values = kind === 'reasoning' ? parameters.levels.find(level => level.id === value)?.values
365
+ : parameters.contextWindows.some(option => option.id === value) ? [{ id: parameters.context.id, value }] : null;
366
+ if (!values) throw pluginError('invalid_input', `${this.label} parameter is not in the current model catalog`);
367
+ const model = this.modelConfig.current;
368
+ for (const selection of values) {
369
+ if (this.modelConfig?.current !== model) throw pluginError('invalid_session', `${this.label} model changed during configuration`);
370
+ const config = this.configOptions.find(config => config.id === selection.id);
371
+ if (!selectValues(config).some(option => option.value === selection.value)) throw pluginError('invalid_input', `${this.label} parameter options changed during configuration`);
372
+ if (config.currentValue === selection.value) continue;
373
+ const transport = this.transport, sessionId = this.sessionId;
374
+ const result = await transport.request('session/set_config_option', { sessionId, configId: selection.id, value: selection.value });
375
+ if (this.transport !== transport || this.sessionId !== sessionId || transport.closed) throw pluginError('invalid_session', `${this.label} session changed during configuration`);
376
+ this.#readModelConfig(result);
377
+ if (!Array.isArray(result?.configOptions) || this.modelConfig?.current !== model || this.configOptions.find(config => config.id === selection.id)?.currentValue !== selection.value) throw pluginError('invalid_output', `${this.label} did not confirm the requested parameter`);
378
+ }
379
+ const confirmed = this.parameters();
380
+ if ((kind === 'reasoning' ? confirmed.current : confirmed.context?.currentValue) !== value) throw pluginError('invalid_output', `${this.label} did not confirm the requested parameter combination`);
381
+ }
382
+
383
+ async models(input) {
384
+ if (this.phase !== 'ready' || !this.sessionId) throw pluginError('busy', `${this.label} model configuration requires an idle session`);
385
+ if (!this.modelConfig) throw pluginError('unsupported', `${this.label} did not provide model configuration`);
386
+ if (input.action === 'set') {
387
+ const transport = this.transport;
388
+ this.phase = 'configuring';
389
+ try { await this.#setModel(input.reference); }
390
+ finally { if (this.transport === transport && this.phase === 'configuring') this.phase = 'ready'; }
391
+ } else if (input.action !== 'list') throw pluginError('invalid_input', `Unknown ${this.label} model action`);
392
+ const parameters = this.parameters();
393
+ return { current: this.modelConfig.current, currentContextWindow: parameters.context?.currentValue ?? null,
394
+ ...(this.extension.parameterScope ? { parameterScope: this.extension.parameterScope } : {}),
395
+ models: this.modelConfig.models.map(model => ({ ...model, reasoningEfforts: model.reference === this.modelConfig.current ? parameters.levels.map(({ values, ...level }) => level) : [], contextWindows: model.reference === this.modelConfig.current ? parameters.contextWindows : [] })), recovery: this.recovery(parameters), capabilities: this.capabilities() };
396
+ }
397
+
398
+ #readModelConfig(result) {
399
+ if (!Array.isArray(result?.configOptions)) return;
400
+ this.configOptions = result.configOptions;
401
+ const config = result.configOptions.find(option => option.category === 'model' || option.id === 'model');
402
+ if (!config || config.type && config.type !== 'select' || typeof config.id !== 'string' || typeof config.currentValue !== 'string') {
403
+ this.modelConfig = null;
404
+ this.configOptions = [];
405
+ this.parameterized = false;
406
+ return;
407
+ }
408
+ this.parameterized ||= this.extension.parameterized(config, result);
409
+ const models = selectValues(config).map(option => ({
410
+ id: option.value, reference: option.value, displayName: option.name || option.value,
411
+ description: option.description ?? null,
412
+ }));
413
+ // The directory describes choices, not subscription entitlements.
414
+ this.modelConfig = models.length ? { id: config.id, current: config.currentValue, models } : null;
415
+ }
416
+
417
+ async #setModel(reference) {
418
+ const config = this.modelConfig;
419
+ if (!config) throw pluginError('unsupported', `${this.label} did not provide model configuration`);
420
+ if (typeof reference !== 'string' || !config.models.some(model => model.reference === reference)) throw pluginError('invalid_input', `${this.label} model is not in the session catalog`);
421
+ if (config.current === reference) return;
422
+ const transport = this.transport, sessionId = this.sessionId;
423
+ const result = await transport.request('session/set_config_option', { sessionId, configId: config.id, value: reference });
424
+ if (this.transport !== transport || this.sessionId !== sessionId || transport.closed) throw pluginError('invalid_session', `${this.label} session changed during model selection`);
425
+ this.#readModelConfig(result);
426
+ if (!Array.isArray(result?.configOptions) || this.modelConfig?.current !== reference) throw pluginError('invalid_output', `${this.label} did not confirm the requested model`);
427
+ }
428
+
429
+ snapshot() { return { nativeSessionId: this.sessionId, recovery: this.recovery(), capabilities: this.capabilities() }; }
430
+ recovery(parameters = this.parameters()) {
431
+ return { schema: this.extension.recoverySchema, version: 1, data: { nativeSessionId: this.sessionId, workspaceId: this.workspaceId, workspacePath: this.workspacePath, protocolVersion: 1, modeId: this.modeId, hostMcpServerName: this.hostMcpServerName, hasPrompt: this.hasPrompt, ...(this.modelConfig ? { modelId: this.modelConfig.current, reasoningEffort: parameters.current, contextWindow: parameters.context?.currentValue ?? null } : {}) } };
432
+ }
433
+
434
+ async close() {
435
+ clearTimeout(this.cancelTimer);
436
+ const transport = this.transport;
437
+ if (transport && !transport.closed) {
438
+ for (const pending of this.pendingInteractions.values()) {
439
+ try { transport.respond(pending.rpcId, cancelReply(pending)); } catch { /* process is already unavailable */ }
440
+ }
441
+ }
442
+ this.removeRequest?.(); this.removeNotification?.();
443
+ this.transport = null;
444
+ this.commandCatalog = null;
445
+ this.earlyCommands.clear();
446
+ for (const done of this.commandWaiters) done();
447
+ this.pendingInteractions.clear();
448
+ this.phase = 'stopped';
449
+ this.hostMcpTools=[];
450
+ this.hostPermissionReplies.clear();
451
+ this.sessionId = null;
452
+ this.modelConfig = null;
453
+ this.configOptions = [];
454
+ this.parameterized = false;
455
+ if (transport) await transport.close();
456
+ return { accepted: true };
457
+ }
458
+
459
+ async #selectMode(result, expected) {
460
+ const options = result?.configOptions ?? [];
461
+ const mode = options.find(option => option.id === 'mode');
462
+ const available = mode?.options ?? result?.modes?.availableModes ?? [];
463
+ const values = new Set(available.map(option => option.value ?? option.id));
464
+ if (!values.has(expected)) throw pluginError('unsupported', `${this.label} does not support ${expected} mode`);
465
+ const current = mode?.currentValue ?? result?.modes?.currentModeId;
466
+ this.modeApi = mode ? 'config' : 'modes';
467
+ this.nativeMode = expected;
468
+ if (current === expected) return;
469
+ if (!mode) {
470
+ // Agents exposing only the session modes API switch with session/set_mode; its
471
+ // successful response is the agent's confirmation that the mode is active.
472
+ await this.transport.request('session/set_mode', { sessionId: this.sessionId, modeId: expected });
473
+ return;
474
+ }
475
+ const changed = await this.transport.request('session/set_config_option', { sessionId: this.sessionId, configId: 'mode', value: expected });
476
+ this.#readModelConfig(changed);
477
+ const updated = changed?.configOptions?.find(option => option.id === 'mode')?.currentValue;
478
+ if (updated !== expected) throw pluginError('invalid_output', `${this.label} did not confirm the requested mode`);
479
+ }
480
+
481
+ #writable(mode) {
482
+ return mode != null && (this.extension.writableModes ?? [this.extension.writableMode]).includes(mode);
483
+ }
484
+
485
+ async #restoreMode() {
486
+ if (this.modeApi === 'config') {
487
+ const changed = await this.transport.request('session/set_config_option', { sessionId: this.sessionId, configId: 'mode', value: this.modeId });
488
+ this.#readModelConfig(changed);
489
+ if (changed?.configOptions?.find(option => option.id === 'mode')?.currentValue !== this.modeId) throw pluginError('invalid_output', `${this.label} did not confirm the requested mode`);
490
+ } else {
491
+ await this.transport.request('session/set_mode', { sessionId: this.sessionId, modeId: this.modeId });
492
+ }
493
+ this.nativeMode = this.modeId;
494
+ }
495
+
496
+ /**
497
+ * Native mode reports. A switch the host committed through an approval option is adopted; any
498
+ * other switch during a turn fails it, because the host still enforces the previous control.
499
+ */
500
+ #observeMode(mode) {
501
+ this.nativeMode = mode;
502
+ if (mode === this.modeId) return;
503
+ if (this.expectedMode?.mode === mode) {
504
+ this.modeId = mode;
505
+ this.profile = this.expectedMode.profile;
506
+ this.expectedMode = null;
507
+ return;
508
+ }
509
+ if (this.phase !== 'prompting' || this.modeViolation) return;
510
+ this.modeViolation = `${this.label} switched to ${String(mode).slice(0, 80)} mode without host approval; the turn was stopped`;
511
+ void this.cancel();
512
+ }
513
+
514
+ /** Approval options shown to the user: host labels, and session-control effects the host commits. */
515
+ #approvalOffer(params, options, writable) {
516
+ const toolKind = params.toolCall?.kind;
517
+ const offered = [], transitions = new Map();
518
+ for (const option of options) {
519
+ if (typeof option?.optionId !== 'string' || !option.optionId || option.optionId.length > 256 || offered.some(entry => entry.id === option.optionId)) continue;
520
+ const choice = this.extension.approvalChoices.find(entry => entry.optionId === option.optionId && (entry.toolKind === undefined || entry.toolKind === toolKind));
521
+ const allow = String(option.kind).startsWith('allow_');
522
+ const eligible = choice?.sessionControl ? true : writable ? ['allow_once', 'reject_once'].includes(option.kind) : option.kind === 'reject_once';
523
+ if (!eligible) continue;
524
+ offered.push({ id: option.optionId, kind: allow ? 'allow' : 'reject', ...(choice?.label ? { label: choice.label } : {}), ...(choice?.sessionControl ? { effects: { sessionControl: choice.sessionControl, ...(choice.contextReset ? { contextReset: true } : {}) } } : {}) });
525
+ if (choice?.sessionControl) transitions.set(option.optionId, { mode: choice.mode, profile: choice.profile });
526
+ }
527
+ return { offered: offered.slice(0, 16), transitions };
528
+ }
529
+
530
+ #validateRecovery(value, workspaceId, workspacePath) {
531
+ const recovery = object(value);
532
+ const data = object(recovery.data);
533
+ if (recovery.schema !== this.extension.recoverySchema || recovery.version !== 1 || typeof data.nativeSessionId !== 'string' || !data.nativeSessionId) throw pluginError('invalid_input', `Invalid ${this.label} recovery data`);
534
+ if (data.workspaceId !== workspaceId || data.workspacePath !== workspacePath) throw pluginError('permission_denied', `${this.label} recovery belongs to another workspace`);
535
+ if (data.hasPrompt !== undefined && typeof data.hasPrompt !== 'boolean') throw pluginError('invalid_input', `Invalid ${this.label} prompt recovery state`);
536
+ return data;
537
+ }
538
+
539
+ #handleRequest(message) {
540
+ const elicitation = message.method === 'elicitation/create' && this.extension.elicitation;
541
+ const dismiss = elicitation ? { action: 'cancel' } : CANCELLED;
542
+ if (!this.turnId || this.phase === 'loading') {
543
+ this.transport.respond(message.id, dismiss);
544
+ return true;
545
+ }
546
+ const params = object(message.params);
547
+ if (params.sessionId != null && params.sessionId !== this.sessionId) {
548
+ this.transport.respond(message.id, dismiss);
549
+ return true;
550
+ }
551
+ const requestId = `${this.extension.requestPrefix}-${typeof message.id === 'number' ? 'n' : 's'}-${String(message.id)}`;
552
+ if (message.method === 'session/request_permission') {
553
+ const options = Array.isArray(params.options) ? params.options : [];
554
+ // Only native structured metadata correlated with this turn can identify
555
+ // the private, randomly named host bridge. Display titles grant nothing.
556
+ const toolCallId=params.toolCall?.toolCallId;
557
+ const tool=this.tools.get(toolCallId);
558
+ const raw=tool?.rawInput;
559
+ const hostRead=this.phase==='prompting' && params.sessionId===this.sessionId
560
+ && tool?.kind==='other' && !this.completedTools.has(toolCallId)
561
+ && this.hostMcpTools?.some(allowed=>raw?.providerIdentifier===allowed.providerIdentifier && raw?.toolName===allowed.toolName);
562
+ if(hostRead){
563
+ const allowed=options.find(option=>option.kind==='allow_once');
564
+ const fresh=!this.hostPermissionReplies.has(requestId);
565
+ this.hostPermissionReplies.add(requestId);
566
+ this.transport.respond(message.id,allowed&&fresh?{outcome:{outcome:'selected',optionId:allowed.optionId}}:{outcome:{outcome:'cancelled'}});
567
+ return true;
568
+ }
569
+ const writable = this.#writable(this.modeId) && ['user', 'auto-review'].includes(this.profile.approvalReviewer);
570
+ // Persistent allow_always / reject_always grants are never offered: the host approves each request.
571
+ // Outside a writable mode only declared transitions (for example approving a plan) reach the user.
572
+ const offer = this.extension.approvalOptions ? this.#approvalOffer(params, options, writable) : {
573
+ offered: options.filter(option => ['allow_once', 'reject_once'].includes(option.kind)).map(option => ({ id: option.optionId, kind: option.kind === 'allow_once' ? 'allow' : 'reject' })),
574
+ transitions: new Map(),
575
+ };
576
+ if (!writable && offer.transitions.size === 0) {
577
+ const rejected = options.find(candidate => candidate.kind === 'reject_once');
578
+ this.transport.respond(message.id, rejected ? { outcome: { outcome: 'selected', optionId: rejected.optionId } } : { outcome: { outcome: 'cancelled' } });
579
+ return true;
580
+ }
581
+ const offered = offer.offered;
582
+ if (!this.hooks.await(requestId, message.id, { kind: 'permission', options, offered, transitions: offer.transitions })) return true;
583
+ this.#event('approval.requested', { requestId, kind: params.toolCall?.kind ?? 'tool', command: params.toolCall?.title ?? null, availableDecisions: ['accept', 'cancel'],
584
+ ...(this.extension.approvalOptions ? { options: offered } : {}) }, { requestId, toolCallId: params.toolCall?.toolCallId ?? null, approvalId: message.id });
585
+ return true;
586
+ }
587
+ if (elicitation) {
588
+ // Only form mode is declared; a form the host question model cannot express is cancelled, never approximated.
589
+ const form = params.mode === 'form' || params.mode === undefined ? elicitationForm(params) : null;
590
+ if (!form) { this.transport.respond(message.id, dismiss); return true; }
591
+ if (!this.hooks.await(requestId, message.id, { kind: 'question', answer: form.answer, cancelled: dismiss })) return true;
592
+ this.#event('user_input.requested', { requestId, title: form.title, questions: form.questions }, { requestId, toolCallId: typeof params.toolCallId === 'string' ? params.toolCallId : null });
593
+ return true;
594
+ }
595
+ return this.extension.handleRequest(this.hooks, message, params, requestId) === true;
596
+ }
597
+
598
+ #handleNotification(message) {
599
+ if (message.method === 'transport/closed') {
600
+ this.phase = 'failed';
601
+ for (const done of this.commandWaiters) done();
602
+ if (this.turnId) this.#event('adapter.crashed', { message: message.params?.message ?? `${this.label} ACP exited` });
603
+ return;
604
+ }
605
+ if (this.extension.handleNotification(this.hooks, message) === true) return;
606
+ if (message.method === 'session/update' && message.params?.update?.sessionUpdate === 'available_commands_update') {
607
+ const { sessionId, update } = message.params;
608
+ if (sessionId === this.sessionId && this.sessionId) this.#readCommands(update.availableCommands);
609
+ else if (['opening', 'loading'].includes(this.phase) && typeof sessionId === 'string' && Array.isArray(update.availableCommands) && this.earlyCommands.size < 16) {
610
+ this.earlyCommands.set(sessionId, update.availableCommands.slice(0, 512));
611
+ }
612
+ return;
613
+ }
614
+ if (message.method === 'session/update' && this.phase !== 'loading' && this.sessionId && message.params?.sessionId === this.sessionId && message.params?.update?.sessionUpdate === 'current_mode_update') {
615
+ if (typeof message.params.update.currentModeId === 'string') this.#observeMode(message.params.update.currentModeId);
616
+ return;
617
+ }
618
+ if (message.method === 'session/update' && this.phase !== 'loading' && message.params?.sessionId === this.sessionId && message.params?.update?.sessionUpdate === 'config_option_update') {
619
+ this.#readModelConfig(message.params.update);
620
+ const mode = Array.isArray(message.params.update.configOptions) ? message.params.update.configOptions.find(option => option?.id === 'mode')?.currentValue : undefined;
621
+ if (typeof mode === 'string' && this.modeApi === 'config') this.#observeMode(mode);
622
+ return;
623
+ }
624
+ if (message.method !== 'session/update' || this.phase === 'loading' || message.params?.sessionId !== this.sessionId || !this.turnId) return;
625
+ const update = object(message.params.update);
626
+ if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
627
+ const nextItemId = update.messageId ?? this.messageItemId ?? `assistant-${this.turnId}`;
628
+ if (this.messageItemId && nextItemId !== this.messageItemId && this.messageText) {
629
+ this.#event('message.completed', { itemId: this.messageItemId, text: this.messageText }, { itemId: this.messageItemId });
630
+ this.messageText = '';
631
+ }
632
+ this.messageItemId = nextItemId;
633
+ this.messageText += update.content.text;
634
+ this.#event('message.delta', { itemId: this.messageItemId, delta: update.content.text }, { itemId: this.messageItemId });
635
+ return;
636
+ }
637
+ if (update.sessionUpdate === 'agent_thought_chunk' && update.content?.type === 'text') {
638
+ const nextItemId = update.messageId ?? this.reasoningItemId ?? `reasoning-${this.turnId}`;
639
+ if (this.reasoningItemId && nextItemId !== this.reasoningItemId && this.reasoningText) {
640
+ this.#event('reasoning.completed', { itemId: this.reasoningItemId, summary: this.reasoningText }, { itemId: this.reasoningItemId });
641
+ this.reasoningText = '';
642
+ }
643
+ this.reasoningItemId = nextItemId;
644
+ this.reasoningText += update.content.text;
645
+ this.#event('reasoning.updated', { itemId: this.reasoningItemId, delta: update.content.text }, { itemId: this.reasoningItemId });
646
+ return;
647
+ }
648
+ if (update.sessionUpdate === 'tool_call') {
649
+ this.tools.set(update.toolCallId, { ...update });
650
+ const started = this.extension.subagentFromTool(update);
651
+ if (started) {
652
+ const subagent = { id: update.toolCallId, parentId: this.sessionId, rootTurnId: this.turnId, status: 'running', ...started };
653
+ this.subagents.set(update.toolCallId, subagent);
654
+ this.#updateSubagent(subagent);
655
+ return;
656
+ }
657
+ this.#event('tool.started', toolPayload(this.label, update, { status: 'pending' }), { itemId: update.toolCallId, toolCallId: update.toolCallId });
658
+ if (['completed', 'failed'].includes(update.status)) {
659
+ this.completedTools.add(update.toolCallId);
660
+ this.#event('tool.completed', toolPayload(this.label, update), { itemId: update.toolCallId, toolCallId: update.toolCallId });
661
+ }
662
+ return;
663
+ }
664
+ if (update.sessionUpdate === 'tool_call_update') {
665
+ const subagent = this.subagents.get(update.toolCallId);
666
+ if (subagent) {
667
+ if (update.status === 'failed') this.#updateSubagent(subagent, { status: 'failed', activity: bounded(update.rawOutput ?? update.content) || `${this.label} subagent task failed.` });
668
+ else if (update.status === 'completed') this.#updateSubagent(subagent, { status: 'waiting', activity: `Waiting for ${this.label} task details.` });
669
+ return;
670
+ }
671
+ if (this.completedTools.has(update.toolCallId)) return;
672
+ const merged = { ...this.tools.get(update.toolCallId), ...update };
673
+ this.tools.set(update.toolCallId, merged);
674
+ const terminal = ['completed', 'failed'].includes(merged.status);
675
+ if (terminal) this.completedTools.add(update.toolCallId);
676
+ this.#event(terminal ? 'tool.completed' : 'tool.updated', toolPayload(this.label, merged, update), { itemId: update.toolCallId, toolCallId: update.toolCallId });
677
+ return;
678
+ }
679
+ if (update.sessionUpdate === 'usage_update') {
680
+ // ACP reports the live context as `used` of `size` tokens.
681
+ this.#updateUsage({ contextTokens: count(update.used), contextWindow: count(update.size), cost: update.cost && typeof update.cost === 'object' ? update.cost : null });
682
+ return;
683
+ }
684
+ if (String(update.sessionUpdate).includes('available_')) return;
685
+ this.#event('extension.updated', { namespace: this.extension.namespace, update });
686
+ }
687
+
688
+ /** Adds a prompt result's per-turn usage to the session totals; cached prompt tokens count as input. */
689
+ #addTurnUsage(usage) {
690
+ const turn = object(usage);
691
+ const total = count(turn.totalTokens);
692
+ if (total === null) return;
693
+ const output = count(turn.outputTokens) ?? 0;
694
+ const input = (count(turn.inputTokens) ?? 0) + (count(turn.cachedReadTokens) ?? 0) + (count(turn.cachedWriteTokens) ?? 0);
695
+ this.#updateUsage({ input: (this.usage.input ?? 0) + input, output: (this.usage.output ?? 0) + output, totalTokens: (this.usage.totalTokens ?? 0) + total });
696
+ }
697
+
698
+ /** Merges known values into the usage snapshot; each event carries the whole snapshot because the host replaces it. */
699
+ #updateUsage(values) {
700
+ const known = Object.entries(values).filter(([, value]) => value !== null);
701
+ if (!known.length) return;
702
+ this.usage = { ...this.usage, ...Object.fromEntries(known) };
703
+ this.#event('usage.updated', { usage: { ...this.usage } });
704
+ }
705
+
706
+ #event(type, payload, correlation = null) {
707
+ this.emit({ nativeSessionId: this.sessionId, turnId: this.turnId ?? null, type, correlation, payload });
708
+ }
709
+
710
+ #updateSubagent(subagent, changes = {}) {
711
+ Object.assign(subagent, changes);
712
+ const { id, parentId, rootTurnId, name, task, status, activity } = subagent;
713
+ this.emit({ nativeSessionId: this.sessionId, turnId: null, type: 'subagent.updated', correlation: { itemId: id, toolCallId: id }, payload: { id, parentId, rootTurnId, name, task, status, activity } });
714
+ }
715
+
716
+ #finishSubagents(status, activity) {
717
+ for (const subagent of this.subagents.values()) {
718
+ if (!['completed', 'failed', 'interrupted', 'unavailable'].includes(subagent.status)) this.#updateSubagent(subagent, { status, activity });
719
+ }
720
+ }
721
+ }