@automatalabs/acp-agents 1.3.0 → 3.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 (88) hide show
  1. package/README.md +66 -21
  2. package/dist/acp-client.d.ts +15 -9
  3. package/dist/acp-client.d.ts.map +1 -1
  4. package/dist/acp-client.js +12 -10
  5. package/dist/agent/acp-agent.d.ts +84 -26
  6. package/dist/agent/acp-agent.d.ts.map +1 -1
  7. package/dist/agent/acp-agent.js +461 -168
  8. package/dist/agent/errors.d.ts +12 -2
  9. package/dist/agent/errors.d.ts.map +1 -1
  10. package/dist/agent/errors.js +38 -9
  11. package/dist/agent/fork.d.ts +7 -5
  12. package/dist/agent/fork.d.ts.map +1 -1
  13. package/dist/agent/fork.js +7 -5
  14. package/dist/agent/messages.d.ts +42 -0
  15. package/dist/agent/messages.d.ts.map +1 -0
  16. package/dist/agent/messages.js +225 -0
  17. package/dist/agent/probe.d.ts +1 -1
  18. package/dist/agent/probe.d.ts.map +1 -1
  19. package/dist/agent/probe.js +5 -1
  20. package/dist/agent/queue.js +2 -2
  21. package/dist/agent/routing.d.ts +19 -1
  22. package/dist/agent/routing.d.ts.map +1 -1
  23. package/dist/agent/routing.js +41 -4
  24. package/dist/agent/stream.d.ts +21 -0
  25. package/dist/agent/stream.d.ts.map +1 -0
  26. package/dist/agent/stream.js +93 -0
  27. package/dist/agent/structured.d.ts +7 -3
  28. package/dist/agent/structured.d.ts.map +1 -1
  29. package/dist/agent/structured.js +24 -14
  30. package/dist/agent/tool-host.d.ts +34 -0
  31. package/dist/agent/tool-host.d.ts.map +1 -0
  32. package/dist/agent/tool-host.js +138 -0
  33. package/dist/agent/tools.d.ts +26 -0
  34. package/dist/agent/tools.d.ts.map +1 -0
  35. package/dist/agent/tools.js +62 -0
  36. package/dist/agent/turn.d.ts +6 -3
  37. package/dist/agent/turn.d.ts.map +1 -1
  38. package/dist/agent/turn.js +15 -67
  39. package/dist/agent/types.d.ts +183 -29
  40. package/dist/agent/types.d.ts.map +1 -1
  41. package/dist/backend.d.ts +13 -5
  42. package/dist/backend.d.ts.map +1 -1
  43. package/dist/backends/claude.d.ts +4 -0
  44. package/dist/backends/claude.d.ts.map +1 -1
  45. package/dist/backends/claude.js +40 -17
  46. package/dist/backends/codex.d.ts +5 -0
  47. package/dist/backends/codex.d.ts.map +1 -1
  48. package/dist/backends/codex.js +15 -9
  49. package/dist/backends/custom.d.ts.map +1 -1
  50. package/dist/backends/custom.js +3 -2
  51. package/dist/backends/opencode.d.ts +4 -0
  52. package/dist/backends/opencode.d.ts.map +1 -1
  53. package/dist/backends/opencode.js +5 -1
  54. package/dist/backends/pi.d.ts +5 -2
  55. package/dist/backends/pi.d.ts.map +1 -1
  56. package/dist/backends/pi.js +15 -3
  57. package/dist/config-catalog.d.ts +4 -0
  58. package/dist/config-catalog.d.ts.map +1 -1
  59. package/dist/config-catalog.js +1 -0
  60. package/dist/index.d.ts +8 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +9 -1
  63. package/dist/interactive.d.ts +4 -5
  64. package/dist/interactive.d.ts.map +1 -1
  65. package/dist/local-mcp-host.d.ts +30 -0
  66. package/dist/local-mcp-host.d.ts.map +1 -0
  67. package/dist/local-mcp-host.js +151 -0
  68. package/dist/protocol-coverage.d.ts +34 -6
  69. package/dist/protocol-coverage.d.ts.map +1 -1
  70. package/dist/protocol-coverage.js +26 -1
  71. package/dist/registry.d.ts +3 -3
  72. package/dist/registry.d.ts.map +1 -1
  73. package/dist/runner.d.ts +5 -0
  74. package/dist/runner.d.ts.map +1 -1
  75. package/dist/runner.js +13 -8
  76. package/dist/structured-output.d.ts +18 -0
  77. package/dist/structured-output.d.ts.map +1 -1
  78. package/dist/structured-output.js +19 -1
  79. package/dist/structured-tool.d.ts +7 -9
  80. package/dist/structured-tool.d.ts.map +1 -1
  81. package/dist/structured-tool.js +14 -102
  82. package/dist/system-prompt.d.ts +13 -0
  83. package/dist/system-prompt.d.ts.map +1 -0
  84. package/dist/system-prompt.js +67 -0
  85. package/dist/traits.d.ts +51 -0
  86. package/dist/traits.d.ts.map +1 -0
  87. package/dist/traits.js +88 -0
  88. package/package.json +4 -4
@@ -1,17 +1,24 @@
1
1
  import { CANCEL_NOT_HONORED_GRACE_MS, PooledConnection, isChildCleanupError, } from "../acp-client.js";
2
2
  import { validateClientHandlers } from "../client-handlers.js";
3
3
  import { appendPromptImages, buildRunPrompt, mergeTurnMeta, validatePromptImages } from "../prompt.js";
4
- import { assertNoModelConfigOption, resolveModelRoute } from "../routing.js";
4
+ import { assertNoModelConfigOption } from "../routing.js";
5
5
  import { sessionRefFor } from "../session-ref.js";
6
+ import { repairPromptText } from "../structured-output.js";
6
7
  import { StructuredOutputToolHost } from "../structured-tool.js";
7
- import { agentClosedError, agentTurnError, agentValidationError, mapAgentError } from "./errors.js";
8
+ import { assertSystemPromptSupported } from "../system-prompt.js";
9
+ import { describeBackendTraits } from "../traits.js";
10
+ import { agentClosedError, agentTurnError, agentValidationError, mapAgentError, validateArguments, } from "./errors.js";
8
11
  import { AgentEventBus } from "./events.js";
9
12
  import { acquireForkedSession, forkTraitFor } from "./fork.js";
13
+ import { MessageFolder, copyMessage } from "./messages.js";
10
14
  import { probeCatalog } from "./probe.js";
11
15
  import { releaseOnExit, retainOnExit } from "./process-registry.js";
12
16
  import { SerialQueue } from "./queue.js";
13
- import { freshBackendFor, resolveAgentRegistry, resolveAgentRoute, resolveRefRoute, validateAgentCwd, } from "./routing.js";
14
- import { assertPerTurnSchemaAllowed, planStructured } from "./structured.js";
17
+ import { TurnStream } from "./stream.js";
18
+ import { freshBackendFor, resolveAgentRegistry, resolveAgentRoute, resolveModelSwitch, resolveRefRoute, resolveSameBackendModel, validateAgentCwd, } from "./routing.js";
19
+ import { assertPerTurnSchemaAllowed, planStructured, validateSchemaRetries } from "./structured.js";
20
+ import { AgentToolHost } from "./tool-host.js";
21
+ import { planTools, validateToolDefinitions } from "./tools.js";
15
22
  import { TurnCollector, buildTurn } from "./turn.js";
