@adecore/agents 0.29.3 → 0.31.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.
Files changed (65) hide show
  1. package/dist/chat/acp-backend.d.ts +59 -0
  2. package/dist/chat/acp-backend.js +407 -0
  3. package/dist/chat/acp-protocol.d.ts +131 -0
  4. package/dist/chat/acp-protocol.js +605 -0
  5. package/dist/chat/codex-transport.d.ts +4 -27
  6. package/dist/chat/codex-transport.js +4 -185
  7. package/dist/chat/fixtures/acp-v1-turn.json +240 -0
  8. package/dist/chat/index.d.ts +2 -0
  9. package/dist/chat/index.js +1 -0
  10. package/dist/chat/json-rpc-transport.d.ts +50 -0
  11. package/dist/chat/json-rpc-transport.js +203 -0
  12. package/dist/providers/accounts/accounts.d.ts +10 -6
  13. package/dist/providers/accounts/accounts.js +19 -19
  14. package/dist/providers/accounts/service.d.ts +3 -0
  15. package/dist/providers/accounts/service.js +33 -17
  16. package/dist/providers/accounts/store.d.ts +4 -5
  17. package/dist/providers/accounts/store.js +12 -12
  18. package/dist/providers/acp-provider.d.ts +22 -0
  19. package/dist/providers/acp-provider.js +45 -0
  20. package/dist/providers/catalog.js +1 -0
  21. package/dist/providers/index.d.ts +1 -0
  22. package/dist/providers/index.js +1 -0
  23. package/dist/providers/provider.d.ts +1 -1
  24. package/dist/providers/registry.d.ts +1 -0
  25. package/dist/providers/registry.js +4 -0
  26. package/dist/testing/fake-acp.d.ts +10 -0
  27. package/dist/testing/fake-acp.js +262 -0
  28. package/dist/testing/index.d.ts +1 -0
  29. package/dist/testing/index.js +1 -0
  30. package/dist/usage/index-file.d.ts +1 -1
  31. package/dist/usage/index-file.js +3 -1
  32. package/dist/usage/index.d.ts +2 -1
  33. package/dist/usage/index.js +2 -0
  34. package/dist/usage/limits/monitor.d.ts +3 -1
  35. package/dist/usage/limits/monitor.js +19 -4
  36. package/dist/usage/limits/normalize.d.ts +1 -0
  37. package/dist/usage/limits/normalize.js +2 -1
  38. package/dist/usage/readers/host-log.d.ts +16 -0
  39. package/dist/usage/readers/host-log.js +49 -0
  40. package/dist/usage/roots.js +6 -2
  41. package/dist/usage/scanner.js +13 -2
  42. package/package.json +3 -3
  43. package/src/chat/acp-backend.ts +476 -0
  44. package/src/chat/acp-protocol.ts +763 -0
  45. package/src/chat/codex-transport.ts +6 -207
  46. package/src/chat/fixtures/acp-v1-turn.json +240 -0
  47. package/src/chat/index.ts +2 -0
  48. package/src/chat/json-rpc-transport.ts +253 -0
  49. package/src/providers/accounts/accounts.ts +28 -25
  50. package/src/providers/accounts/service.ts +49 -18
  51. package/src/providers/accounts/store.ts +14 -17
  52. package/src/providers/acp-provider.ts +73 -0
  53. package/src/providers/catalog.ts +1 -0
  54. package/src/providers/index.ts +1 -0
  55. package/src/providers/provider.ts +2 -1
  56. package/src/providers/registry.ts +5 -0
  57. package/src/testing/fake-acp.ts +296 -0
  58. package/src/testing/index.ts +1 -0
  59. package/src/usage/index-file.ts +3 -2
  60. package/src/usage/index.ts +2 -1
  61. package/src/usage/limits/monitor.ts +24 -5
  62. package/src/usage/limits/normalize.ts +2 -1
  63. package/src/usage/readers/host-log.ts +72 -0
  64. package/src/usage/roots.ts +7 -3
  65. package/src/usage/scanner.ts +16 -5
