@automatalabs/acp-agents 1.2.7 → 2.0.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 (82) hide show
  1. package/README.md +84 -11
  2. package/dist/acp-client.d.ts +26 -6
  3. package/dist/acp-client.d.ts.map +1 -1
  4. package/dist/acp-client.js +30 -7
  5. package/dist/agent/acp-agent.d.ts +141 -0
  6. package/dist/agent/acp-agent.d.ts.map +1 -0
  7. package/dist/agent/acp-agent.js +837 -0
  8. package/dist/agent/errors.d.ts +17 -0
  9. package/dist/agent/errors.d.ts.map +1 -0
  10. package/dist/agent/errors.js +37 -0
  11. package/dist/agent/events.d.ts +26 -0
  12. package/dist/agent/events.d.ts.map +1 -0
  13. package/dist/agent/events.js +165 -0
  14. package/dist/agent/fork.d.ts +27 -0
  15. package/dist/agent/fork.d.ts.map +1 -0
  16. package/dist/agent/fork.js +28 -0
  17. package/dist/agent/probe.d.ts +14 -0
  18. package/dist/agent/probe.d.ts.map +1 -0
  19. package/dist/agent/probe.js +87 -0
  20. package/dist/agent/process-registry.d.ts +9 -0
  21. package/dist/agent/process-registry.d.ts.map +1 -0
  22. package/dist/agent/process-registry.js +28 -0
  23. package/dist/agent/queue.d.ts +19 -0
  24. package/dist/agent/queue.d.ts.map +1 -0
  25. package/dist/agent/queue.js +90 -0
  26. package/dist/agent/routing.d.ts +27 -0
  27. package/dist/agent/routing.d.ts.map +1 -0
  28. package/dist/agent/routing.js +87 -0
  29. package/dist/agent/structured.d.ts +47 -0
  30. package/dist/agent/structured.d.ts.map +1 -0
  31. package/dist/agent/structured.js +90 -0
  32. package/dist/agent/turn.d.ts +65 -0
  33. package/dist/agent/turn.d.ts.map +1 -0
  34. package/dist/agent/turn.js +187 -0
  35. package/dist/agent/types.d.ts +226 -0
  36. package/dist/agent/types.d.ts.map +1 -0
  37. package/dist/agent/types.js +9 -0
  38. package/dist/backend.d.ts +18 -5
  39. package/dist/backend.d.ts.map +1 -1
  40. package/dist/backends/claude.d.ts +5 -0
  41. package/dist/backends/claude.d.ts.map +1 -1
  42. package/dist/backends/claude.js +45 -17
  43. package/dist/backends/codex.d.ts +4 -0
  44. package/dist/backends/codex.d.ts.map +1 -1
  45. package/dist/backends/codex.js +14 -9
  46. package/dist/backends/custom.d.ts.map +1 -1
  47. package/dist/backends/custom.js +3 -2
  48. package/dist/backends/opencode.d.ts +4 -0
  49. package/dist/backends/opencode.d.ts.map +1 -1
  50. package/dist/backends/opencode.js +5 -1
  51. package/dist/backends/pi.d.ts +5 -2
  52. package/dist/backends/pi.d.ts.map +1 -1
  53. package/dist/backends/pi.js +15 -3
  54. package/dist/config-catalog.d.ts +173 -0
  55. package/dist/config-catalog.d.ts.map +1 -0
  56. package/dist/config-catalog.js +408 -0
  57. package/dist/index.d.ts +11 -3
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +12 -1
  60. package/dist/interactive.d.ts +4 -5
  61. package/dist/interactive.d.ts.map +1 -1
  62. package/dist/protocol-coverage.d.ts +82 -0
  63. package/dist/protocol-coverage.d.ts.map +1 -1
  64. package/dist/protocol-coverage.js +76 -0
  65. package/dist/registry.d.ts +12 -0
  66. package/dist/registry.d.ts.map +1 -1
  67. package/dist/registry.js +22 -0
  68. package/dist/routing.d.ts +14 -0
  69. package/dist/routing.d.ts.map +1 -0
  70. package/dist/routing.js +53 -0
  71. package/dist/runner.d.ts.map +1 -1
  72. package/dist/runner.js +9 -68
  73. package/dist/session-ref.d.ts +8 -0
  74. package/dist/session-ref.d.ts.map +1 -0
  75. package/dist/session-ref.js +21 -0
  76. package/dist/structured-tool.d.ts +4 -0
  77. package/dist/structured-tool.d.ts.map +1 -1
  78. package/dist/structured-tool.js +5 -0
  79. package/dist/system-prompt.d.ts +13 -0
  80. package/dist/system-prompt.d.ts.map +1 -0
  81. package/dist/system-prompt.js +67 -0
  82. package/package.json +4 -4
