@automatalabs/acp-agents 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runner.js CHANGED
@@ -42,6 +42,7 @@ export class AcpAgentRunner {
42
42
  * so dedicated interactive connections must receive the SAME deps the pool receives. */
43
43
  clientHandlers;
44
44
  permissionResolver;
45
+ elicitationResolver;
45
46
  /** Held-open interactive sessions own dedicated ACP processes outside the pool. The runner
46
47
  * tracks their connections so dispose() can release them and the process-exit hook can
47
48
  * synchronously kill any dedicated children if the host exits without release(). */
@@ -52,16 +53,20 @@ export class AcpAgentRunner {
52
53
  constructor(options = {}) {
53
54
  this.clientHandlers = options.clientHandlers;
54
55
  this.permissionResolver = options.onPermissionRequest;
56
+ this.elicitationResolver = options.onElicitation;
55
57
  this.pool = new AcpAgentPool(options, {
56
58
  onEvent: this.emitEvent,
57
59
  permissionResolver: options.onPermissionRequest,
60
+ elicitationResolver: options.onElicitation,
61
+ advertiseElicitation: Boolean(options.onElicitation),
58
62
  });
59
63
  this.backends = resolveBackendRegistry(options.backends);
60
64
  }
61
65
  /**
62
66
  * Listen in on the live ACP stream. `name` is an ACP `sessionUpdate` discriminant
63
67
  * ("agent_message_chunk", "tool_call", "usage_update", …) or one of the cross-cutting events
64
- * ("session_update" catch-all, "permission_pending", "permission_request", "raw_message",
68
+ * ("session_update" catch-all, "permission_pending", "permission_request",
69
+ * "elicitation_pending", "elicitation_request", "elicitation_complete", "raw_message",
65
70
  * "session_open", "session_close", "backend_error"). The listener is typed to the event.
66
71
  * Returns an unsubscribe thunk. A pooled runner multiplexes many concurrent runs, so each
67
72
  * event carries `{ sessionId, backendId, label?, runId? }` for filtering. Listeners are
@@ -91,70 +96,66 @@ export class AcpAgentRunner {
91
96
  * is one).
92
97
  */
93
98
  async openSession(opts) {
99
+ return this.createInteractiveSession(opts, "openSession", (connection, prepared) => connection.openSession(prepared.sessionOptions));
100
+ }
101
+ /** List persisted ACP sessions from the selected backend. */
102
+ async listSessions(opts = {}) {
94
103
  if (this.disposed)
95
104
  throw new Error("ACP agent runner is disposed");
96
- validateInteractiveCwd(opts.cwd, opts.label);
105
+ validateOptionalLifecycleCwd(opts.cwd, opts.label, "listSessions");
97
106
  opts.signal?.throwIfAborted();
98
- const prepared = this.prepareSession(opts, {
99
- cwd: opts.cwd,
100
- schema: undefined,
101
- registry: this.backends,
102
- permissionResolver: opts.onPermissionRequest,
103
- retainSessionLog: false,
104
- });
105
- this.installExitHook();
106
- let interactive;
107
- const connection = PooledConnection.create(prepared.backend, {
108
- onDead: () => {
109
- // Dedicated connections are not stored in pool arrays, so there is nothing to evict.
110
- // Once the public wrapper exists, process death releases it through the normal path:
111
- // subscriptions are removed, session_close is emitted, and future prompt() calls fail
112
- // with the clean released-session error. Before then, openSession's catch tears down.
113
- void interactive?.release();
114
- },
115
- onEvent: this.emitEvent,
116
- permissionResolver: this.permissionResolver,
117
- clientHandlers: this.clientHandlers,
118
- });
119
- let session;
107
+ const backend = selectBackend(opts, this.backends);
108
+ const connection = this.createDedicatedConnection(backend, () => undefined);
120
109
  try {
121
- session = await connection.openSession(prepared.sessionOptions);
110
+ const request = {
111
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
112
+ ...(opts.cursor ? { cursor: opts.cursor } : {}),
113
+ ...(opts.meta ? { _meta: opts.meta } : {}),
114
+ };
115
+ const response = await connection.listSessions(request, opts.label);
122
116
  opts.signal?.throwIfAborted();
123
- await applyModelSelection(session, prepared.modelSpec, opts);
124
- opts.signal?.throwIfAborted();
125
- if (opts.mode)
126
- await session.setMode(opts.mode);
117
+ if (this.disposed)
118
+ throw new Error("ACP agent runner is disposed");
119
+ return response;
120
+ }
121
+ finally {
122
+ await disposeBestEffort(connection);
123
+ }
124
+ }
125
+ /** Delete a persisted ACP session from the selected backend. */
126
+ async deleteSession(opts) {
127
+ if (this.disposed)
128
+ throw new Error("ACP agent runner is disposed");
129
+ if (typeof opts.sessionId !== "string" || opts.sessionId.trim() === "") {
130
+ throw new WorkflowError("deleteSession requires sessionId to be a non-empty string", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: opts.label });
131
+ }
132
+ opts.signal?.throwIfAborted();
133
+ const backend = selectBackend(opts, this.backends);
134
+ const connection = this.createDedicatedConnection(backend, () => undefined);
135
+ try {
136
+ const request = {
137
+ sessionId: opts.sessionId,
138
+ ...(opts.meta ? { _meta: opts.meta } : {}),
139
+ };
140
+ await connection.deleteSession(request, opts.label);
127
141
  opts.signal?.throwIfAborted();
128
142
  if (this.disposed)
129
143
  throw new Error("ACP agent runner is disposed");
130
- interactive = new InteractiveSession({
131
- session,
132
- connection,
133
- backend: prepared.backend,
134
- subscribe: (name, listener) => this.events.on(name, listener),
135
- onRelease: (self) => this.interactiveSessions.delete(self),
136
- signal: opts.signal,
137
- label: opts.label,
138
- });
139
- this.interactiveSessions.set(interactive, connection);
140
- return interactive;
141
144
  }
142
- catch (error) {
143
- try {
144
- await session?.release();
145
- }
146
- catch {
147
- // best-effort: openSession failed, so teardown must never mask the real error.
148
- }
149
- try {
150
- await connection.dispose();
151
- }
152
- catch {
153
- // best-effort: same as pool teardown.
154
- }
155
- throw error;
145
+ finally {
146
+ await disposeBestEffort(connection);
156
147
  }
157
148
  }
149
+ /** Load an existing ACP session and return a live, routed InteractiveSession. */
150
+ async loadSession(opts) {
151
+ validateLifecycleSessionId(opts.sessionId, opts.label, "loadSession");
152
+ return this.createInteractiveSession(opts, "loadSession", (connection, prepared) => connection.loadSession(opts.sessionId, prepared.sessionOptions));
153
+ }
154
+ /** Resume an existing ACP session without replay and return a live, routed InteractiveSession. */
155
+ async resumeSession(opts) {
156
+ validateLifecycleSessionId(opts.sessionId, opts.label, "resumeSession");
157
+ return this.createInteractiveSession(opts, "resumeSession", (connection, prepared) => connection.resumeSession(opts.sessionId, prepared.sessionOptions));
158
+ }
158
159
  async run(prompt, options = {}) {
159
160
  const opts = options;
160
161
  const schema = opts.schema;
@@ -266,6 +267,72 @@ export class AcpAgentRunner {
266
267
  await this.pool.dispose();
267
268
  this.events.removeAllListeners();
268
269
  }
270
+ async createInteractiveSession(opts, methodName, open) {
271
+ if (this.disposed)
272
+ throw new Error("ACP agent runner is disposed");
273
+ validateInteractiveCwd(opts.cwd, opts.label, methodName);
274
+ opts.signal?.throwIfAborted();
275
+ const prepared = this.prepareSession(opts, {
276
+ cwd: opts.cwd,
277
+ schema: undefined,
278
+ registry: this.backends,
279
+ permissionResolver: opts.permissionResolver ?? opts.onPermissionRequest,
280
+ elicitationResolver: opts.onElicitation,
281
+ retainSessionLog: opts.retainSessionLog ?? false,
282
+ });
283
+ this.installExitHook();
284
+ let interactive;
285
+ const connection = this.createDedicatedConnection(prepared.backend, () => {
286
+ // Dedicated connections are not stored in pool arrays, so there is nothing to evict.
287
+ // Once the public wrapper exists, process death releases it through the normal path:
288
+ // subscriptions are removed, session_close is emitted, and future prompt() calls fail
289
+ // with the clean released-session error. Before then, this method's catch tears down.
290
+ void interactive?.release();
291
+ });
292
+ let session;
293
+ try {
294
+ session = await open(connection, prepared);
295
+ opts.signal?.throwIfAborted();
296
+ await applyModelSelection(session, prepared.modelSpec, opts);
297
+ opts.signal?.throwIfAborted();
298
+ if (opts.mode)
299
+ await session.setMode(opts.mode);
300
+ opts.signal?.throwIfAborted();
301
+ if (this.disposed)
302
+ throw new Error("ACP agent runner is disposed");
303
+ interactive = new InteractiveSession({
304
+ session,
305
+ connection,
306
+ backend: prepared.backend,
307
+ subscribe: (name, listener) => this.events.on(name, listener),
308
+ onRelease: (self) => this.interactiveSessions.delete(self),
309
+ signal: opts.signal,
310
+ label: opts.label,
311
+ });
312
+ this.interactiveSessions.set(interactive, connection);
313
+ return interactive;
314
+ }
315
+ catch (error) {
316
+ try {
317
+ await session?.release();
318
+ }
319
+ catch {
320
+ // best-effort: lifecycle setup failed, so teardown must never mask the real error.
321
+ }
322
+ await disposeBestEffort(connection);
323
+ throw error;
324
+ }
325
+ }
326
+ createDedicatedConnection(backend, onDead) {
327
+ return PooledConnection.create(backend, {
328
+ onDead,
329
+ onEvent: this.emitEvent,
330
+ permissionResolver: this.permissionResolver,
331
+ elicitationResolver: this.elicitationResolver,
332
+ advertiseElicitation: Boolean(this.elicitationResolver),
333
+ clientHandlers: this.clientHandlers,
334
+ });
335
+ }
269
336
  /** Build the backend choice, model-selection spec, tool policy, and session/new options in one
270
337
  * place for run() and openSession() so new AcpSessionOptions fields cannot drift by path. */
271
338
  prepareSession(opts, config) {
@@ -284,6 +351,7 @@ export class AcpAgentRunner {
284
351
  schema: config.schema,
285
352
  policy,
286
353
  permissionResolver: config.permissionResolver,
354
+ elicitationResolver: config.elicitationResolver,
287
355
  signal: config.signal,
288
356
  mcpServers: opts.mcpServers,
289
357
  // Generic session-scoped _meta passthrough (RunOptions.meta) — merged UNDER the
@@ -412,9 +480,27 @@ function innerModelSpec(spec, backend) {
412
480
  }
413
481
  /** Interactive sessions are public and long-lived, so fail before spawning a dedicated process
414
482
  * when the required worktree root is absent or not absolute. */
415
- function validateInteractiveCwd(cwd, label) {
483
+ function validateInteractiveCwd(cwd, label, methodName) {
416
484
  if (typeof cwd !== "string" || cwd.trim() === "" || !isAbsolute(cwd)) {
417
- throw new WorkflowError("openSession requires cwd to be a non-empty absolute path", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: label });
485
+ throw new WorkflowError(`${methodName} requires cwd to be a non-empty absolute path`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: label });
486
+ }
487
+ }
488
+ function validateOptionalLifecycleCwd(cwd, label, methodName) {
489
+ if (cwd === undefined || cwd === null)
490
+ return;
491
+ validateInteractiveCwd(cwd, label, methodName);
492
+ }
493
+ function validateLifecycleSessionId(sessionId, label, methodName) {
494
+ if (typeof sessionId !== "string" || sessionId.trim() === "") {
495
+ throw new WorkflowError(`${methodName} requires sessionId to be a non-empty string`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: label });
496
+ }
497
+ }
498
+ async function disposeBestEffort(connection) {
499
+ try {
500
+ await connection.dispose();
501
+ }
502
+ catch {
503
+ // Dedicated lifecycle processes are already no longer useful; never mask the request result.
418
504
  }
419
505
  }
420
506
  function backendIdForSpec(spec) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automatalabs/acp-agents",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "license": "Apache-2.0",
5
5
  "engines": {
6
6
  "node": ">=22"