@@ -0,0 +1,476 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { attachmentImageMime, type ChatAttachment, type ModelSelection } from '@adecore/agent-contracts';
3
+ import { errorText } from '../error-text.ts';
4
+ import { MODE_ORDER } from '../modes.ts';
5
+ import {
6
+ ACP_AUTH_REQUIRED,
7
+ ACP_PROTOCOL_VERSION,
8
+ AcpProtocol,
9
+ acpStopWords,
10
+ autoAllows,
11
+ parseInitialize,
12
+ parseSessionInfo,
13
+ resolveAcpMode,
14
+ runtimeModeOf,
15
+ sessionIdOf,
16
+ type AcpAgentCapabilities,
17
+ type AcpModeMap,
18
+ type AcpSessionInfo
19
+ } from './acp-protocol.ts';
20
+ import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
21
+ import { attachmentNote } from './input.ts';
22
+ import { JsonRpcError, JsonRpcTransport, type JsonRpcFrame } from './json-rpc-transport.ts';
23
+
24
+ /* How the host names itself in `initialize`. */
25
+ export interface AcpClientInfo {
26
+ name: string;
27
+ title?: string;
28
+ version: string;
29
+ }
30
+
31
+ export const DEFAULT_ACP_CLIENT: AcpClientInfo = { name: 'agents', title: 'Agents', version: '0.1.0' };
32
+
33
+ /* One agent that speaks ACP over its stdio, as the backend needs to know it. */
34
+ export interface AcpAgentSpec {
35
+ // The agent as a person reads it, in the notes and errors the backend writes.
36
+ name: string;
37
+ // Variables set over the launch's environment.
38
+ env?: Record<string, string>;
39
+ // The agent's own mode ids per runtime mode, best first. A runtime mode the agent has no id for runs in
40
+ // the next narrower one it has, and the backend answers the rest of what the runtime mode allows itself.
41
+ modes?: AcpModeMap;
42
+ // The auth method passed to `authenticate` when the agent answers a session request with "authentication
43
+ // required"; absent or null fails the start with `loginHint` instead.
44
+ authMethod?: string | null;
45
+ // What a person does to sign the agent in, for the error a start without a login fails with.
46
+ loginHint?: string;
47
+ client?: AcpClientInfo;
48
+ // The agent's model id for a selection; null leaves the agent's own choice. Absent takes the selection's model as it is.
49
+ model?(selection: ModelSelection): string | null;
50
+ // What every new, loaded or resumed session offers, for a host that fills its model catalog from the agent.
51
+ onSession?(info: AcpSessionInfo): void;
52
+ }
53
+
54
+ // What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
55
+ const REQUEST_TIMEOUT_MS = 60_000;
56
+
57
+ function nameOf(client: AcpClientInfo): string {
58
+ return client.title ?? client.name;
59
+ }
60
+
61
+ /*
62
+ * One ACP agent process on JSON-RPC over stdio. `start` runs `initialize` and opens the session
63
+ * (new, or the chat's own resumed or loaded), sets the mode and the model, and only then takes a turn.
64
+ * A turn is one `session/prompt` request and ends on its response. The client advertises no file
65
+ * system and no terminal, so the agent works with its own tools.
66
+ */
67
+ export class AcpBackend implements ChatBackend {
68
+ private readonly launch: BackendLaunch;
69
+ private readonly host: BackendHost;
70
+ private readonly spec: AcpAgentSpec;
71
+ private readonly client: AcpClientInfo;
72
+ private readonly protocol: AcpProtocol;
73
+ private transport: JsonRpcTransport | null = null;
74
+ private capabilities: AcpAgentCapabilities | null = null;
75
+ private sessionId = '';
76
+ // A loaded session replays its history as updates the thread already holds.
77
+ private replaying = false;
78
+ private contextPending: string | null = null;
79
+ private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
80
+
81
+ constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
82
+ this.launch = launch;
83
+ this.host = host;
84
+ this.spec = spec;
85
+ this.client = spec.client ?? DEFAULT_ACP_CLIENT;
86
+ this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name));
87
+ }
88
+
89
+ get running(): boolean {
90
+ return this.transport !== null;
91
+ }
92
+
93
+ get pid(): number | null {
94
+ return this.transport?.pid ?? null;
95
+ }
96
+
97
+ async start(): Promise<void> {
98
+ if (this.transport) {
99
+ return;
100
+ }
101
+ const transport = new JsonRpcTransport({
102
+ command: this.launch.command,
103
+ cwd: this.launch.cwd,
104
+ env: { ...this.launch.env, ...this.spec.env },
105
+ ...(this.launch.spawn ? { spawn: this.launch.spawn } : {}),
106
+ label: this.spec.name,
107
+ errorName: 'AcpRpcError',
108
+ requestTimeoutMs: REQUEST_TIMEOUT_MS,
109
+ versionField: true,
110
+ onError: (error) => this.fail(error),
111
+ onFrame: (frame) => this.handleFrame(transport, frame),
112
+ onExit: (exitCode, stderr) => this.handleExit(transport, exitCode, stderr)
113
+ });
114
+ this.transport = transport;
115
+ try {
116
+ await this.handshake(transport);
117
+ } catch (error) {
118
+ // A chat that failed to start drops this backend, so nothing else would end an agent stuck in its handshake.
119
+ if (this.transport === transport) {
120
+ this.transport = null;
121
+ void transport.dispose();
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ private async handshake(transport: JsonRpcTransport): Promise<void> {
128
+ const capabilities = parseInitialize(
129
+ await transport.request('initialize', {
130
+ protocolVersion: ACP_PROTOCOL_VERSION,
131
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
132
+ clientInfo: { name: this.client.name, title: nameOf(this.client), version: this.client.version }
133
+ })
134
+ );
135
+ if (capabilities.protocolVersion !== ACP_PROTOCOL_VERSION) {
136
+ throw new Error(`${this.spec.name} speaks ACP version ${capabilities.protocolVersion}; only version ${ACP_PROTOCOL_VERSION} is supported`);
137
+ }
138
+ this.capabilities = capabilities;
139
+ const info = await this.openSession(transport, capabilities);
140
+ const modeId = await this.applyMode(transport, info);
141
+ const model = await this.applyModel(transport, info);
142
+ await this.applyThoughtLevel(transport, info);
143
+ this.spec.onSession?.(info);
144
+ this.emit({
145
+ type: 'session',
146
+ agentSessionId: this.sessionId,
147
+ model,
148
+ effectiveRuntimeMode: this.launch.runtimeMode,
149
+ ...(modeId === null ? {} : { permissionMode: modeId })
150
+ });
151
+ }
152
+
153
+ private sessionParams(): Record<string, unknown> {
154
+ const folders = this.launch.folders ?? [];
155
+ return {
156
+ cwd: this.launch.cwd,
157
+ mcpServers: [],
158
+ ...(folders.length > 0 && this.capabilities?.additionalDirectories ? { additionalDirectories: folders } : {})
159
+ };
160
+ }
161
+
162
+ private async openSession(transport: JsonRpcTransport, capabilities: AcpAgentCapabilities): Promise<AcpSessionInfo> {
163
+ const resume = this.launch.resume;
164
+ if (resume) {
165
+ const method = capabilities.resumeSession ? 'session/resume' : capabilities.loadSession ? 'session/load' : null;
166
+ if (method === null) {
167
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot continue a session it ran before. Started a new one.` });
168
+ } else {
169
+ try {
170
+ this.replaying = method === 'session/load';
171
+ const result = await this.authenticated(transport, method, { sessionId: resume, ...this.sessionParams() });
172
+ this.sessionId = resume;
173
+ this.contextPending = [this.launch.resumeInstructions ?? null, this.launch.resumeNote].filter((part) => part !== null).join('\n\n') || null;
174
+ return parseSessionInfo(result, resume, capabilities.agent);
175
+ } catch (error) {
176
+ // A refusal means the agent no longer has the session and a fresh one keeps the chat usable; a silence or an exit says nothing about it.
177
+ if (!(error instanceof JsonRpcError)) {
178
+ throw error;
179
+ }
180
+ this.emit({
181
+ type: 'note',
182
+ level: 'warning',
183
+ text: `${this.spec.name} could not continue its session (${errorText(error)}). Started a new one.`
184
+ });
185
+ } finally {
186
+ this.replaying = false;
187
+ }
188
+ }
189
+ }
190
+ const result = await this.authenticated(transport, 'session/new', this.sessionParams());
191
+ const sessionId = sessionIdOf(result);
192
+ if (sessionId === null) {
193
+ throw new Error(`session/new failed: ${this.spec.name} answered without a session id`);
194
+ }
195
+ this.sessionId = sessionId;
196
+ this.contextPending = this.launch.instructions;
197
+ return parseSessionInfo(result, sessionId, capabilities.agent);
198
+ }
199
+
200
+ /* A session request, once more after `authenticate` when the agent says it needs a login first. */
201
+ private async authenticated(transport: JsonRpcTransport, method: string, params: unknown): Promise<unknown> {
202
+ try {
203
+ return await transport.request(method, params);
204
+ } catch (error) {
205
+ if (!(error instanceof JsonRpcError) || error.code !== ACP_AUTH_REQUIRED) {
206
+ throw error;
207
+ }
208
+ const methodId = this.spec.authMethod ?? null;
209
+ const offered = this.capabilities?.authMethods ?? [];
210
+ if (methodId === null || (offered.length > 0 && !offered.includes(methodId))) {
211
+ throw new Error(`${this.spec.name} is not signed in.${this.spec.loginHint ? ` ${this.spec.loginHint}` : ''}`, { cause: error });
212
+ }
213
+ await transport.request('authenticate', { methodId }, { timeoutMs: null });
214
+ return transport.request(method, params);
215
+ }
216
+ }
217
+
218
+ /* Puts the session in the agent mode the runtime mode maps to; answers the mode it runs in, or null for an agent without modes. */
219
+ private async applyMode(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<string | null> {
220
+ const modes = info.modes;
221
+ if (modes === null || modes.modes.length === 0) {
222
+ return null;
223
+ }
224
+ const map = this.spec.modes ?? {};
225
+ const requested = this.launch.runtimeMode;
226
+ const resolved = resolveAcpMode(
227
+ requested,
228
+ map,
229
+ modes.modes.map((mode) => mode.id)
230
+ );
231
+ if (resolved === null) {
232
+ const current = runtimeModeOf(modes.current, map);
233
+ if (current !== null && MODE_ORDER.indexOf(current) > MODE_ORDER.indexOf(requested)) {
234
+ throw new Error(`${this.spec.name} runs in ${modes.current}, which allows more than ${requested}, and offers no narrower mode`);
235
+ }
236
+ return modes.current;
237
+ }
238
+ if (resolved.modeId === modes.current) {
239
+ return modes.current;
240
+ }
241
+ if (modes.configId === null) {
242
+ await transport.request('session/set_mode', { sessionId: this.sessionId, modeId: resolved.modeId });
243
+ } else {
244
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: modes.configId, value: resolved.modeId });
245
+ }
246
+ return resolved.modeId;
247
+ }
248
+
249
+ /* Switches to the selected model where the agent offers it; answers the model the session runs. */
250
+ private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<string | null> {
251
+ const models = info.models;
252
+ const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
253
+ if (models === null || wanted === null || wanted === models.current) {
254
+ return models?.current ?? wanted;
255
+ }
256
+ if (!models.models.some((model) => model.id === wanted)) {
257
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
258
+ return models.current;
259
+ }
260
+ try {
261
+ if (models.configId === null) {
262
+ await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
263
+ } else {
264
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
265
+ }
266
+ return wanted;
267
+ } catch (error) {
268
+ if (!(error instanceof JsonRpcError)) {
269
+ throw error;
270
+ }
271
+ this.emit({
272
+ type: 'note',
273
+ level: 'warning',
274
+ text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
275
+ });
276
+ return models.current;
277
+ }
278
+ }
279
+
280
+ private async applyThoughtLevel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<void> {
281
+ const level = info.thoughtLevel;
282
+ const effort = this.launch.selection.options.effort;
283
+ if (level === null || typeof effort !== 'string' || effort === level.current || !level.values.includes(effort)) {
284
+ return;
285
+ }
286
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: effort }).catch((error: unknown) => {
287
+ if (!(error instanceof JsonRpcError)) {
288
+ throw error;
289
+ }
290
+ });
291
+ }
292
+
293
+ private promptBlocks(input: TurnInput): unknown[] {
294
+ const sendsImages = this.capabilities?.images === true;
295
+ const images = sendsImages ? input.attachments.filter((attachment) => attachmentImageMime(attachment) !== null) : [];
296
+ const blocks = images.map((attachment) => {
297
+ let data: string;
298
+ try {
299
+ data = readFileSync(attachment.path).toString('base64');
300
+ } catch {
301
+ throw new Error(`Could not read attached image "${attachment.name}". Attach it again and retry.`);
302
+ }
303
+ return { type: 'image', mimeType: attachmentImageMime(attachment), data };
304
+ });
305
+ const named: ChatAttachment[] = input.attachments.filter((attachment) => !images.includes(attachment));
306
+ const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part): part is string => part !== null && part !== '');
307
+ this.contextPending = null;
308
+ return [{ type: 'text', text: parts.join('\n\n') }, ...blocks];
309
+ }
310
+
311
+ sendTurn(input: TurnInput): void {
312
+ const transport = this.transport;
313
+ if (!transport) {
314
+ this.emit({ type: 'failed', message: `session/prompt failed: ${this.spec.name} is not running` });
315
+ return;
316
+ }
317
+ const prompt = this.promptBlocks(input);
318
+ const turn = { promptId: input.promptId, cancelled: false };
319
+ this.prompt = turn;
320
+ transport
321
+ .request('session/prompt', { sessionId: this.sessionId, prompt }, { timeoutMs: null })
322
+ .then((result) => {
323
+ if (this.transport === transport && this.prompt === turn) {
324
+ this.prompt = null;
325
+ this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
326
+ }
327
+ })
328
+ .catch((error: unknown) => {
329
+ // A process that went has its exit tell the thread; anything else ends this turn alone.
330
+ if (this.transport === transport && transport.alive && this.prompt === turn) {
331
+ this.prompt = null;
332
+ this.emitAll(this.protocol.abort(turn.promptId, turn.cancelled ? null : errorText(error)));
333
+ }
334
+ });
335
+ }
336
+
337
+ /* ACP has no call to fold the context; a host that offers it sends the agent's own command as a prompt. */
338
+ compact(): void {
339
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
340
+ this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
341
+ }
342
+
343
+ /* Cancels the turn: every permission request still open is answered as cancelled, and the turn ends on the prompt's response. */
344
+ interrupt(): void {
345
+ const transport = this.transport;
346
+ if (!transport) {
347
+ return;
348
+ }
349
+ if (this.prompt === null) {
350
+ this.emit({ type: 'turn.done', state: 'aborted', costUsd: 0 });
351
+ return;
352
+ }
353
+ this.prompt.cancelled = true;
354
+ try {
355
+ for (const cancelled of this.protocol.cancelPermissions()) {
356
+ transport.respond(cancelled.rpcId, cancelled.result);
357
+ this.emit({ type: 'request.withdrawn', requestId: cancelled.requestId });
358
+ }
359
+ transport.notify('session/cancel', { sessionId: this.sessionId });
360
+ } catch (error) {
361
+ this.fail(error);
362
+ }
363
+ }
364
+
365
+ respondApproval(requestId: string, decision: ApprovalDecision): boolean {
366
+ const answer = this.protocol.answer(requestId, decision);
367
+ if (!answer) {
368
+ return false;
369
+ }
370
+ return this.respond(requestId, answer.rpcId, answer.result);
371
+ }
372
+
373
+ /* ACP asks no questions of its own; an agent asks in its message and the next prompt answers. */
374
+ respondQuestion(): boolean {
375
+ return false;
376
+ }
377
+
378
+ declineRequest(requestId: string): boolean {
379
+ return this.protocol.hasPermission(requestId) && this.respondApproval(requestId, 'deny');
380
+ }
381
+
382
+ stop(): void {
383
+ this.transport?.stop();
384
+ }
385
+
386
+ dispose(): Promise<void> {
387
+ const transport = this.transport;
388
+ this.transport = null;
389
+ return transport?.dispose() ?? Promise.resolve();
390
+ }
391
+
392
+ private respond(requestId: string, rpcId: number | string, result: unknown): boolean {
393
+ try {
394
+ if (!this.transport) {
395
+ throw new Error(`${this.spec.name} is not running`);
396
+ }
397
+ this.transport.respond(rpcId, result);
398
+ return true;
399
+ } catch (error) {
400
+ this.emit({ type: 'request.withdrawn', requestId });
401
+ this.fail(error);
402
+ return false;
403
+ }
404
+ }
405
+
406
+ private fail(error: unknown): void {
407
+ this.emit({ type: 'failed', message: errorText(error), processAlive: this.running });
408
+ }
409
+
410
+ private handleFrame(transport: JsonRpcTransport, frame: JsonRpcFrame): void {
411
+ if (this.transport !== transport) {
412
+ return;
413
+ }
414
+ const id = typeof frame.id === 'number' || typeof frame.id === 'string' ? frame.id : null;
415
+ if (frame.method === 'session/update' && id === null) {
416
+ if (!this.replaying) {
417
+ this.emitAll(this.protocol.update(this.sessionId, frame.params));
418
+ }
419
+ return;
420
+ }
421
+ if (id === null) {
422
+ return;
423
+ }
424
+ if (frame.method !== 'session/request_permission') {
425
+ transport.respondError(id, -32601, `${nameOf(this.client)} does not handle ${String(frame.method)}`);
426
+ return;
427
+ }
428
+ const permission = this.protocol.permission(id, this.sessionId, frame.params);
429
+ if (permission === null) {
430
+ transport.respondError(id, -32602, 'Not a permission request of this session');
431
+ return;
432
+ }
433
+ if (this.prompt?.cancelled) {
434
+ this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
435
+ const answer = this.protocol.cancelPermissions().find((cancelled) => cancelled.requestId === permission.requestId);
436
+ if (answer) {
437
+ transport.respond(answer.rpcId, answer.result);
438
+ }
439
+ return;
440
+ }
441
+ if (autoAllows(this.launch.runtimeMode, permission.kind)) {
442
+ this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
443
+ const answer = this.protocol.answer(permission.requestId, 'allow');
444
+ if (answer) {
445
+ this.respond(permission.requestId, answer.rpcId, answer.result);
446
+ }
447
+ return;
448
+ }
449
+ this.emitAll(permission.events);
450
+ }
451
+
452
+ private handleExit(transport: JsonRpcTransport, exitCode: number | null, stderr: string | null): void {
453
+ if (this.transport !== transport) {
454
+ return;
455
+ }
456
+ this.transport = null;
457
+ this.prompt = null;
458
+ this.protocol.cancelPermissions();
459
+ this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
460
+ }
461
+
462
+ private emitAll(events: BackendEvent[]): void {
463
+ for (const event of events) {
464
+ this.emit(event);
465
+ }
466
+ }
467
+
468
+ private emit(event: BackendEvent): void {
469
+ this.host.onEvent(event);
470
+ }
471
+ }
472
+
473
+ /* The `createBackend` of a provider whose agent speaks ACP. */
474
+ export function createAcpBackend(spec: AcpAgentSpec): (launch: BackendLaunch, host: BackendHost) => ChatBackend {
475
+ return (launch, host) => new AcpBackend(launch, host, spec);
476
+ }