@@ -0,0 +1,837 @@
1
+ import { CANCEL_NOT_HONORED_GRACE_MS, PooledConnection, isChildCleanupError, } from "../acp-client.js";
2
+ import { validateClientHandlers } from "../client-handlers.js";
3
+ import { appendPromptImages, buildRunPrompt, mergeTurnMeta, validatePromptImages } from "../prompt.js";
4
+ import { assertNoModelConfigOption, resolveModelRoute } from "../routing.js";
5
+ import { sessionRefFor } from "../session-ref.js";
6
+ import { StructuredOutputToolHost } from "../structured-tool.js";
7
+ import { assertSystemPromptSupported } from "../system-prompt.js";
8
+ import { agentClosedError, agentTurnError, agentValidationError, mapAgentError } from "./errors.js";
9
+ import { AgentEventBus } from "./events.js";
10
+ import { acquireForkedSession, forkTraitFor } from "./fork.js";
11
+ import { probeCatalog } from "./probe.js";
12
+ import { releaseOnExit, retainOnExit } from "./process-registry.js";
13
+ import { SerialQueue } from "./queue.js";
14
+ import { freshBackendFor, resolveAgentRegistry, resolveAgentRoute, resolveRefRoute, validateAgentCwd, } from "./routing.js";
15
+ import { assertPerTurnSchemaAllowed, planStructured } from "./structured.js";
16
+ import { TurnCollector, buildTurn } from "./turn.js";
17
+ import { ZERO_USAGE, } from "./types.js";
18
+ /** Handed to the constructor by `AcpAgent.#seeded` only. Set and consumed SYNCHRONOUSLY (the
19
+ * constructor has no await), so two constructions can never interleave. Module-private: nothing
20
+ * outside this file can reach it, so the public constructor signature never grows a parameter
21
+ * through which a caller could skip the ref/poolKey checks of the statics. */
22
+ let constructionSeed;
23
+ let cancelGraceMs = CANCEL_NOT_HONORED_GRACE_MS;
24
+ /** Package-internal test seam for the ignored-cancel escalation grace. Not barrel-exported. */
25
+ export function setCancelGraceForTests(ms) {
26
+ const previous = cancelGraceMs;
27
+ cancelGraceMs = ms;
28
+ return () => {
29
+ cancelGraceMs = previous;
30
+ };
31
+ }
32
+ const noop = () => undefined;
33
+ /** Resolve true when `op` settles before `ms`, false when the grace wins; the timer never keeps
34
+ * the process alive and is cleared either way. */
35
+ function resolvesWithin(op, ms) {
36
+ return new Promise((resolve) => {
37
+ const timer = setTimeout(() => resolve(false), ms);
38
+ timer.unref?.();
39
+ void op.then(() => {
40
+ clearTimeout(timer);
41
+ resolve(true);
42
+ });
43
+ });
44
+ }
45
+ function isRecord(value) {
46
+ return value !== null && typeof value === "object" && !Array.isArray(value);
47
+ }
48
+ /** Layer the backend's vendor-stream `_meta` UNDER the caller's `meta`, merging one level deep
49
+ * for keys both carry as objects (`sessionRequestMeta` layers shallowly, so a caller's
50
+ * `claudeCode: { custom }` must not erase the stream flag and vice versa). */
51
+ function layerRawMeta(rawMeta, userMeta) {
52
+ if (!rawMeta)
53
+ return userMeta;
54
+ if (!userMeta)
55
+ return rawMeta;
56
+ const merged = { ...rawMeta, ...userMeta };
57
+ for (const [key, rawValue] of Object.entries(rawMeta)) {
58
+ const userValue = userMeta[key];
59
+ if (isRecord(rawValue) && isRecord(userValue))
60
+ merged[key] = { ...rawValue, ...userValue };
61
+ }
62
+ return merged;
63
+ }
64
+ /** Client-side guard: every authored option id must be in the advertised catalog (values are
65
+ * still validated by the agent). */
66
+ function assertKnownConfigOptionIds(configOptions, advertised, backendId, label) {
67
+ if (!configOptions)
68
+ return;
69
+ const ids = advertised.map((option) => option.id);
70
+ for (const id of Object.keys(configOptions)) {
71
+ if (ids.includes(id))
72
+ continue;
73
+ throw agentValidationError(`config option "${id}" is not advertised by ${backendId}; advertised: ${ids.length > 0 ? ids.join(", ") : "(none)"}`, label);
74
+ }
75
+ }
76
+ function resolveNewSeed(options) {
77
+ const registry = resolveAgentRegistry(options.backends, options.label);
78
+ const route = resolveAgentRoute(options, registry);
79
+ assertSystemPromptSupported(route.backend, options.systemPrompt, options.label);
80
+ return { kind: "new", registry, backend: route.backend, modelSpec: route.modelSpec };
81
+ }
82
+ function assertSessionRef(ref, label, method) {
83
+ if (!isRecord(ref) || typeof ref.sessionId !== "string" || ref.sessionId.trim() === "") {
84
+ throw agentValidationError(`${method} requires a session ref with a non-empty sessionId`, label);
85
+ }
86
+ if (typeof ref.backendId !== "string" || ref.backendId.trim() === "") {
87
+ throw agentValidationError(`${method} requires a session ref with a non-empty backendId`, label);
88
+ }
89
+ }
90
+ /**
91
+ * One ACP agent session on its own dedicated backend process.
92
+ *
93
+ * Lazy: the constructor validates (cwd, `configOptions`, the registry, `clientHandlers`) and
94
+ * routes the backend synchronously but spawns nothing; the first queued operation (an explicit
95
+ * `ready()` or an implicit `prompt()`) opens the session. `state` walks
96
+ * `idle → opening → ready ⇄ busy → closed`.
97
+ */
98
+ export class AcpAgent {
99
+ /** The session's absolute working directory (sent on session/new|fork|resume|load). */
100
+ cwd;
101
+ /** The human label stamped on event contexts and error `agentLabel`; never on the wire. */
102
+ label;
103
+ /** The model this agent selects at open, as a routing spec that leads back to the same backend
104
+ * (`<backendId>/<model id>`, e.g. `"claude/opus[1m]"`), or `undefined` when no model was
105
+ * selected (the backend's default). Inherited by forks. An `AgentSessionRef` carries no model,
106
+ * so a cold reopen keeps it only when told: `AcpAgent.resume(agent.sessionRef!, { model: agent.model })`. */
107
+ model;
108
+ #options;
109
+ #seed;
110
+ #registry;
111
+ #backend;
112
+ #modelSpec;
113
+ #schema;
114
+ #retainHistory;
115
+ #raw;
116
+ #signal;
117
+ #bus = new AgentEventBus();
118
+ #queue = new SerialQueue();
119
+ #replay = [];
120
+ #openPromise;
121
+ #closed = false;
122
+ #closedDetail;
123
+ #connection;
124
+ #handle;
125
+ #plan;
126
+ #structuredHost;
127
+ #sessionId;
128
+ #sessionRef;
129
+ #sessionUsage = ZERO_USAGE;
130
+ #historySeed = [];
131
+ #textSeed = "";
132
+ #collectingReplay = false;
133
+ #activeTurn;
134
+ #closePromise;
135
+ /** The `keep` the first `close()` asked for; a constructor-signal abort that drains that queued
136
+ * close() tears down with it, never with a keep of its own. */
137
+ #closeKeep;
138
+ #teardownPromise;
139
+ #teardownStarted = false;
140
+ #forkCount = 0;
141
+ #removeAbort;
142
+ /**
143
+ * Lazy: validates cwd/configOptions/registry/clientHandlers synchronously, routes the backend,
144
+ * spawns nothing. This is the ONLY public constructor signature — seeded agents (forks, cold
145
+ * reopen) are built by the statics through a module-private factory.
146
+ */
147
+ constructor(options) {
148
+ const label = options.label;
149
+ validateAgentCwd(options.cwd, label, "AcpAgent");
150
+ assertNoModelConfigOption(options.configOptions, label);
151
+ try {
152
+ validateClientHandlers(options.clientHandlers);
153
+ }
154
+ catch (error) {
155
+ throw agentValidationError(error instanceof Error ? error.message : String(error), label);
156
+ }
157
+ const seed = constructionSeed ?? resolveNewSeed(options);
158
+ this.#options = { ...options };
159
+ this.#seed = seed;
160
+ this.#registry = seed.registry;
161
+ this.#backend = seed.backend;
162
+ this.#modelSpec = seed.modelSpec;
163
+ this.cwd = options.cwd;
164
+ this.label = label;
165
+ this.model = seed.modelSpec === undefined ? undefined : `${seed.backend.id}/${seed.modelSpec}`;
166
+ this.#schema = options.schema;
167
+ this.#retainHistory = options.retainHistory ?? true;
168
+ this.#raw = options.raw ?? true;
169
+ this.#signal = options.signal;
170
+ // Verbatim session/update records received before the session was ready (a load's replay, a
171
+ // fork's pre-response replay) — adopted from the acquisition buffer, observable as `replay`.
172
+ this.#bus.tap((name, event) => {
173
+ if (name !== "session_update" || !this.#collectingReplay)
174
+ return;
175
+ const { update } = event;
176
+ this.#replay.push({ update: structuredClone(update), receivedAt: Date.now() });
177
+ });
178
+ if (options.signal) {
179
+ const signal = options.signal;
180
+ if (signal.aborted) {
181
+ this.#closed = true;
182
+ }
183
+ else {
184
+ const onAbort = () => this.#onAbort();
185
+ signal.addEventListener("abort", onAbort, { once: true });
186
+ this.#removeAbort = () => signal.removeEventListener("abort", onAbort);
187
+ }
188
+ }
189
+ }
190
+ /** Statics and `fork()` build agents through this; the public signature never grows a second
191
+ * parameter, so a caller cannot hand-roll a seed that skips the ref/poolKey checks. */
192
+ static #seeded(options, seed) {
193
+ constructionSeed = seed;
194
+ try {
195
+ return new AcpAgent(options);
196
+ }
197
+ finally {
198
+ constructionSeed = undefined;
199
+ }
200
+ }
201
+ static async #opened(agent) {
202
+ try {
203
+ await agent.ready();
204
+ return agent;
205
+ }
206
+ catch (error) {
207
+ await agent.close().catch(noop);
208
+ throw error;
209
+ }
210
+ }
211
+ /** `new AcpAgent(options)` + `ready()`; on failure the agent is closed and the mapped error rethrown. */
212
+ static async open(options) {
213
+ return AcpAgent.#opened(new AcpAgent(options));
214
+ }
215
+ /** No-prompt catalog discovery: the same `HarnessConfigReport` the MCP `action:"config"` is
216
+ * projected from, plus the per-harness `models` view. Never throws for a per-harness failure
217
+ * (`probed: false`); one disposed process per target. */
218
+ static probe(options = {}) {
219
+ return probeCatalog(options);
220
+ }
221
+ /** `session/resume` of `ref.sessionId` on a fresh dedicated process of `ref.backendId`
222
+ * (routed by name — never the default backend — and pool-key checked). `cwd` defaults to
223
+ * `ref.cwd`; `model` must stay on the ref's backend. */
224
+ static resume(ref, options = {}) {
225
+ return AcpAgent.#reopen("resume", ref, options);
226
+ }
227
+ /** `session/load`: the agent replays the transcript before the response; it lands in
228
+ * `history`/`text`/`replay` (the statics return after the fact, so the replay is observable
229
+ * only there, not through `on()`). */
230
+ static load(ref, options = {}) {
231
+ return AcpAgent.#reopen("load", ref, options);
232
+ }
233
+ /** Cold fork of a recorded session: the trait-driven choreography without a history seed
234
+ * (`history` starts empty on id-only backends unless the reattach fell back to `session/load`).
235
+ * To seed the fork with the transcript: `const src = await AcpAgent.load(ref); await src.fork()`. */
236
+ static fork(ref, options = {}) {
237
+ return AcpAgent.#reopen("fork", ref, options);
238
+ }
239
+ static async #reopen(kind, ref, options) {
240
+ const label = options.label;
241
+ const method = `AcpAgent.${kind}`;
242
+ assertSessionRef(ref, label, method);
243
+ const cwd = options.cwd ?? ref.cwd;
244
+ validateAgentCwd(cwd, label, method);
245
+ const registry = resolveAgentRegistry(options.backends, label);
246
+ const route = resolveRefRoute(ref, options.model, registry, label);
247
+ assertSystemPromptSupported(route.backend, options.systemPrompt, label);
248
+ const base = { registry, backend: route.backend, modelSpec: route.modelSpec };
249
+ let seed;
250
+ if (kind === "fork") {
251
+ const trait = forkTraitFor(route.backend, registry);
252
+ if (trait.cwd === "source-only" && cwd !== ref.cwd) {
253
+ throw agentValidationError(`fork on ${route.backend.id} must keep the source cwd (${ref.cwd})`, label);
254
+ }
255
+ seed = { kind, sourceSessionId: ref.sessionId, ...base };
256
+ }
257
+ else {
258
+ seed = { kind, sessionId: ref.sessionId, ...base };
259
+ }
260
+ return AcpAgent.#opened(AcpAgent.#seeded({ ...options, cwd }, seed));
261
+ }
262
+ // ── Getters (all readable after close; they return retained values) ──
263
+ /** The resolved backend id (built-in id or registered custom name). */
264
+ get backendId() {
265
+ return this.#backend.id;
266
+ }
267
+ /** `idle` → `opening` → `ready` ⇄ `busy` → `closed` (set the instant `close()` is called,
268
+ * the constructor signal aborts, or the process dies). */
269
+ get state() {
270
+ if (this.#closed)
271
+ return "closed";
272
+ if (this.#handle === undefined)
273
+ return this.#openPromise ? "opening" : "idle";
274
+ return this.#queue.running ? "busy" : "ready";
275
+ }
276
+ /** The ACP session id once open; retained after close. */
277
+ get sessionId() {
278
+ return this.#sessionId;
279
+ }
280
+ /** The re-attach handle computed at open (drives `AcpAgent.resume/load/fork`); retained after close. */
281
+ get sessionRef() {
282
+ return this.#sessionRef;
283
+ }
284
+ /** Capabilities negotiated on this agent's dedicated connection. */
285
+ get capabilities() {
286
+ return this.#connection?.capabilities;
287
+ }
288
+ /** The latest echoed session config-option catalog (verbatim ACP wire shapes). */
289
+ get configOptions() {
290
+ return this.#handle?.advertisedConfigOptions ?? [];
291
+ }
292
+ /** The agent-advertised mode catalog plus the current mode, when supported. */
293
+ get modes() {
294
+ return this.#handle?.modes;
295
+ }
296
+ /** `[...seed, ...session history]` (copies on read). The seed is the parent's snapshot for a
297
+ * live fork; a `session/load` replay lands in the session history itself. */
298
+ get history() {
299
+ return [
300
+ ...this.#historySeed.map((entry) => ({ ...entry })),
301
+ ...(this.#handle?.history ?? []).map((entry) => ({ ...entry })),
302
+ ];
303
+ }
304
+ /** Verbatim session/update records received before the session was ready (a fork's
305
+ * pre-response replay, a load's replay), adopted from the acquisition buffer. */
306
+ get replay() {
307
+ return this.#replay;
308
+ }
309
+ /** The retained assistant text — the parent's seed (live fork) and this session's messages —
310
+ * folded exactly like `turn.text`: chunks of one message concatenate, distinct messages join
311
+ * with "\n\n". */
312
+ get text() {
313
+ return [this.#textSeed, this.#handle?.foldedText() ?? ""].filter((part) => part !== "").join("\n\n");
314
+ }
315
+ /** Running per-field sum of every turn this agent ran; `ZERO_USAGE` before the first turn. */
316
+ get usage() {
317
+ return this.#sessionUsage;
318
+ }
319
+ /** The session-level structured-output contract, if any. */
320
+ get schema() {
321
+ return this.#schema;
322
+ }
323
+ // ── Events (per agent: only this agent's session id; forks get their own emitter) ──
324
+ /** Subscribe. `session_open` is sticky: a listener registered after the session opened receives
325
+ * it once (next microtask); a listener that saw it live never sees it twice. Returns the
326
+ * unsubscribe thunk; after close it is a no-op. */
327
+ on(name, listener) {
328
+ return this.#bus.on(name, listener);
329
+ }
330
+ once(name, listener) {
331
+ return this.#bus.once(name, listener);
332
+ }
333
+ off(name, listener) {
334
+ this.#bus.off(name, listener);
335
+ }
336
+ // ── Lifecycle ──
337
+ /** Spawn + initialize + session/new|resume|load|fork (idempotent; memoized). The implicit
338
+ * open of the first `prompt()` shares the same promise. */
339
+ ready() {
340
+ return this.#enqueue(async () => {
341
+ await this.#ensureOpen();
342
+ });
343
+ }
344
+ /**
345
+ * One prompt turn, FIFO behind every earlier queued operation. Resolves an `AcpAgentTurn` for
346
+ * EVERY `PromptResponse` the wire returned (no `stopReason` is thrown on — refusal, max_tokens,
347
+ * cancelled included). Rejects only on a wire rejection (mapped like the runner), validation,
348
+ * abort (`signal.reason` untouched), a closed agent, or a typed session failure (the mapped
349
+ * `WorkflowError` carrying the complete turn as `error.turn`; see `isAcpAgentTurnError`).
350
+ * `configOptions`/`mode` are applied before the turn and stick for the session. To stop a
351
+ * specific turn use `options.signal`: it rejects while queued or before the turn reached the
352
+ * wire (nothing is sent) and sends one `session/cancel` once in flight — `cancel()` reaches only
353
+ * a turn already on the wire.
354
+ */
355
+ prompt(content, options = {}) {
356
+ return this.#enqueue(async () => {
357
+ await this.#ensureOpen();
358
+ options.signal?.throwIfAborted();
359
+ this.#signal?.throwIfAborted();
360
+ const handle = this.#handle;
361
+ const plan = this.#plan;
362
+ const backend = this.#backend;
363
+ const label = this.label;
364
+ validatePromptImages(options.images, label);
365
+ assertPerTurnSchemaAllowed(backend, options.schema, label);
366
+ assertNoModelConfigOption(options.configOptions, label);
367
+ assertKnownConfigOptionIds(options.configOptions, handle.advertisedConfigOptions, this.backendId, label);
368
+ try {
369
+ if (options.configOptions) {
370
+ await handle.setConfigOptions(options.configOptions);
371
+ options.signal?.throwIfAborted();
372
+ this.#signal?.throwIfAborted();
373
+ }
374
+ if (options.mode !== undefined) {
375
+ await handle.setMode(options.mode);
376
+ options.signal?.throwIfAborted();
377
+ this.#signal?.throwIfAborted();
378
+ }
379
+ }
380
+ catch (error) {
381
+ throw mapAgentError(error, this.#errorContext(), options.signal?.aborted ? options.signal : this.#signal);
382
+ }
383
+ const turnSchema = options.schema ?? this.#schema;
384
+ // Same request shaping as the runner: a generic backend whose agent may ignore the `_meta`
385
+ // forward gets the contract stated in-band; backend turn meta wins only direct collisions.
386
+ const shaped = typeof content === "string" && turnSchema !== undefined && backend.embedSchemaInPrompt
387
+ ? buildRunPrompt(content, {}, turnSchema, backend, plan.toolActive)
388
+ : content;
389
+ const turnContent = appendPromptImages(shaped, options.images);
390
+ const promptMeta = mergeTurnMeta(options.meta, backend.promptMeta(turnSchema));
391
+ // SYNCHRONOUSLY before the wire call: the collector's tap sees every update of the turn.
392
+ const collector = new TurnCollector(this.#bus, handle, { retainHistory: this.#retainHistory });
393
+ // A capture left by a turn that rejected (wire error/abort) must not leak into this turn.
394
+ plan.registration?.takeCaptured();
395
+ const outcome = handle.promptOutcome(turnContent, promptMeta);
396
+ const active = { ended: outcome.then(noop, noop), aborted: false };
397
+ this.#activeTurn = active;
398
+ const callSignal = options.signal;
399
+ const onCallAbort = () => {
400
+ active.aborted = true;
401
+ active.abortReason = callSignal?.reason;
402
+ void this.#cancelTurn().catch(noop);
403
+ };
404
+ callSignal?.addEventListener("abort", onCallAbort, { once: true });
405
+ let response;
406
+ let failure;
407
+ try {
408
+ ({ response, failure } = await outcome);
409
+ }
410
+ catch (error) {
411
+ if (active.aborted)
412
+ throw active.abortReason;
413
+ if (this.#signal?.aborted)
414
+ throw this.#signal.reason;
415
+ throw mapAgentError(error, this.#errorContext());
416
+ }
417
+ finally {
418
+ collector.stop();
419
+ callSignal?.removeEventListener("abort", onCallAbort);
420
+ if (this.#activeTurn === active)
421
+ this.#activeTurn = undefined;
422
+ }
423
+ // An abort observed in flight rejects with the reason even when the agent answered
424
+ // `stopReason: "cancelled"` — abort is never a resolved turn.
425
+ if (active.aborted)
426
+ throw active.abortReason;
427
+ if (this.#signal?.aborted)
428
+ throw this.#signal.reason;
429
+ const turn = buildTurn({
430
+ response,
431
+ collector,
432
+ handle,
433
+ backend,
434
+ schema: turnSchema,
435
+ captured: plan.registration?.takeCaptured(),
436
+ sessionBefore: this.#sessionUsage,
437
+ });
438
+ // A walled turn still counts the tokens it burned.
439
+ this.#sessionUsage = turn.usage.session;
440
+ if (failure)
441
+ throw agentTurnError(failure, turn, this.#errorContext());
442
+ return turn;
443
+ }, options.signal);
444
+ }
445
+ /** Inject content into the turn in flight (`_session/steering`). Overlaps the FIFO; requires a
446
+ * `prompt()` in flight (SCRIPT_VALIDATION_ERROR otherwise). The complete raw response is returned. */
447
+ async steer(content, options = {}) {
448
+ this.#signal?.throwIfAborted();
449
+ if (this.#closed)
450
+ throw this.#closedError();
451
+ const handle = this.#handle;
452
+ if (!this.#activeTurn || !handle) {
453
+ throw agentValidationError("AcpAgent.steer() requires a prompt() in flight", this.label);
454
+ }
455
+ validatePromptImages(options.images, this.label);
456
+ try {
457
+ return await handle.steer(appendPromptImages(content, options.images), options.meta);
458
+ }
459
+ catch (error) {
460
+ throw mapAgentError(error, this.#errorContext(), this.#signal);
461
+ }
462
+ }
463
+ /** ONE `session/cancel` for the turn whose `session/prompt` is on the wire (no-op otherwise).
464
+ * Resolves at the notify boundary; the in-flight `prompt()` then resolves with
465
+ * `stopReason: "cancelled"` when the agent honors it. A turn that ignores the cancel for the
466
+ * grace period ends in process disposal WITHOUT a wire `session/close` (the session stays
467
+ * re-openable through `sessionRef`); the turn then rejects and the agent is closed. Queued
468
+ * turns are untouched, and so is a turn that has started (`state === "busy"`) but has not
469
+ * reached the wire yet — the lazy first open, or its per-turn `configOptions`/`mode` — a
470
+ * `cancel()` in that window is a no-op the turn never sees. A per-call `signal` covers every
471
+ * window (rejects with the reason before anything is sent; `session/cancel` once in flight). */
472
+ cancel() {
473
+ return this.#cancelTurn();
474
+ }
475
+ /**
476
+ * Fork this agent onto a NEW dedicated process (queued: it runs only when no turn is in flight,
477
+ * so the parent's persisted transcript is complete). Inherits every constructor option except
478
+ * `label` (suffixed `/fork-<n>`) and `signal`; `overrides` may change anything but the backend
479
+ * (`backends`, `authStore`, `providerStore`, `clientHandlers` are typed out; a `model` override
480
+ * must route to the same backend; a `cwd` override is rejected on `source-only` backends). The
481
+ * child's `history`/`text` are seeded from the parent's snapshot on backends whose fork response
482
+ * has no replay. The parent keeps going, unaffected; closing either side never affects the other.
483
+ */
484
+ fork(overrides = {}) {
485
+ return this.#enqueue(async () => {
486
+ await this.#ensureOpen();
487
+ this.#signal?.throwIfAborted();
488
+ const handle = this.#handle;
489
+ const trait = forkTraitFor(this.#backend, this.#registry);
490
+ const n = (this.#forkCount += 1);
491
+ const label = overrides.label ?? (this.label ? `${this.label}/fork-${n}` : `fork-${n}`);
492
+ const cwd = overrides.cwd ?? this.cwd;
493
+ // An override set to `undefined` means "not overridden" (`fork({ schema: maybeSchema })` with
494
+ // an undefined variable type-checks): drop such keys so the spread cannot erase the parent's
495
+ // value.
496
+ const defined = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
497
+ const merged = {
498
+ ...this.#options,
499
+ ...defined,
500
+ cwd,
501
+ label,
502
+ signal: overrides.signal,
503
+ backends: this.#options.backends,
504
+ authStore: this.#options.authStore,
505
+ providerStore: this.#options.providerStore,
506
+ clientHandlers: this.#options.clientHandlers,
507
+ };
508
+ validateAgentCwd(cwd, this.label, "AcpAgent.fork");
509
+ if (trait.cwd === "source-only" && cwd !== this.cwd) {
510
+ throw agentValidationError(`fork on ${this.backendId} must keep the source cwd (${this.cwd})`, this.label);
511
+ }
512
+ let route;
513
+ if (overrides.model !== undefined) {
514
+ route = resolveModelRoute(overrides.model, this.#registry);
515
+ const samePool = (route.backend.poolKey ?? route.backend.id) === (this.#backend.poolKey ?? this.backendId);
516
+ if (route.backend.id !== this.backendId || !samePool) {
517
+ throw agentValidationError(`fork model "${overrides.model}" must stay on backend "${this.backendId}"`, this.label);
518
+ }
519
+ }
520
+ assertNoModelConfigOption(merged.configOptions, label);
521
+ // The backend is fixed by the parent, so an inherited value already passed; an override
522
+ // is validated here, before the child's process spawns.
523
+ assertSystemPromptSupported(this.#backend, merged.systemPrompt, label);
524
+ const child = AcpAgent.#seeded(merged, {
525
+ kind: "fork",
526
+ sourceSessionId: handle.sessionId,
527
+ registry: this.#registry,
528
+ backend: route?.backend ?? freshBackendFor(this.#backend, this.#registry),
529
+ modelSpec: route ? route.modelSpec : this.#modelSpec,
530
+ historySeed: this.history.map((entry) => ({ ...entry })),
531
+ textSeed: this.text,
532
+ });
533
+ return AcpAgent.#opened(child);
534
+ });
535
+ }
536
+ /** `session/set_mode` (queued; strict — an unadvertised id is a SCRIPT_VALIDATION_ERROR). */
537
+ setMode(modeId) {
538
+ return this.#enqueue(async () => {
539
+ await this.#ensureOpen();
540
+ this.#signal?.throwIfAborted();
541
+ try {
542
+ await this.#handle.setMode(modeId);
543
+ }
544
+ catch (error) {
545
+ throw mapAgentError(error, this.#errorContext(), this.#signal);
546
+ }
547
+ });
548
+ }
549
+ /** `session/set_config_option` per id in ascending order (queued; sticky; `"model"` reserved;
550
+ * unknown ids rejected against the advertised catalog). */
551
+ setConfigOptions(options) {
552
+ return this.#enqueue(async () => {
553
+ await this.#ensureOpen();
554
+ this.#signal?.throwIfAborted();
555
+ const handle = this.#handle;
556
+ assertNoModelConfigOption(options, this.label);
557
+ assertKnownConfigOptionIds(options, handle.advertisedConfigOptions, this.backendId, this.label);
558
+ try {
559
+ await handle.setConfigOptions(options);
560
+ }
561
+ catch (error) {
562
+ throw mapAgentError(error, this.#errorContext(), this.#signal);
563
+ }
564
+ });
565
+ }
566
+ /**
567
+ * Close: `state` becomes `closed` immediately (no new work is admitted), the teardown waits
568
+ * behind queued work, releases the session (`keep: true` skips the wire `session/close` so the
569
+ * agent-persisted session stays re-openable via `sessionRef`), disposes the dedicated process,
570
+ * and releases the structured-output tool. Idempotent (same promise); never throws for an
571
+ * already-dead process; rethrows only a `child_cleanup_error` (mapped, non-recoverable).
572
+ */
573
+ close(options = {}) {
574
+ this.#closeKeep ??= options.keep === true;
575
+ this.#closePromise ??= this.#closeOwned(this.#closeKeep);
576
+ return this.#closePromise;
577
+ }
578
+ /** `await using agent = …` — equivalent to `close()`. */
579
+ async [Symbol.asyncDispose]() {
580
+ await this.close();
581
+ }
582
+ // ── Internals ──
583
+ #enqueue(op, signal) {
584
+ if (this.#signal?.aborted)
585
+ return Promise.reject(this.#signal.reason);
586
+ if (this.#closed)
587
+ return Promise.reject(this.#closedError());
588
+ return this.#queue.run(op, signal);
589
+ }
590
+ #closedError(detail) {
591
+ return agentClosedError(this.label, this.backendId, detail ?? this.#closedDetail);
592
+ }
593
+ #errorContext() {
594
+ return {
595
+ label: this.label,
596
+ backendId: this.backendId,
597
+ backend: this.#backend,
598
+ providerErrorMetadata: this.#handle?.providerErrorMetadata,
599
+ authMethods: this.#connection?.capabilities?.authMethods,
600
+ };
601
+ }
602
+ #ensureOpen() {
603
+ this.#openPromise ??= this.#open();
604
+ return this.#openPromise;
605
+ }
606
+ #connectionDeps() {
607
+ const options = this.#options;
608
+ return {
609
+ onDead: () => this.#onDead(),
610
+ onEvent: this.#bus.sink,
611
+ // Session-scoped resolvers ride AcpSessionOptions; the connection-wide ones stay undefined.
612
+ advertiseElicitation: Boolean(options.onElicitation),
613
+ authStore: options.authStore,
614
+ providerStore: options.providerStore,
615
+ clientHandlers: options.clientHandlers,
616
+ };
617
+ }
618
+ #layeredMeta() {
619
+ const options = this.#options;
620
+ return this.#raw ? layerRawMeta(this.#backend.rawMessagesMeta?.(), options.meta) : options.meta;
621
+ }
622
+ #sessionOptions(plan) {
623
+ const options = this.#options;
624
+ return {
625
+ cwd: this.cwd,
626
+ schema: this.#schema,
627
+ policy: options.tools ?? {},
628
+ permissionResolver: options.onPermissionRequest,
629
+ enforceToolPolicyBeforePermissionResolver: false,
630
+ elicitationResolver: options.onElicitation,
631
+ // The agent owns abort (it sends the cancel and the escalation itself); never the handle.
632
+ signal: undefined,
633
+ mcpServers: plan.mcpServers,
634
+ meta: this.#layeredMeta(),
635
+ label: this.label,
636
+ systemPrompt: options.systemPrompt,
637
+ retainSessionLog: this.#retainHistory,
638
+ };
639
+ }
640
+ #planStructured(connection) {
641
+ return planStructured({
642
+ schema: this.#schema,
643
+ backend: this.#backend,
644
+ mcpServers: this.#options.mcpServers,
645
+ host: () => (this.#structuredHost ??= new StructuredOutputToolHost()),
646
+ }, connection);
647
+ }
648
+ async #open() {
649
+ let handle;
650
+ let plan;
651
+ try {
652
+ // Inside the try: `create` spawns synchronously and can throw before any wire traffic
653
+ // (spawn argument validation, missing stdio pipes, a backend's `spawnConfig()` side
654
+ // effects); such a failure must close the agent and map like every other open failure.
655
+ const connection = PooledConnection.create(this.#backend, this.#connectionDeps());
656
+ this.#connection = connection;
657
+ retainOnExit(connection);
658
+ this.#bus.beginAcquisition();
659
+ this.#collectingReplay = true;
660
+ const seed = this.#seed;
661
+ let replayed = false;
662
+ if (seed.kind === "new") {
663
+ // `prepare` runs after initialize, so the injection decision sees the capabilities.
664
+ handle = await connection.openPreparedSession(async (ready) => {
665
+ plan = await this.#planStructured(ready);
666
+ return this.#sessionOptions(plan);
667
+ });
668
+ }
669
+ else {
670
+ // The cheapest "await initialize": the injection decision needs the capabilities.
671
+ await connection.authMethods();
672
+ plan = await this.#planStructured(connection);
673
+ const opts = this.#sessionOptions(plan);
674
+ if (seed.kind === "fork") {
675
+ const trait = forkTraitFor(this.#backend, this.#registry);
676
+ const acquired = await acquireForkedSession(connection, seed.sourceSessionId, opts, trait);
677
+ handle = acquired.handle;
678
+ replayed = acquired.method === "load";
679
+ }
680
+ else if (seed.kind === "resume") {
681
+ handle = await connection.resumeSession(seed.sessionId, opts);
682
+ }
683
+ else {
684
+ handle = await connection.loadSession(seed.sessionId, opts);
685
+ replayed = true;
686
+ }
687
+ // The replay is complete at the load response; mark synchronously, before any later
688
+ // wire message can be applied.
689
+ if (replayed)
690
+ handle.markLoadBoundary();
691
+ }
692
+ this.#handle = handle;
693
+ this.#plan = plan;
694
+ this.#sessionId = handle.sessionId;
695
+ this.#bus.endAcquisition(handle.sessionId);
696
+ this.#collectingReplay = false;
697
+ this.#signal?.throwIfAborted();
698
+ await this.#applyPostOpen(handle);
699
+ if (seed.kind === "fork")
700
+ this.#seedHistory(seed.historySeed, seed.textSeed, handle);
701
+ this.#sessionRef = sessionRefFor(handle, this.#backend, this.cwd);
702
+ this.#sessionUsage = ZERO_USAGE;
703
+ }
704
+ catch (error) {
705
+ this.#collectingReplay = false;
706
+ this.#bus.abortAcquisition();
707
+ this.#closed = true;
708
+ this.#handle ??= handle;
709
+ this.#plan ??= plan;
710
+ plan?.registration?.release();
711
+ // Cleanup failure (child_cleanup_error) wins, exactly like the runner's interactive open.
712
+ await this.#teardown(false);
713
+ throw this.#signal?.aborted ? this.#signal.reason : mapAgentError(error, this.#errorContext());
714
+ }
715
+ }
716
+ /** Re-apply model selection, config options and the mode on the LIVE handle (fork/resume/load
717
+ * responses replace the catalog). The mode rule is the runner's, verbatim. */
718
+ async #applyPostOpen(handle) {
719
+ const opts = this.#options;
720
+ const backend = this.#backend;
721
+ if (this.#modelSpec !== undefined)
722
+ await handle.selectModel(this.#modelSpec);
723
+ this.#signal?.throwIfAborted();
724
+ assertKnownConfigOptionIds(opts.configOptions, handle.advertisedConfigOptions, this.backendId, this.label);
725
+ await handle.setConfigOptions(opts.configOptions);
726
+ this.#signal?.throwIfAborted();
727
+ const effectiveMode = opts.mode ?? backend.defaultModeId;
728
+ if (effectiveMode &&
729
+ (opts.mode !== undefined || handle.modes?.availableModes.some((mode) => mode.id === effectiveMode))) {
730
+ await handle.setMode(effectiveMode);
731
+ }
732
+ this.#signal?.throwIfAborted();
733
+ }
734
+ /** Seed a live fork's history/text from the parent's snapshot — only when the child's own
735
+ * accumulator is empty (a `session/load` fallback already replayed the transcript). */
736
+ #seedHistory(seed, text, handle) {
737
+ if (!seed || handle.history.length > 0)
738
+ return;
739
+ this.#historySeed = seed;
740
+ this.#textSeed = text ?? "";
741
+ }
742
+ #cancelTurn() {
743
+ const active = this.#activeTurn;
744
+ const connection = this.#connection;
745
+ const sessionId = this.#sessionId;
746
+ if (!active || !connection || sessionId === undefined)
747
+ return Promise.resolve();
748
+ // Settles pending permissions/elicitations + ONE session/cancel notify.
749
+ active.cancelRequested ??= connection.cancelSession(sessionId);
750
+ active.escalation ??= active.cancelRequested.then(async () => {
751
+ if (await resolvesWithin(active.ended, cancelGraceMs))
752
+ return;
753
+ // Ignored: kill the process; NO wire session/close, so `keep` semantics survive.
754
+ await connection.dispose();
755
+ });
756
+ void active.escalation.catch(noop);
757
+ return active.cancelRequested;
758
+ }
759
+ #onAbort() {
760
+ const reason = this.#signal?.reason;
761
+ this.#closed = true;
762
+ this.#queue.drain(reason);
763
+ void this.#cancelTurn().catch(noop);
764
+ // An open/fork/reattach in flight: dispose the process so the raced wire call rejects.
765
+ if (this.#handle === undefined && this.#connection)
766
+ void this.#connection.dispose().catch(noop);
767
+ // Not queued: tear down once the in-flight op settled (queued ones were just drained). A
768
+ // close() that was queued behind that op keeps the `keep` it asked for.
769
+ void this.#queue.whenIdle().then(() => this.#teardown(this.#closeKeep ?? false)).catch(noop);
770
+ }
771
+ #onDead() {
772
+ // Our own dispose (close / abort / open failure) — the teardown already owns the connection.
773
+ if (this.#teardownStarted)
774
+ return;
775
+ if (!this.#closed)
776
+ this.#closedDetail = "process exited";
777
+ this.#closed = true;
778
+ this.#queue.drain(this.#closedError("process exited before the queued operation ran"));
779
+ void this.#teardown(true).catch(noop);
780
+ }
781
+ async #closeOwned(keep) {
782
+ this.#closed = true;
783
+ let started = false;
784
+ try {
785
+ await this.#queue.run(() => {
786
+ started = true;
787
+ return this.#teardown(keep);
788
+ });
789
+ }
790
+ catch (error) {
791
+ // The teardown itself failed: only a genuine child_cleanup_error (mapped) gets here.
792
+ if (started)
793
+ throw error;
794
+ // The queued entry was drained (constructor abort / process death) while an op was still
795
+ // running: wait for that op to settle, then run the memoized teardown — never under a turn
796
+ // that is still on the wire (the abort's own cancel + grace must play out first).
797
+ await this.#queue.whenIdle();
798
+ await this.#teardown(keep);
799
+ }
800
+ }
801
+ #teardown(keep) {
802
+ this.#teardownPromise ??= this.#teardownOwned(keep);
803
+ return this.#teardownPromise;
804
+ }
805
+ async #teardownOwned(keep) {
806
+ this.#teardownStarted = true;
807
+ const handle = this.#handle;
808
+ const connection = this.#connection;
809
+ const plan = this.#plan;
810
+ const host = this.#structuredHost;
811
+ let cleanupError;
812
+ try {
813
+ if (handle)
814
+ await handle.release({ keepOpen: keep });
815
+ }
816
+ catch (error) {
817
+ if (isChildCleanupError(error))
818
+ cleanupError = error;
819
+ }
820
+ plan?.registration?.release();
821
+ // The process BEFORE the tool host (the runner's order: pool, then tools): the agent process
822
+ // holds keep-alive sockets to the host's HTTP server, and `server.close()` waits for idle
823
+ // sockets to time out (seconds) unless the peer is gone first.
824
+ if (connection) {
825
+ await connection.dispose().catch(noop);
826
+ releaseOnExit(connection);
827
+ }
828
+ if (host)
829
+ await host.dispose().catch(noop);
830
+ this.#removeAbort?.();
831
+ this.#removeAbort = undefined;
832
+ // Last, so the agent's own `session_close` (emitted by the release above) was delivered.
833
+ this.#bus.close();
834
+ if (cleanupError)
835
+ throw mapAgentError(cleanupError, this.#errorContext());
836
+ }
837
+ }