16
23
  import { ZERO_USAGE, } from "./types.js";
17
24
  /** Handed to the constructor by `AcpAgent.#seeded` only. Set and consumed SYNCHRONOUSLY (the
@@ -75,6 +82,7 @@ function assertKnownConfigOptionIds(configOptions, advertised, backendId, label)
75
82
  function resolveNewSeed(options) {
76
83
  const registry = resolveAgentRegistry(options.backends, options.label);
77
84
  const route = resolveAgentRoute(options, registry);
85
+ assertSystemPromptSupported(route.backend, options.systemPrompt, options.label);
78
86
  return { kind: "new", registry, backend: route.backend, modelSpec: route.modelSpec };
79
87
  }
80
88
  function assertSessionRef(ref, label, method) {
@@ -88,27 +96,29 @@ function assertSessionRef(ref, label, method) {
88
96
  /**
89
97
  * One ACP agent session on its own dedicated backend process.
90
98
  *
91
- * Lazy: the constructor validates (cwd, `configOptions`, the registry, `clientHandlers`) and
92
- * routes the backend synchronously but spawns nothing; the first queued operation (an explicit
93
- * `ready()` or an implicit `prompt()`) opens the session. `state` walks
94
- * `idle → opening → ready ⇄ busy → closed`.
99
+ * Lazy: the constructor validates (cwd, `configOptions`, the registry, `clientHandlers`, the
100
+ * function-tool definitions, `schemaRetries`) and routes the backend synchronously but spawns nothing; the first
101
+ * queued operation (an explicit `ready()` or an implicit `prompt()`) opens the session. `state`
102
+ * walks `idle → opening → ready ⇄ busy → closed`.
95
103
  */
96
104
  export class AcpAgent {
97
105
  /** The session's absolute working directory (sent on session/new|fork|resume|load). */
98
106
  cwd;
99
107
  /** The human label stamped on event contexts and error `agentLabel`; never on the wire. */
100
108
  label;
101
- /** The model this agent selects at open, as a routing spec that leads back to the same backend
102
- * (`<backendId>/<model id>`, e.g. `"claude/opus[1m]"`), or `undefined` when no model was
103
- * selected (the backend's default). Inherited by forks. An `AgentSessionRef` carries no model,
104
- * so a cold reopen keeps it only when told: `AcpAgent.resume(agent.sessionRef!, { model: agent.model })`. */
105
- model;
106
109
  #options;
107
110
  #seed;
108
111
  #registry;
109
112
  #backend;
113
+ /** The verbatim model id this agent is on (what open sends and a switch replaces); `undefined`
114
+ * = no selection. Read by open's post-open apply and by `fork()` for the child's seed. */
110
115
  #modelSpec;
116
+ /** `#modelSpec` in its routed spec form (`<backendId>/<id>`) — what the `model` getter returns. */
117
+ #model;
111
118
  #schema;
119
+ /** The default repair budget (`schemaRetries`, validated in the constructor); a per-turn value wins. */
120
+ #schemaRetries;
121
+ #tools;
112
122
  #retainHistory;
113
123
  #raw;
114
124
  #signal;
@@ -122,11 +132,22 @@ export class AcpAgent {
122
132
  #handle;
123
133
  #plan;
124
134
  #structuredHost;
135
+ /** The `agent_tools` host, created lazily by the first open that injects it; disposed on close. */
136
+ #toolHost;
137
+ /** The collector of the turn on the wire — what a function tool's `toolCallId` is correlated against. */
138
+ #activeCollector;
125
139
  #sessionId;
126
140
  #sessionRef;
127
141
  #sessionUsage = ZERO_USAGE;
128
142
  #historySeed = [];
129
143
  #textSeed = "";
144
+ #messagesSeed = [];
145
+ /** The retained transcript as messages — the same updates the handle's accumulator folds into
146
+ * `history`/`text`, folded per message. Live once the session is registered; a `load` replay
147
+ * is folded from `#replay` at that point (the accumulator saw it too); a fork's pre-response
148
+ * replay is not (the accumulator never saw it — it lands in `replay` only). */
149
+ #transcript = new MessageFolder();
150
+ #transcriptLive = false;
130
151
  #collectingReplay = false;
131
152
  #activeTurn;
132
153
  #closePromise;
@@ -138,40 +159,54 @@ export class AcpAgent {
138
159
  #forkCount = 0;
139
160
  #removeAbort;
140
161
  /**
141
- * Lazy: validates cwd/configOptions/registry/clientHandlers synchronously, routes the backend,
142
- * spawns nothing. This is the ONLY public constructor signature — seeded agents (forks, cold
143
- * reopen) are built by the statics through a module-private factory.
162
+ * Lazy: validates cwd/configOptions/registry/clientHandlers/tools/schemaRetries synchronously,
163
+ * routes the backend, spawns nothing. This is the ONLY public constructor signature — seeded agents (forks,
164
+ * cold reopen) are built by the statics through a module-private factory.
144
165
  */
145
166
  constructor(options) {
146
167
  const label = options.label;
147
- validateAgentCwd(options.cwd, label, "AcpAgent");
148
- assertNoModelConfigOption(options.configOptions, label);
149
- try {
150
- validateClientHandlers(options.clientHandlers);
151
- }
152
- catch (error) {
153
- throw agentValidationError(error instanceof Error ? error.message : String(error), label);
154
- }
155
- const seed = constructionSeed ?? resolveNewSeed(options);
168
+ // Every pre-spawn guard speaks INVALID_ARGUMENT: the SDK's own throw it directly, a shared
169
+ // validator's SCRIPT_VALIDATION_ERROR is re-coded here at the boundary.
170
+ const { seed, tools, schemaRetries } = validateArguments(() => {
171
+ validateAgentCwd(options.cwd, label, "AcpAgent");
172
+ assertNoModelConfigOption(options.configOptions, label);
173
+ try {
174
+ validateClientHandlers(options.clientHandlers);
175
+ }
176
+ catch (error) {
177
+ throw agentValidationError(error instanceof Error ? error.message : String(error), label);
178
+ }
179
+ const tools = validateToolDefinitions(options.tools, label);
180
+ const schemaRetries = validateSchemaRetries(options.schemaRetries, label, "AcpAgent");
181
+ return { seed: constructionSeed ?? resolveNewSeed(options), tools, schemaRetries };
182
+ });
156
183
  this.#options = { ...options };
184
+ this.#tools = tools;
185
+ this.#schemaRetries = schemaRetries;
157
186
  this.#seed = seed;
158
187
  this.#registry = seed.registry;
159
188
  this.#backend = seed.backend;
160
189
  this.#modelSpec = seed.modelSpec;
161
190
  this.cwd = options.cwd;
162
191
  this.label = label;
163
- this.model = seed.modelSpec === undefined ? undefined : `${seed.backend.id}/${seed.modelSpec}`;
192
+ this.#model = seed.modelSpec === undefined ? undefined : `${seed.backend.id}/${seed.modelSpec}`;
164
193
  this.#schema = options.schema;
165
194
  this.#retainHistory = options.retainHistory ?? true;
166
195
  this.#raw = options.raw ?? true;
167
196
  this.#signal = options.signal;
168
197
  // Verbatim session/update records received before the session was ready (a load's replay, a
169
198
  // fork's pre-response replay) — adopted from the acquisition buffer, observable as `replay`.
199
+ // Once the session is registered, every update is folded into the message transcript.
170
200
  this.#bus.tap((name, event) => {
171
- if (name !== "session_update" || !this.#collectingReplay)
201
+ if (name !== "session_update")
172
202
  return;
173
203
  const { update } = event;
174
- this.#replay.push({ update: structuredClone(update), receivedAt: Date.now() });
204
+ if (this.#collectingReplay) {
205
+ this.#replay.push({ update: structuredClone(update), receivedAt: Date.now() });
206
+ }
207
+ else if (this.#transcriptLive) {
208
+ this.#transcript.apply(structuredClone(update), Date.now());
209
+ }
175
210
  });
176
211
  if (options.signal) {
177
212
  const signal = options.signal;
@@ -216,6 +251,17 @@ export class AcpAgent {
216
251
  static probe(options = {}) {
217
252
  return probeCatalog(options);
218
253
  }
254
+ /** The table-based traits of the backend `spec` routes to — exactly the constructor's routing
255
+ * (`backends` merged over `AGENTPRISM_BACKENDS`, a registered name wins, an unrouted spec goes
256
+ * to the default backend) — without spawning anything. The instance getter refines the same
257
+ * shape with the live initialize advertisements once the agent is open. */
258
+ static traits(spec, options = {}) {
259
+ return validateArguments(() => {
260
+ const registry = resolveAgentRegistry(options.backends);
261
+ const route = resolveAgentRoute({ model: spec }, registry);
262
+ return describeBackendTraits(route.backend, registry);
263
+ });
264
+ }
219
265
  /** `session/resume` of `ref.sessionId` on a fresh dedicated process of `ref.backendId`
220
266
  * (routed by name — never the default backend — and pool-key checked). `cwd` defaults to
221
267
  * `ref.cwd`; `model` must stay on the ref's backend. */
@@ -228,32 +274,34 @@ export class AcpAgent {
228
274
  static load(ref, options = {}) {
229
275
  return AcpAgent.#reopen("load", ref, options);
230
276
  }
231
- /** Cold fork of a recorded session: the trait-driven choreography without a history seed
232
- * (`history` starts empty on id-only backends unless the reattach fell back to `session/load`).
233
- * To seed the fork with the transcript: `const src = await AcpAgent.load(ref); await src.fork()`. */
277
+ /** Cold fork of a recorded session: the trait-driven choreography with no parent to seed from,
278
+ * so on an id-only backend the reattach PREFERS `session/load` — the agent replays the forked
279
+ * transcript and it lands in the child's `history`/`text`/`messages`/`replay` — and takes
280
+ * `session/resume` (an empty transcript) only when load is not advertised. On a live backend the
281
+ * fork handle is the session and the child's transcript is whatever the agent streams. */
234
282
  static fork(ref, options = {}) {
235
283
  return AcpAgent.#reopen("fork", ref, options);
236
284
  }
237
285
  static async #reopen(kind, ref, options) {
238
286
  const label = options.label;
239
287
  const method = `AcpAgent.${kind}`;
240
- assertSessionRef(ref, label, method);
241
- const cwd = options.cwd ?? ref.cwd;
242
- validateAgentCwd(cwd, label, method);
243
- const registry = resolveAgentRegistry(options.backends, label);
244
- const route = resolveRefRoute(ref, options.model, registry, label);
245
- const base = { registry, backend: route.backend, modelSpec: route.modelSpec };
246
- let seed;
247
- if (kind === "fork") {
248
- const trait = forkTraitFor(route.backend, registry);
249
- if (trait.cwd === "source-only" && cwd !== ref.cwd) {
250
- throw agentValidationError(`fork on ${route.backend.id} must keep the source cwd (${ref.cwd})`, label);
288
+ const { cwd, seed } = validateArguments(() => {
289
+ assertSessionRef(ref, label, method);
290
+ const cwd = options.cwd ?? ref.cwd;
291
+ validateAgentCwd(cwd, label, method);
292
+ const registry = resolveAgentRegistry(options.backends, label);
293
+ const route = resolveRefRoute(ref, options.model, registry, label);
294
+ assertSystemPromptSupported(route.backend, options.systemPrompt, label);
295
+ const base = { registry, backend: route.backend, modelSpec: route.modelSpec };
296
+ if (kind === "fork") {
297
+ const trait = forkTraitFor(route.backend, registry);
298
+ if (trait.cwd === "source-only" && cwd !== ref.cwd) {
299
+ throw agentValidationError(`fork on ${route.backend.id} must keep the source cwd (${ref.cwd})`, label);
300
+ }
301
+ return { cwd, seed: { kind, sourceSessionId: ref.sessionId, reattach: "load", ...base } };
251
302
  }
252
- seed = { kind, sourceSessionId: ref.sessionId, ...base };
253
- }
254
- else {
255
- seed = { kind, sessionId: ref.sessionId, ...base };
256
- }
303
+ return { cwd, seed: { kind, sessionId: ref.sessionId, ...base } };
304
+ });
257
305
  return AcpAgent.#opened(AcpAgent.#seeded({ ...options, cwd }, seed));
258
306
  }
259
307
  // ── Getters (all readable after close; they return retained values) ──
@@ -261,6 +309,15 @@ export class AcpAgent {
261
309
  get backendId() {
262
310
  return this.#backend.id;
263
311
  }
312
+ /** The model this agent is on, as a routing spec that leads back to the same backend
313
+ * (`<backendId>/<model id>`, e.g. `"claude/opus[1m]"`): the constructor's `model` until a
314
+ * `setModel()` or a per-turn `model` applied, then the switched one; `undefined` when nothing
315
+ * was ever selected (the backend's default). Inherited by forks taken after the switch. An
316
+ * `AgentSessionRef` carries no model, so a cold reopen keeps it only when told:
317
+ * `AcpAgent.resume(agent.sessionRef!, { model: agent.model })`. */
318
+ get model() {
319
+ return this.#model;
320
+ }
264
321
  /** `idle` → `opening` → `ready` ⇄ `busy` → `closed` (set the instant `close()` is called,
265
322
  * the constructor signal aborts, or the process dies). */
266
323
  get state() {
@@ -282,6 +339,12 @@ export class AcpAgent {
282
339
  get capabilities() {
283
340
  return this.#connection?.capabilities;
284
341
  }
342
+ /** This backend's traits (`describeBackendTraits`): the tables before open, refined by the live
343
+ * initialize advertisements once the connection is up (retained after close). A fresh frozen
344
+ * object per read. */
345
+ get traits() {
346
+ return describeBackendTraits(this.#backend, this.#registry, this.#connection?.capabilities);
347
+ }
285
348
  /** The latest echoed session config-option catalog (verbatim ACP wire shapes). */
286
349
  get configOptions() {
287
350
  return this.#handle?.advertisedConfigOptions ?? [];
@@ -309,6 +372,13 @@ export class AcpAgent {
309
372
  get text() {
310
373
  return [this.#textSeed, this.#handle?.foldedText() ?? ""].filter((part) => part !== "").join("\n\n");
311
374
  }
375
+ /** The retained transcript as messages (`AcpAgentMessage`, copies on read): `[...seed, ...this
376
+ * session's messages]` — the same retained log `history`/`text` describe, folded per message
377
+ * with the turn fold's boundary, a `load` replay included, seeded from the parent's snapshot on
378
+ * a live fork, and holding only the latest turn under `retainHistory: false`. */
379
+ get messages() {
380
+ return [...this.#messagesSeed.map(copyMessage), ...this.#transcript.snapshot()];
381
+ }
312
382
  /** Running per-field sum of every turn this agent ran; `ZERO_USAGE` before the first turn. */
313
383
  get usage() {
314
384
  return this.#sessionUsage;
@@ -344,103 +414,224 @@ export class AcpAgent {
344
414
  * cancelled included). Rejects only on a wire rejection (mapped like the runner), validation,
345
415
  * abort (`signal.reason` untouched), a closed agent, or a typed session failure (the mapped
346
416
  * `WorkflowError` carrying the complete turn as `error.turn`; see `isAcpAgentTurnError`).
347
- * `configOptions`/`mode` are applied before the turn and stick for the session. To stop a
348
- * specific turn use `options.signal`: it rejects while queued or before the turn reached the
349
- * wire (nothing is sent) and sends one `session/cancel` once in flight — `cancel()` reaches only
350
- * a turn already on the wire.
417
+ * `model`/`configOptions`/`mode` are applied before the turn, in that order, and stick for the
418
+ * session. With a schema active and a `schemaRetries` budget > 0 (constructor or per turn), an
419
+ * `end_turn` that left `structured` absent is followed by up to that many repair turns inside
420
+ * this same operation; the resolved turn is the final attempt's with `structuredAttempts`. To
421
+ * stop a specific turn use `options.signal`: it rejects while queued or before the turn reached
422
+ * the wire (nothing is sent) and sends one `session/cancel` once in flight — `cancel()` reaches
423
+ * only a turn already on the wire (a repair turn included).
351
424
  */
352
425
  prompt(content, options = {}) {
353
- return this.#enqueue(async () => {
354
- await this.#ensureOpen();
355
- options.signal?.throwIfAborted();
356
- this.#signal?.throwIfAborted();
357
- const handle = this.#handle;
358
- const plan = this.#plan;
359
- const backend = this.#backend;
360
- const label = this.label;
426
+ return this.#enqueue(() => this.#promptTurn(content, options, "prompt"), options.signal);
427
+ }
428
+ /**
429
+ * The same turn as `prompt()`, observed as an async iterable of this turn's events: every bus
430
+ * event emitted while the turn's operation runs — from the instant it is dequeued (a lazy
431
+ * open's `session_open`, a per-turn `mode`'s `current_mode_update` included) until the wire
432
+ * settles — each tagged `{ type: <event name>, ...payload }` (an update once, under its kind;
433
+ * never the `session_update` catch-all; a concurrent `steer()`'s `steering` event and the
434
+ * permission / elicitation / raw_message events included), then the terminal
435
+ * `{ type: "turn", turn }` with exactly the `AcpAgentTurn` `prompt()` would have resolved.
436
+ * The turn is queued (FIFO) by this call, exactly like `prompt()`; events are buffered without
437
+ * dropping until consumed. When the turn rejects, the iterator yields the buffered events, then
438
+ * throws that same error. Leaving early — `break`, `return()`, `throw()` — aborts the turn's
439
+ * per-call signal: a turn still queued or not yet on the wire is dropped with nothing sent, a
440
+ * turn in flight gets the one `session/cancel` (and the agent's escalation), and the call
441
+ * resolves only once the turn settled, so no turn keeps running unobserved.
442
+ */
443
+ stream(content, options = {}) {
444
+ // The consumer's exit is not an error: the turn rejects with this marker, which never leaves
445
+ // the stream (a caller's own `options.signal` reason does — the iterator throws it).
446
+ const left = new Error("AcpAgent.stream(): the consumer stopped iterating");
447
+ const controller = new AbortController();
448
+ const outer = options.signal;
449
+ const forward = () => controller.abort(outer?.reason);
450
+ if (outer?.aborted)
451
+ controller.abort(outer.reason);
452
+ else
453
+ outer?.addEventListener("abort", forward, { once: true });
454
+ const stream = new TurnStream(() => {
455
+ controller.abort(left);
456
+ return settled.then(noop, noop);
457
+ });
458
+ // The tap is attached INSIDE the queued operation, the instant this turn is dequeued: from
459
+ // outside the queue it would observe the previous turn's events too.
460
+ const settled = this.#enqueue(async () => {
461
+ const untap = this.#bus.tap((name, event) => {
462
+ if (name === "session_update")
463
+ return;
464
+ stream.push({ type: name, ...event });
465
+ });
466
+ try {
467
+ const turn = await this.#promptTurn(content, { ...options, signal: controller.signal }, "stream");
468
+ stream.push({ type: "turn", turn });
469
+ return turn;
470
+ }
471
+ finally {
472
+ untap();
473
+ }
474
+ }, controller.signal);
475
+ void settled.then(() => stream.end(), (error) => {
476
+ if (error === left)
477
+ stream.end();
478
+ else
479
+ stream.fail(error);
480
+ });
481
+ void settled.then(noop, noop).finally(() => outer?.removeEventListener("abort", forward));
482
+ return stream;
483
+ }
484
+ /** The body of one turn — runs inside the FIFO (`prompt()` queues it directly, `stream()` under
485
+ * its tap). `options.signal` is the per-call signal; `stream()` hands in its own combined one.
486
+ * `entry` is the public method the caller used, so a refused per-turn option names it. */
487
+ async #promptTurn(content, options, entry) {
488
+ await this.#ensureOpen();
489
+ options.signal?.throwIfAborted();
490
+ this.#signal?.throwIfAborted();
491
+ const handle = this.#handle;
492
+ const plan = this.#plan;
493
+ const backend = this.#backend;
494
+ const label = this.label;
495
+ // Every per-turn option is validated up front — a rejected turn sends nothing, not even the
496
+ // options that would have passed.
497
+ const { modelSwitch, schemaRetries } = validateArguments(() => {
361
498
  validatePromptImages(options.images, label);
362
499
  assertPerTurnSchemaAllowed(backend, options.schema, label);
363
500
  assertNoModelConfigOption(options.configOptions, label);
364
501
  assertKnownConfigOptionIds(options.configOptions, handle.advertisedConfigOptions, this.backendId, label);
365
- try {
366
- if (options.configOptions) {
367
- await handle.setConfigOptions(options.configOptions);
368
- options.signal?.throwIfAborted();
369
- this.#signal?.throwIfAborted();
370
- }
371
- if (options.mode !== undefined) {
372
- await handle.setMode(options.mode);
373
- options.signal?.throwIfAborted();
374
- this.#signal?.throwIfAborted();
375
- }
376
- }
377
- catch (error) {
378
- throw mapAgentError(error, this.#errorContext(), options.signal?.aborted ? options.signal : this.#signal);
379
- }
380
- const turnSchema = options.schema ?? this.#schema;
381
- // Same request shaping as the runner: a generic backend whose agent may ignore the `_meta`
382
- // forward gets the contract stated in-band; backend turn meta wins only direct collisions.
383
- const shaped = typeof content === "string" && turnSchema !== undefined && backend.embedSchemaInPrompt
384
- ? buildRunPrompt(content, {}, turnSchema, backend, plan.toolActive)
385
- : content;
386
- const turnContent = appendPromptImages(shaped, options.images);
387
- const promptMeta = mergeTurnMeta(options.meta, backend.promptMeta(turnSchema));
388
- // SYNCHRONOUSLY before the wire call: the collector's tap sees every update of the turn.
389
- const collector = new TurnCollector(this.#bus, handle, { retainHistory: this.#retainHistory });
390
- // A capture left by a turn that rejected (wire error/abort) must not leak into this turn.
391
- plan.registration?.takeCaptured();
392
- const outcome = handle.promptOutcome(turnContent, promptMeta);
393
- const active = { ended: outcome.then(noop, noop), aborted: false };
394
- this.#activeTurn = active;
395
- const callSignal = options.signal;
396
- const onCallAbort = () => {
397
- active.aborted = true;
398
- active.abortReason = callSignal?.reason;
399
- void this.#cancelTurn().catch(noop);
502
+ return {
503
+ modelSwitch: options.model === undefined
504
+ ? undefined
505
+ : resolveModelSwitch(options.model, backend, this.#registry, label, `AcpAgent.${entry}({ model })`),
506
+ schemaRetries: options.schemaRetries === undefined
507
+ ? this.#schemaRetries
508
+ : validateSchemaRetries(options.schemaRetries, label, `AcpAgent.${entry}({ schemaRetries })`),
400
509
  };
401
- callSignal?.addEventListener("abort", onCallAbort, { once: true });
402
- let response;
403
- let failure;
404
- try {
405
- ({ response, failure } = await outcome);
510
+ });
511
+ try {
512
+ // The open's order, verbatim: model, config options, mode.
513
+ if (modelSwitch) {
514
+ await this.#applyModelSwitch(handle, modelSwitch);
515
+ options.signal?.throwIfAborted();
516
+ this.#signal?.throwIfAborted();
406
517
  }
407
- catch (error) {
408
- if (active.aborted)
409
- throw active.abortReason;
410
- if (this.#signal?.aborted)
411
- throw this.#signal.reason;
412
- throw mapAgentError(error, this.#errorContext());
518
+ if (options.configOptions) {
519
+ await handle.setConfigOptions(options.configOptions);
520
+ options.signal?.throwIfAborted();
521
+ this.#signal?.throwIfAborted();
413
522
  }
414
- finally {
415
- collector.stop();
416
- callSignal?.removeEventListener("abort", onCallAbort);
417
- if (this.#activeTurn === active)
418
- this.#activeTurn = undefined;
523
+ if (options.mode !== undefined) {
524
+ await handle.setMode(options.mode);
525
+ options.signal?.throwIfAborted();
526
+ this.#signal?.throwIfAborted();
419
527
  }
420
- // An abort observed in flight rejects with the reason even when the agent answered
421
- // `stopReason: "cancelled"` — abort is never a resolved turn.
528
+ }
529
+ catch (error) {
530
+ throw mapAgentError(error, this.#errorContext(), options.signal?.aborted ? options.signal : this.#signal);
531
+ }
532
+ const turnSchema = options.schema ?? this.#schema;
533
+ // Same request shaping as the runner: a generic backend whose agent may ignore the `_meta`
534
+ // forward gets the contract stated in-band; backend turn meta wins only direct collisions.
535
+ const shaped = typeof content === "string" && turnSchema !== undefined && backend.embedSchemaInPrompt
536
+ ? buildRunPrompt(content, {}, turnSchema, backend, plan.toolActive)
537
+ : content;
538
+ const turnContent = appendPromptImages(shaped, options.images);
539
+ const promptMeta = mergeTurnMeta(options.meta, backend.promptMeta(turnSchema));
540
+ let attempt = await this.#runTurn(turnContent, promptMeta, turnSchema, options.signal);
541
+ if (turnSchema === undefined) {
542
+ if (attempt.failure)
543
+ throw agentTurnError(attempt.failure, attempt.turn, this.#errorContext());
544
+ return attempt.turn;
545
+ }
546
+ // The opt-in repair ladder (`schemaRetries`, default 0): re-prompt the SAME session with the
547
+ // runner's repair prompt — the StructuredOutput-tool variant when the tool is active, else
548
+ // the JSON one, plus the attempt's validation failure — and the same turn `_meta`, so the
549
+ // native channel stays authoritative (Codex's per-turn `outputSchema` rides the repair too).
550
+ // Text only, like the runner's repair turns: no images, no re-embedded contract. Only an
551
+ // `end_turn` miss is repaired — a cancelled, refused, or truncated attempt (or a walled one)
552
+ // ends the ladder where it stands; a per-call or agent abort between attempts rejects.
553
+ let attempts = 1;
554
+ while (attempts <= schemaRetries &&
555
+ attempt.failure === undefined &&
556
+ attempt.turn.structured === undefined &&
557
+ attempt.turn.stopReason === "end_turn") {
558
+ options.signal?.throwIfAborted();
559
+ this.#signal?.throwIfAborted();
560
+ const repair = repairPromptText({ toolActive: plan.toolActive, reason: attempt.turn.structuredError });
561
+ attempt = await this.#runTurn(repair, promptMeta, turnSchema, options.signal);
562
+ attempts += 1;
563
+ }
564
+ const turn = { ...attempt.turn, structuredAttempts: attempts };
565
+ if (attempt.failure)
566
+ throw agentTurnError(attempt.failure, turn, this.#errorContext());
567
+ return turn;
568
+ }
569
+ /** ONE `session/prompt` on the wire and its fold: the collector's tap is attached synchronously
570
+ * before the call, the turn is the agent's active turn for `cancel()`/`steer()`/tool
571
+ * correlation while it flies, an abort observed in flight rejects with the reason (never a
572
+ * resolved `cancelled` turn), and the turn's tokens are added to the session sum whether or
573
+ * not it was walled. `#promptTurn` runs it once, plus once per repair. */
574
+ async #runTurn(content, promptMeta, schema, callSignal) {
575
+ const handle = this.#handle;
576
+ const plan = this.#plan;
577
+ // SYNCHRONOUSLY before the wire call: the collector's tap sees every update of the turn, and
578
+ // the transcript marks the turn boundary exactly where the handle's accumulator does
579
+ // (`SessionState.beginTurn()` inside `promptOutcome`) — nothing can interleave.
580
+ const collector = new TurnCollector(this.#bus, handle, { retainHistory: this.#retainHistory });
581
+ this.#transcript.beginTurn(this.#retainHistory);
582
+ // A capture left by a turn that rejected (wire error/abort) must not leak into this turn.
583
+ plan.registration?.takeCaptured();
584
+ const outcome = handle.promptOutcome(content, promptMeta);
585
+ const active = { ended: outcome.then(noop, noop), aborted: false };
586
+ this.#activeTurn = active;
587
+ this.#activeCollector = collector;
588
+ const onCallAbort = () => {
589
+ active.aborted = true;
590
+ active.abortReason = callSignal?.reason;
591
+ void this.#cancelTurn(callSignal?.reason).catch(noop);
592
+ };
593
+ callSignal?.addEventListener("abort", onCallAbort, { once: true });
594
+ let response;
595
+ let failure;
596
+ try {
597
+ ({ response, failure } = await outcome);
598
+ }
599
+ catch (error) {
422
600
  if (active.aborted)
423
601
  throw active.abortReason;
424
602
  if (this.#signal?.aborted)
425
603
  throw this.#signal.reason;
426
- const turn = buildTurn({
427
- response,
428
- collector,
429
- handle,
430
- backend,
431
- schema: turnSchema,
432
- captured: plan.registration?.takeCaptured(),
433
- sessionBefore: this.#sessionUsage,
434
- });
435
- // A walled turn still counts the tokens it burned.
436
- this.#sessionUsage = turn.usage.session;
437
- if (failure)
438
- throw agentTurnError(failure, turn, this.#errorContext());
439
- return turn;
440
- }, options.signal);
604
+ throw mapAgentError(error, this.#errorContext());
605
+ }
606
+ finally {
607
+ collector.stop();
608
+ callSignal?.removeEventListener("abort", onCallAbort);
609
+ if (this.#activeTurn === active)
610
+ this.#activeTurn = undefined;
611
+ if (this.#activeCollector === collector)
612
+ this.#activeCollector = undefined;
613
+ }
614
+ // An abort observed in flight rejects with the reason even when the agent answered
615
+ // `stopReason: "cancelled"` — abort is never a resolved turn.
616
+ if (active.aborted)
617
+ throw active.abortReason;
618
+ if (this.#signal?.aborted)
619
+ throw this.#signal.reason;
620
+ const turn = buildTurn({
621
+ response,
622
+ collector,
623
+ handle,
624
+ backend: this.#backend,
625
+ schema,
626
+ captured: plan.registration?.takeCaptured(),
627
+ sessionBefore: this.#sessionUsage,
628
+ });
629
+ // A walled turn still counts the tokens it burned.
630
+ this.#sessionUsage = turn.usage.session;
631
+ return failure ? { turn, failure } : { turn };
441
632
  }
442
633
  /** Inject content into the turn in flight (`_session/steering`). Overlaps the FIFO; requires a
443
- * `prompt()` in flight (SCRIPT_VALIDATION_ERROR otherwise). The complete raw response is returned. */
634
+ * `prompt()` in flight (INVALID_ARGUMENT otherwise). The complete raw response is returned. */
444
635
  async steer(content, options = {}) {
445
636
  this.#signal?.throwIfAborted();
446
637
  if (this.#closed)
@@ -449,7 +640,7 @@ export class AcpAgent {
449
640
  if (!this.#activeTurn || !handle) {
450
641
  throw agentValidationError("AcpAgent.steer() requires a prompt() in flight", this.label);
451
642
  }
452
- validatePromptImages(options.images, this.label);
643
+ validateArguments(() => validatePromptImages(options.images, this.label));
453
644
  try {
454
645
  return await handle.steer(appendPromptImages(content, options.images), options.meta);
455
646
  }
@@ -463,11 +654,11 @@ export class AcpAgent {
463
654
  * grace period ends in process disposal WITHOUT a wire `session/close` (the session stays
464
655
  * re-openable through `sessionRef`); the turn then rejects and the agent is closed. Queued
465
656
  * turns are untouched, and so is a turn that has started (`state === "busy"`) but has not
466
- * reached the wire yet — the lazy first open, or its per-turn `configOptions`/`mode` — a
467
- * `cancel()` in that window is a no-op the turn never sees. A per-call `signal` covers every
657
+ * reached the wire yet — the lazy first open, or its per-turn `model`/`configOptions`/`mode` —
658
+ * a `cancel()` in that window is a no-op the turn never sees. A per-call `signal` covers every
468
659
  * window (rejects with the reason before anything is sent; `session/cancel` once in flight). */
469
660
  cancel() {
470
- return this.#cancelTurn();
661
+ return this.#cancelTurn(new Error("AcpAgent.cancel(): the turn was cancelled"));
471
662
  }
472
663
  /**
473
664
  * Fork this agent onto a NEW dedicated process (queued: it runs only when no turn is in flight,
@@ -502,32 +693,73 @@ export class AcpAgent {
502
693
  providerStore: this.#options.providerStore,
503
694
  clientHandlers: this.#options.clientHandlers,
504
695
  };
505
- validateAgentCwd(cwd, this.label, "AcpAgent.fork");
506
- if (trait.cwd === "source-only" && cwd !== this.cwd) {
507
- throw agentValidationError(`fork on ${this.backendId} must keep the source cwd (${this.cwd})`, this.label);
508
- }
509
- let route;
510
- if (overrides.model !== undefined) {
511
- route = resolveModelRoute(overrides.model, this.#registry);
512
- const samePool = (route.backend.poolKey ?? route.backend.id) === (this.#backend.poolKey ?? this.backendId);
513
- if (route.backend.id !== this.backendId || !samePool) {
514
- throw agentValidationError(`fork model "${overrides.model}" must stay on backend "${this.backendId}"`, this.label);
696
+ const route = validateArguments(() => {
697
+ validateAgentCwd(cwd, this.label, "AcpAgent.fork");
698
+ if (trait.cwd === "source-only" && cwd !== this.cwd) {
699
+ throw agentValidationError(`fork on ${this.backendId} must keep the source cwd (${this.cwd})`, this.label);
515
700
  }
516
- }
517
- assertNoModelConfigOption(merged.configOptions, label);
701
+ let route;
702
+ if (overrides.model !== undefined) {
703
+ route = resolveSameBackendModel(overrides.model, this.#backend, this.#registry, this.label, "AcpAgent.fork()");
704
+ }
705
+ assertNoModelConfigOption(merged.configOptions, label);
706
+ // The backend is fixed by the parent, so an inherited value already passed; an override
707
+ // is validated here, before the child's process spawns.
708
+ assertSystemPromptSupported(this.#backend, merged.systemPrompt, label);
709
+ return route;
710
+ });
518
711
  const child = AcpAgent.#seeded(merged, {
519
712
  kind: "fork",
520
713
  sourceSessionId: handle.sessionId,
714
+ // The parent's snapshot seeds the child: reattach without a replay (load is the fallback).
715
+ reattach: "resume",
521
716
  registry: this.#registry,
522
717
  backend: route?.backend ?? freshBackendFor(this.#backend, this.#registry),
718
+ // The model the parent is on NOW (a `setModel` / per-turn switch included), not its
719
+ // constructor option — `merged.model` is never read for a seeded child.
523
720
  modelSpec: route ? route.modelSpec : this.#modelSpec,
524
721
  historySeed: this.history.map((entry) => ({ ...entry })),
525
722
  textSeed: this.text,
723
+ messagesSeed: [...this.messages],
526
724
  });
527
725
  return AcpAgent.#opened(child);
528
726
  });
529
727
  }
530
- /** `session/set_mode` (queued; strict — an unadvertised id is a SCRIPT_VALIDATION_ERROR). */
728
+ /**
729
+ * Switch the session's model (queued, FIFO like `setMode`; sticky). `spec` is resolved with the
730
+ * fork rule — the runner's routing grammar, and it must land on this agent's backend and poolKey
731
+ * (`"<backendId>/<model id>"`; an unrouted spec goes to the default backend and passes only when
732
+ * that is this one) — otherwise INVALID_ARGUMENT naming both backends. The route is checked
733
+ * BEFORE the operation is queued, so a refused spec sends nothing and, on an agent that has not
734
+ * opened yet, spawns nothing (the agent stays `idle`). A backend-only spec is INVALID_ARGUMENT
735
+ * too: there is no wire form for "unselect". Applied exactly like open's selection —
736
+ * `session/set_config_option { configId: "model" }` with the routed remainder verbatim; no
737
+ * aliases, coercion, catalog matching, or fallback; the agent's catalog and validation are
738
+ * authoritative and a wire rejection maps through the normal error path with `model` unchanged.
739
+ * On success `model` becomes the routed spec, so later forks and a cold reopen passing
740
+ * `agent.model` back inherit the switch. `"model"` stays reserved in `configOptions`; this is
741
+ * the one way to move it after open.
742
+ */
743
+ setModel(spec) {
744
+ let modelSwitch;
745
+ try {
746
+ modelSwitch = validateArguments(() => resolveModelSwitch(spec, this.#backend, this.#registry, this.label, "AcpAgent.setModel()"));
747
+ }
748
+ catch (error) {
749
+ return Promise.reject(error);
750
+ }
751
+ return this.#enqueue(async () => {
752
+ await this.#ensureOpen();
753
+ this.#signal?.throwIfAborted();
754
+ try {
755
+ await this.#applyModelSwitch(this.#handle, modelSwitch);
756
+ }
757
+ catch (error) {
758
+ throw mapAgentError(error, this.#errorContext(), this.#signal);
759
+ }
760
+ });
761
+ }
762
+ /** `session/set_mode` (queued; strict — an unadvertised id is INVALID_ARGUMENT). */
531
763
  setMode(modeId) {
532
764
  return this.#enqueue(async () => {
533
765
  await this.#ensureOpen();
@@ -547,8 +779,10 @@ export class AcpAgent {
547
779
  await this.#ensureOpen();
548
780
  this.#signal?.throwIfAborted();
549
781
  const handle = this.#handle;
550
- assertNoModelConfigOption(options, this.label);
551
- assertKnownConfigOptionIds(options, handle.advertisedConfigOptions, this.backendId, this.label);
782
+ validateArguments(() => {
783
+ assertNoModelConfigOption(options, this.label);
784
+ assertKnownConfigOptionIds(options, handle.advertisedConfigOptions, this.backendId, this.label);
785
+ });
552
786
  try {
553
787
  await handle.setConfigOptions(options);
554
788
  }
@@ -561,8 +795,9 @@ export class AcpAgent {
561
795
  * Close: `state` becomes `closed` immediately (no new work is admitted), the teardown waits
562
796
  * behind queued work, releases the session (`keep: true` skips the wire `session/close` so the
563
797
  * agent-persisted session stays re-openable via `sessionRef`), disposes the dedicated process,
564
- * and releases the structured-output tool. Idempotent (same promise); never throws for an
565
- * already-dead process; rethrows only a `child_cleanup_error` (mapped, non-recoverable).
798
+ * releases the structured-output tool, and closes the function-tool host (aborting any running
799
+ * `execute`). Idempotent (same promise); never throws for an already-dead process; rethrows
800
+ * only a `child_cleanup_error` (mapped, non-recoverable).
566
801
  */
567
802
  close(options = {}) {
568
803
  this.#closeKeep ??= options.keep === true;
@@ -618,7 +853,7 @@ export class AcpAgent {
618
853
  return {
619
854
  cwd: this.cwd,
620
855
  schema: this.#schema,
621
- policy: options.tools ?? {},
856
+ policy: options.permissions ?? {},
622
857
  permissionResolver: options.onPermissionRequest,
623
858
  enforceToolPolicyBeforePermissionResolver: false,
624
859
  elicitationResolver: options.onElicitation,
@@ -627,18 +862,50 @@ export class AcpAgent {
627
862
  mcpServers: plan.mcpServers,
628
863
  meta: this.#layeredMeta(),
629
864
  label: this.label,
630
- baseInstructions: options.instructions?.base,
631
- developerInstructions: options.instructions?.developer,
865
+ systemPrompt: options.systemPrompt,
632
866
  retainSessionLog: this.#retainHistory,
633
867
  };
634
868
  }
635
- #planStructured(connection) {
636
- return planStructured({
869
+ /** After initialize (the capabilities are known): the structured-output injection, then the
870
+ * function-tool injection appended to the same `mcpServers` — or the INVALID_ARGUMENT refusal
871
+ * when the agent does not advertise HTTP MCP for the tools it was given. */
872
+ async #planSession(connection) {
873
+ const structured = await planStructured({
637
874
  schema: this.#schema,
638
875
  backend: this.#backend,
639
876
  mcpServers: this.#options.mcpServers,
640
877
  host: () => (this.#structuredHost ??= new StructuredOutputToolHost()),
641
878
  }, connection);
879
+ const mcpServers = await planTools({
880
+ tools: this.#tools,
881
+ backendId: this.backendId,
882
+ label: this.label,
883
+ mcpServers: structured.mcpServers,
884
+ host: () => (this.#toolHost ??= new AgentToolHost(this.#tools, () => this.#toolContext())),
885
+ }, connection);
886
+ return { ...structured, mcpServers };
887
+ }
888
+ #toolContext() {
889
+ return {
890
+ sessionId: this.#sessionId,
891
+ backendId: this.backendId,
892
+ label: this.label,
893
+ resolveToolCallId: (toolName) => this.#resolveToolCallId(toolName),
894
+ };
895
+ }
896
+ /** Best-effort: the latest unsettled `tool_call` of the turn in flight whose standard `name`
897
+ * (or `title`) is the tool's name or ends in `__<name>` (pi's `mcp__agent_tools__<name>`). */
898
+ #resolveToolCallId(toolName) {
899
+ const calls = this.#activeCollector?.toolCalls ?? [];
900
+ for (let index = calls.length - 1; index >= 0; index -= 1) {
901
+ const call = calls[index];
902
+ if (call.status === "completed" || call.status === "failed")
903
+ continue;
904
+ const names = [call.name, call.title].filter((value) => typeof value === "string");
905
+ if (names.some((name) => name === toolName || name.endsWith(`__${toolName}`)))
906
+ return call.toolCallId;
907
+ }
908
+ return undefined;
642
909
  }
643
910
  async #open() {
644
911
  let handle;
@@ -657,18 +924,18 @@ export class AcpAgent {
657
924
  if (seed.kind === "new") {
658
925
  // `prepare` runs after initialize, so the injection decision sees the capabilities.
659
926
  handle = await connection.openPreparedSession(async (ready) => {
660
- plan = await this.#planStructured(ready);
927
+ plan = await this.#planSession(ready);
661
928
  return this.#sessionOptions(plan);
662
929
  });
663
930
  }
664
931
  else {
665
932
  // The cheapest "await initialize": the injection decision needs the capabilities.
666
933
  await connection.authMethods();
667
- plan = await this.#planStructured(connection);
934
+ plan = await this.#planSession(connection);
668
935
  const opts = this.#sessionOptions(plan);
669
936
  if (seed.kind === "fork") {
670
937
  const trait = forkTraitFor(this.#backend, this.#registry);
671
- const acquired = await acquireForkedSession(connection, seed.sourceSessionId, opts, trait);
938
+ const acquired = await acquireForkedSession(connection, seed.sourceSessionId, opts, trait, seed.reattach);
672
939
  handle = acquired.handle;
673
940
  replayed = acquired.method === "load";
674
941
  }
@@ -689,10 +956,19 @@ export class AcpAgent {
689
956
  this.#sessionId = handle.sessionId;
690
957
  this.#bus.endAcquisition(handle.sessionId);
691
958
  this.#collectingReplay = false;
959
+ // The transcript follows the handle's accumulator: a replay the registered session applied
960
+ // (`session/load`, the id-only fork's load fallback) is folded from the adopted records; a
961
+ // pre-response fork replay was never applied there and stays in `replay` alone. Live from
962
+ // here on — synchronously, so no later update can slip between the two.
963
+ if (replayed) {
964
+ for (const { update, receivedAt } of this.#replay)
965
+ this.#transcript.apply(structuredClone(update), receivedAt);
966
+ }
967
+ this.#transcriptLive = true;
692
968
  this.#signal?.throwIfAborted();
693
969
  await this.#applyPostOpen(handle);
694
970
  if (seed.kind === "fork")
695
- this.#seedHistory(seed.historySeed, seed.textSeed, handle);
971
+ this.#seedHistory(seed, handle);
696
972
  this.#sessionRef = sessionRefFor(handle, this.#backend, this.cwd);
697
973
  this.#sessionUsage = ZERO_USAGE;
698
974
  }
@@ -708,6 +984,14 @@ export class AcpAgent {
708
984
  throw this.#signal?.aborted ? this.#signal.reason : mapAgentError(error, this.#errorContext());
709
985
  }
710
986
  }
987
+ /** The one mechanism behind every model selection after open (`setModel`, a per-turn `model`):
988
+ * `SessionHandle.selectModel` — the same call open makes — then `model` moves to the routed
989
+ * spec. Nothing moves when the wire rejects. */
990
+ async #applyModelSwitch(handle, next) {
991
+ await handle.selectModel(next.modelSpec);
992
+ this.#modelSpec = next.modelSpec;
993
+ this.#model = next.model;
994
+ }
711
995
  /** Re-apply model selection, config options and the mode on the LIVE handle (fork/resume/load
712
996
  * responses replace the catalog). The mode rule is the runner's, verbatim. */
713
997
  async #applyPostOpen(handle) {
@@ -726,20 +1010,25 @@ export class AcpAgent {
726
1010
  }
727
1011
  this.#signal?.throwIfAborted();
728
1012
  }
729
- /** Seed a live fork's history/text from the parent's snapshot — only when the child's own
730
- * accumulator is empty (a `session/load` fallback already replayed the transcript). */
731
- #seedHistory(seed, text, handle) {
732
- if (!seed || handle.history.length > 0)
1013
+ /** Seed a live fork's history/text/messages from the parent's snapshot — only when the child's
1014
+ * own accumulator is empty (a `session/load` reattach already replayed the transcript). The
1015
+ * cold static carries no seed: its transcript is the load replay, or empty after a resume. */
1016
+ #seedHistory(seed, handle) {
1017
+ if (!seed.historySeed || handle.history.length > 0)
733
1018
  return;
734
- this.#historySeed = seed;
735
- this.#textSeed = text ?? "";
1019
+ this.#historySeed = seed.historySeed;
1020
+ this.#textSeed = seed.textSeed ?? "";
1021
+ this.#messagesSeed = seed.messagesSeed ?? [];
736
1022
  }
737
- #cancelTurn() {
1023
+ /** `reason` is what a running function tool's `ctx.signal` aborts with. */
1024
+ #cancelTurn(reason) {
738
1025
  const active = this.#activeTurn;
739
1026
  const connection = this.#connection;
740
1027
  const sessionId = this.#sessionId;
741
1028
  if (!active || !connection || sessionId === undefined)
742
1029
  return Promise.resolve();
1030
+ // A function tool running for this turn stops with it.
1031
+ this.#toolHost?.abortInFlight(reason);
743
1032
  // Settles pending permissions/elicitations + ONE session/cancel notify.
744
1033
  active.cancelRequested ??= connection.cancelSession(sessionId);
745
1034
  active.escalation ??= active.cancelRequested.then(async () => {
@@ -755,7 +1044,8 @@ export class AcpAgent {
755
1044
  const reason = this.#signal?.reason;
756
1045
  this.#closed = true;
757
1046
  this.#queue.drain(reason);
758
- void this.#cancelTurn().catch(noop);
1047
+ this.#toolHost?.abortInFlight(reason);
1048
+ void this.#cancelTurn(reason).catch(noop);
759
1049
  // An open/fork/reattach in flight: dispose the process so the raced wire call rejects.
760
1050
  if (this.#handle === undefined && this.#connection)
761
1051
  void this.#connection.dispose().catch(noop);
@@ -803,6 +1093,7 @@ export class AcpAgent {
803
1093
  const connection = this.#connection;
804
1094
  const plan = this.#plan;
805
1095
  const host = this.#structuredHost;
1096
+ const toolHost = this.#toolHost;
806
1097
  let cleanupError;
807
1098
  try {
808
1099
  if (handle)
@@ -813,8 +1104,8 @@ export class AcpAgent {
813
1104
  cleanupError = error;
814
1105
  }
815
1106
  plan?.registration?.release();
816
- // The process BEFORE the tool host (the runner's order: pool, then tools): the agent process
817
- // holds keep-alive sockets to the host's HTTP server, and `server.close()` waits for idle
1107
+ // The process BEFORE the tool hosts (the runner's order: pool, then tools): the agent process
1108
+ // holds keep-alive sockets to the hosts' HTTP servers, and `server.close()` waits for idle
818
1109
  // sockets to time out (seconds) unless the peer is gone first.
819
1110
  if (connection) {
820
1111
  await connection.dispose().catch(noop);
@@ -822,6 +1113,8 @@ export class AcpAgent {
822
1113
  }
823
1114
  if (host)
824
1115
  await host.dispose().catch(noop);
1116
+ if (toolHost)
1117
+ await toolHost.dispose().catch(noop);
825
1118
  this.#removeAbort?.();
826
1119
  this.#removeAbort = undefined;
827
1120
  // Last, so the agent's own `session_close` (emitted by the release above) was delivered.