@zvada/agent-server 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.10
4
+
5
+ - Support Codex app-server per-turn stdio and Streamable HTTP MCP configuration
6
+ in an explicitly isolated `CODEX_HOME`. Reuse unchanged connections, apply
7
+ credential changes before prompt submission, and remove omitted servers.
8
+ Verify effective settings before reload so repository overrides cannot
9
+ redirect supplied credentials; fail setup when requested tools are unavailable.
10
+ - Add the interactive CLI's explicit `--codex-home` path and preserve it for
11
+ continuation. Release the old logical session before `/new` reuses its home.
12
+ The ACP server binding rejects supplied Codex MCP configuration explicitly;
13
+ it has no session-specific home resource seam.
14
+ - Snapshot Claude MCP configuration so reusing and mutating a caller's server
15
+ map cannot hide credential changes or alter a queued configuration update.
16
+ - After host suspension, replace Claude and Codex app-server subprocesses
17
+ before the next prompt and strictly resume saved conversations. MCP reset
18
+ and unchanged-config reload can retain dead remote connections. Preserve
19
+ turn-admission receipts; ordinary warm turns still reuse the process.
20
+ CLI-owned stdio MCP processes and Claude host MCP instances are recreated.
21
+ - Fail closed when Claude, Codex, or ACP cannot resume saved context. Remove
22
+ automatic fresh-conversation retries, retain native targets across process
23
+ eviction and failed setup, and report missing context as `resume_failed`.
24
+ Starting a fresh conversation now requires an explicit new/closed session.
25
+ - Honor a different explicit Claude resume target on warm sessions; reject
26
+ mismatched Codex resume responses before accepting the replacement ID.
27
+ - Reject empty resume IDs. Share turn ownership and conversation preparation
28
+ across all built-in harnesses: overlapping turns on one logical session fail
29
+ before native setup or changes to saved context. Different sessions remain
30
+ parallel; custom harnesses retain their existing execution contract.
31
+ - Detach completed turns' cancellation signals so a late abort cannot interrupt
32
+ the next warm turn. Retain ownership until native cleanup finishes.
33
+ - Prevent Claude from creating an orphan query when release or shutdown occurs
34
+ during asynchronous startup configuration.
35
+ - Reject direct execution on built-in agents as soon as shutdown begins, before
36
+ starting native setup or creating another session.
37
+ - Preserve classified errors from the runtime in terminal events and client
38
+ results, including resume failures and overlapping turns. Cancellation cleanup
39
+ diagnostics no longer appear as terminal failures.
40
+ - Verify external MCP readiness on initial attachment and configuration changes,
41
+ including OAuth `needs-auth` states. Preserve early Claude resume errors when
42
+ startup MCP controls fail, and allow lazily initialized in-process SDK tools.
43
+
44
+ ## 0.3.9
45
+
46
+ - Recover transient Claude MCP setup failures by reattaching with the current
47
+ credentials, preserving the process and conversation. Failed updates remain
48
+ dirty until recovery is verified; an identical SDK retry cannot silently
49
+ admit a turn with unavailable tools. Cancelling during preparation never
50
+ submits the prompt.
51
+ - Add the embed-tier `runtime.invalidateMcpConnections()` lifecycle signal for
52
+ resumed hosts, including turns whose remote MCP configuration is unchanged.
53
+
3
54
  ## 0.3.8
4
55
 
5
56
  - Report each Claude turn's cost as the increase in the SDK's cumulative query cost. Preserve the native total in raw diagnostics, reset the baseline with a fresh query or conversation reset, and keep unknown cost distinct from reported zero.
package/docs/consuming.md CHANGED
@@ -187,11 +187,25 @@ data disappears. A known type with a malformed body still fails loudly.
187
187
 
188
188
  Store `state.nativeSessionId` (from `session.created`) keyed by your logical
189
189
  `sessionId`, and pass it back as `config.resumeSessionId` to continue a
190
- conversation later — across processes and machines. Check
191
- `session.created.resumed` on resume turns: `false` means the harness fell
192
- back to a fresh session (context lost) — surface that, never swallow it.
193
- Compare with the flag, not ids: a successful Claude resume mints a NEW native
194
- id.
190
+ conversation later — across processes and machines where its native history is
191
+ available. Built-in harnesses fail the turn if the requested conversation is
192
+ missing, unsupported, or resolves to the wrong Codex thread. They never replay
193
+ the prompt on a new conversation. Missing or unsupported history reports
194
+ `error.category: "resume_failed"`; authentication and transport failures keep
195
+ their existing error categories.
196
+
197
+ Keep the saved ID when a turn fails. To retry after fixing the cause, submit a
198
+ new `turnId` with the same resume target. To deliberately start fresh, use a new
199
+ logical `sessionId`, or explicitly close the old session first. Do not overwrite
200
+ a saved native ID with an absent ID from a failed turn.
201
+
202
+ Within one engine, conversation identity survives process exit, idle eviction,
203
+ and failed setup. Changing the working directory or credential environment on
204
+ that logical session requires an explicit `resumeSessionId` or a new session.
205
+ Across engine restarts, callers still need to supply the persisted native ID.
206
+ A successful Claude resume can mint a new native ID; use the reported `resumed`
207
+ flag rather than comparing Claude IDs. The `resumed: false` wire value remains
208
+ readable for older engines and custom harnesses.
195
209
 
196
210
  ## Retry safely
197
211
 
@@ -203,9 +217,19 @@ identical request converges on the original execution — the wire acks it with
203
217
  `TurnConflictError`). This is what makes at-least-once RPC layers (Durable
204
218
  Object retries, queue redelivery) safe over the engine.
205
219
 
220
+ Built-in harnesses allow one active turn per logical session, including setup
221
+ and cancellation cleanup. Different sessions can run in parallel. Wait for the
222
+ current turn to end before starting another. Direct `agent.execute` rejects
223
+ overlap with `TurnActiveError`; embedded `runtime.run` completes the second
224
+ turn with `error.category: "invalid_request"` without starting native work or
225
+ changing its saved conversation. That result is memoized like other completed
226
+ turns, so retry after the first turn ends with a **new `turnId`**. The wire
227
+ rejects earlier with `turnActive`, before admission, so its rejected request
228
+ can reuse its turn ID. Custom harnesses retain their own concurrency contract.
229
+
206
230
  ## Cancel honestly
207
231
 
208
- `turn/cancel` (and `runtime.cancel`) accept a `turnId` stamp — always pass
232
+ `turn/cancel` accepts a `turnId` stamp — always pass
209
233
  the id of the turn you mean, so a late cancel can never kill its successor
210
234
  (a stale stamp returns `{outcome: "no_active_turn", activeTurnId}`). The
211
235
  result is a single-outcome union: `cancelled` means the harness confirmed the
@@ -214,6 +238,10 @@ the `turn/start` quick-ack and the harness registering its abortable turn —
214
238
  and the agent may still be running. On `unconfirmed`, report "stopping…" and
215
239
  treat `turn.ended` as the source of truth, not the cancel response.
216
240
 
241
+ Embedded `runtime.cancel(harness, sessionId)` cancels the session's active
242
+ turn without a turn ID check. Prefer a per-turn `AbortSignal` when cancellation
243
+ can arrive late; a completed turn's signal is detached before the next turn.
244
+
217
245
  ## Verify the stream
218
246
 
219
247
  `verifyStreamContract(events, {seqs?})` machine-checks a recorded stream
@@ -235,15 +263,100 @@ and unknown event/part types are forward-compat rather than violations. Pass
235
263
 
236
264
  ## Own your session resources
237
265
 
266
+ ### Configure MCP between turns
267
+
268
+ For Claude and Codex app-server, `RunConfig.mcpServers` is the complete set of wire-configured
269
+ servers for that turn, not an incremental patch. Repeating an unchanged set
270
+ reuses the running process and its MCP connections. Changed entries are
271
+ applied through the harness's native configuration API before the prompt;
272
+ finishing a turn does not restart the agent or its servers. Omitting the map
273
+ on a later turn removes the previous wire-configured set, just like `{}`.
274
+ Claude's host `sdkMcpServers` remain attached. The native CLI may also load MCP servers
275
+ from settings or plugins; this map is not an exclusive tool allowlist.
276
+
277
+ The engine snapshots the supplied wire configuration, including headers,
278
+ before asynchronous setup. Credential rotation therefore works even if the
279
+ caller reuses and mutates its server map between turns. Apply changes between
280
+ turns and supply the current complete map on each turn. The explicit live
281
+ `setMcpServers` control does not establish separate persistent defaults: the
282
+ next turn's map takes precedence. External MCP servers enforce credential
283
+ expiry; ending a turn does not itself revoke an attached credential.
284
+
285
+ Codex supports stdio and Streamable HTTP servers; legacy SSE is rejected. Its
286
+ per-turn MCP configuration requires an existing, absolute, session-specific
287
+ `config.env.CODEX_HOME` directory. Create it with mode `0700`, retain it while
288
+ the conversation may resume, and delete it when that logical session is
289
+ released. It contains conversation history and MCP credentials. Never share
290
+ it between sessions or use the operator's normal Codex home. AGNT supplies
291
+ and owns these directories for its callers.
292
+
293
+ The repository's interactive CLI accepts `--codex-home=/absolute/private/path`
294
+ and remembers the path for `--continue`; the operator supplies and authenticates
295
+ that existing home. The ACP **server binding** (`createAcpAgentApp` /
296
+ `agent-server --acp --harness codex-app-server`) has no per-session environment
297
+ resource seam in this release. It rejects supplied Codex MCP servers explicitly
298
+ at `session/new` or `session/resume`. Use the engine or standard JSON-RPC API
299
+ with `config.env.CODEX_HOME` for that combination. This limitation is separate
300
+ from the engine's ACP **harness**, which forwards MCP to other ACP agents.
301
+
302
+ An explicitly supplied `CODEX_HOME` gives the engine ownership of that home's
303
+ user-layer `mcp_servers` table: each turn replaces that table, including
304
+ clearing it when the map is omitted. Without an explicit home or a per-turn
305
+ map, native Codex configuration continues to work normally. Repository and
306
+ plugin servers remain separate, but a higher-priority setting that changes a
307
+ requested server fails preparation before reload. Codex's `codex_apps` name
308
+ is reserved.
309
+
310
+ Codex writes changed configuration without reloading, verifies its effective
311
+ settings, then reloads MCP and checks readiness before submitting the prompt.
312
+ Servers still starting are awaited through native startup notifications, with
313
+ a bounded deadline; unavailable or failed servers fail preparation immediately.
314
+ Unchanged configuration is checked without reloading. A failed setup fails
315
+ the turn; an explicitly submitted next turn reapplies the configuration so a
316
+ previously failed native client can reconnect. No model turn or tool call is
317
+ automatically replayed.
318
+
319
+ ### Resume a suspended host
320
+
321
+ After thawing a VM, call `runtime.invalidateMcpConnections()` before admitting
322
+ new turns. Drain active turns before suspending the host. This embed-tier
323
+ lifecycle signal retains conversation state and turn-admission receipts.
324
+ Before the next prompt, the Claude and Codex app-server harnesses replace
325
+ retained subprocesses and strictly resume their saved conversations, using
326
+ that turn's current MCP credentials before reconnecting the tools.
327
+ Missing history fails with `resume_failed`; it never silently starts an empty
328
+ conversation. Ordinary warm turns still reuse the process.
329
+
330
+ A resumed VM can retain dead TCP sockets in Claude's process-wide HTTP pool;
331
+ recreating MCP clients does not clear that pool. Codex's MCP configuration
332
+ reload also retains stale connections when configuration is unchanged.
333
+ Process replacement recreates CLI-owned stdio MCP processes and invokes
334
+ Claude's host MCP factory again, as idle eviction already does. Keep durable
335
+ tool state outside those processes. Workspace files and external MCP servers
336
+ are unaffected. Other harnesses without this lifecycle hook are unaffected.
337
+
338
+ Claude MCP updates also repair a transient socket setup failure once. Recovery
339
+ removes and re-adds the affected connection: an identical SDK update can cache
340
+ a failed client, and the SDK's reconnect-by-name command can restore startup
341
+ credentials. Persistent connection failures and invalid credentials fail the
342
+ turn before prompt submission. Tool calls and model turns are never replayed.
343
+
344
+ ### Release a session
345
+
238
346
  Register `onSessionEnd` (claude options) to release per-session resources —
239
347
  BYOK proxy keys, recorders — instead of re-deriving termination from side
240
- effects. Reasons: `idle` (idle-timeout eviction), `replaced` (config change
241
- restarted the subprocess — usually respawns immediately with context kept),
348
+ effects. Reasons: `idle` (idle-timeout eviction), `replaced` (config change or
349
+ host resume replaced the subprocess — usually respawns immediately with context kept),
242
350
  `released` (explicit close), `shutdown`. On the wire, call `session/close`
243
351
  when a logical session will not be resumed: it frees the replay buffer and
244
352
  the harness-native state (until then, memory cost ≈ `bufferSize` events per
245
353
  session).
246
354
 
355
+ When embedding, close idle sessions with `runtime.closeSession` and await its
356
+ completion before starting another turn on that ID. Await shutdown before
357
+ disposing the runtime. Cancellation acknowledgement alone does not mean the
358
+ old execution has finished cleaning up.
359
+
247
360
  `session/close` also broadcasts `session.ended {reason: "released"}` — but
248
361
  only to subscribers **connected at that moment**, because the replay log is
249
362
  retired in the same operation. A subscriber that was detached does not get the
@@ -254,7 +367,7 @@ Treat `unknownSession` on replay as "session over", not as an error to retry.
254
367
  ## Listen to the engine's diagnostics
255
368
 
256
369
  Pass `onDiagnostic` (registry/runtime/proxy options) and log what arrives:
257
- `interruptTimeout`, `resumeFallback`, `sinkError`, `proxyUpstreamAuth`.
370
+ `interruptTimeout`, `sinkError`, `proxyUpstreamAuth`.
258
371
  These are the signals the engine deliberately does not fail turns over — a
259
372
  product that doesn't surface them debugs blind.
260
373
 
package/docs/harnesses.md CHANGED
@@ -31,12 +31,16 @@ warm multi-turn reuse, a unified `ThinkingLevel`, and normalized token usage.
31
31
  | `resumeSessionId` | ✅ | ✅ | ✅ |
32
32
  | `systemPromptAppend` | ✅ | ❌ (SDK has no field) | ✅ (`developerInstructions`) |
33
33
  | `maxTurns` | ✅ | ❌ | ❌ |
34
- | `mcpServers` | ✅ | ❌ (capability=false) | ❌ (capability=false) |
34
+ | `mcpServers` | ✅ | ❌ (capability=false) | ✅ (stdio / Streamable HTTP; isolated `CODEX_HOME`) |
35
35
  | `apiKey` / `env` | ✅ | ✅ | `env` ✅, `apiKey` via ambient CLI auth |
36
36
  | `disableTools` | ✅ | (use read-only sandbox) | (use read-only sandbox) |
37
37
  | `permissionRequests` | ✅ (`canUseTool`) | ❌ (sandbox is the gate) | ✅ (`on-request` approvals) |
38
38
  | `includeRaw` | ✅ | ✅ | ✅ |
39
39
 
40
+ Codex per-turn MCP is supported through the engine/JSON-RPC API, AGNT, and the
41
+ repository CLI with an explicit `--codex-home`. The ACP server binding cannot
42
+ provide isolated homes and rejects this combination at session admission.
43
+
40
44
  Consult `runtime.capabilities(harness)` before relying on a capability.
41
45
 
42
46
  ## Host-managed Codex authentication
@@ -116,8 +120,9 @@ provide the cache breakdown without an additional native usage report.
116
120
 
117
121
  ## Known limitations (roadmap)
118
122
 
119
- - **MCP servers** are wired for Claude only; Codex MCP passthrough is pending
120
- an upstream protocol re-verification.
123
+ - **MCP servers** are supported by Claude and Codex app-server. Codex SDK
124
+ passthrough remains unsupported. See [MCP configuration](consuming.md#configure-mcp-between-turns)
125
+ for Codex home ownership, supported transports, and live-update behavior.
121
126
  - The **BYOK proxy** (`/core/proxy`) is a building block, not yet auto-wired
122
127
  into the harnesses (they use ambient/explicit keys today).
123
128
  - **Hook bridge** (PreToolUse/Stop decisions) is exposed for the claude
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvada/agent-server",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,9 +6,10 @@ import type {
6
6
  } from "@agentclientprotocol/sdk";
7
7
  import type { AgentCapabilities, AgentInput, PermissionMode } from "../../../protocol/index.ts";
8
8
  import { AsyncQueue } from "../../../protocol/index.ts";
9
+ import { SessionResumeError } from "../../utils/errors.ts";
9
10
  import type { AgentExecuteOptions, PermissionRequestHandler, RawAgentEvent } from "../base.ts";
10
- import { BaseAgent } from "../base.ts";
11
11
  import { configFingerprint } from "../config-fingerprint.ts";
12
+ import { SessionAgent } from "../session-agent.ts";
12
13
  import { SessionStore } from "../session-store.ts";
13
14
  import { AcpClient, type AcpClientOptions, type AcpLaunch } from "./client.ts";
14
15
  import {
@@ -22,9 +23,7 @@ import {
22
23
 
23
24
  const CAPABILITIES: AgentCapabilities = {
24
25
  multiTurn: true,
25
- // The engine always *accepts* resumeSessionId; whether context actually
26
- // survives depends on the target agent's `sessionCapabilities.resume`
27
- // (falls back to a fresh session when unsupported).
26
+ // A requested resume fails if the target agent cannot load saved sessions.
28
27
  sessionResume: true,
29
28
  // v1 exposes model selection only through agent-defined config options;
30
29
  // there is no portable model parameter to pass through yet.
@@ -135,7 +134,7 @@ export async function answerPermission(
135
134
  * `session/prompt` *response*, so `execute` re-injects it into the raw stream
136
135
  * as a synthetic `session/prompt_result` event for the adapter.
137
136
  */
138
- export class AcpAgent extends BaseAgent {
137
+ export class AcpAgent extends SessionAgent {
139
138
  readonly harness = "acp" as const;
140
139
  readonly capabilities = CAPABILITIES;
141
140
  private readonly sessions = new SessionStore<AcpSession>();
@@ -163,21 +162,13 @@ export class AcpAgent extends BaseAgent {
163
162
  });
164
163
 
165
164
  const existing = this.sessions.get(options.sessionId);
166
- let resumeTarget = options.resumeSessionId;
165
+ const resumeTarget = options.resumeSessionId;
167
166
  if (existing && !existing.client.closed) {
168
167
  if (acpSessionCompatible(existing, options, launchFingerprint)) {
169
168
  this.sessions.clearIdle(existing);
170
169
  return existing;
171
170
  }
172
- // Preserve context within the same cwd/env boundary when the agent can
173
- // resume (mirrors the claude-code/codex restart policy).
174
- if (
175
- resumeTarget === undefined &&
176
- existing.cwd === options.cwd &&
177
- existing.envFingerprint === configFingerprint(options.env)
178
- ) {
179
- resumeTarget = existing.acpSessionId;
180
- }
171
+
181
172
  this.sessions.close(options.sessionId);
182
173
  }
183
174
 
@@ -210,20 +201,28 @@ export class AcpAgent extends BaseAgent {
210
201
  );
211
202
  if (signal.aborted) throw new Error("turn aborted");
212
203
  const mcpServers = toAcpMcpServers(options.mcpServers);
213
- if (resumeTarget && init.agentCapabilities?.sessionCapabilities?.resume) {
214
- try {
215
- await client.request(
204
+ if (resumeTarget) {
205
+ if (!init.agentCapabilities?.sessionCapabilities?.resume) {
206
+ throw new SessionResumeError(
207
+ resumeTarget,
208
+ "the ACP agent does not support session/resume",
209
+ );
210
+ }
211
+ await client
212
+ .request(
216
213
  "session/resume",
217
214
  { sessionId: resumeTarget, cwd: options.cwd, mcpServers },
218
215
  SESSION_REQUEST_TIMEOUT_MS,
219
- );
220
- acpSessionId = resumeTarget;
221
- } catch {
222
- // Unknown/expired session id — fall through to a fresh one.
223
- }
224
- }
225
- if (signal.aborted) throw new Error("turn aborted");
226
- if (!acpSessionId) {
216
+ )
217
+ .catch((cause: unknown) => {
218
+ throw new SessionResumeError(
219
+ resumeTarget,
220
+ cause instanceof Error ? cause.message : String(cause),
221
+ { cause },
222
+ );
223
+ });
224
+ acpSessionId = resumeTarget;
225
+ } else {
227
226
  const created = await client.request(
228
227
  "session/new",
229
228
  { cwd: options.cwd, mcpServers },
@@ -231,6 +230,7 @@ export class AcpAgent extends BaseAgent {
231
230
  );
232
231
  acpSessionId = created.sessionId;
233
232
  }
233
+ signal.throwIfAborted();
234
234
  if (!acpSessionId) throw new Error("acp agent returned no session id");
235
235
 
236
236
  const session: AcpSession = {
@@ -254,16 +254,17 @@ export class AcpAgent extends BaseAgent {
254
254
  }
255
255
  }
256
256
 
257
- async *execute(
257
+ protected override async *executeTurn(
258
258
  input: AgentInput,
259
259
  options: AgentExecuteOptions,
260
+ controller: AbortController,
260
261
  ): AsyncIterableIterator<RawAgentEvent> {
261
- const controller = this.trackTurn(options.sessionId, options.signal);
262
262
  const queue = new AsyncQueue<RawAgentEvent>();
263
263
  const unsubscribe: Array<() => void> = [];
264
264
  let cancelTimer: ReturnType<typeof setTimeout> | undefined;
265
265
  let session: AcpSession | undefined;
266
266
  try {
267
+ controller.signal.throwIfAborted();
267
268
  session = await this.resolveSession(options, controller.signal);
268
269
  session.turn = {
269
270
  broker: options.onPermissionRequest,
@@ -341,7 +342,6 @@ export class AcpAgent extends BaseAgent {
341
342
  if (cancelTimer) clearTimeout(cancelTimer);
342
343
  for (const off of unsubscribe) off();
343
344
  if (session) session.turn = undefined;
344
- this.endTurn(options.sessionId, controller);
345
345
  if (session && !session.client.closed) {
346
346
  this.sessions.armIdle(
347
347
  options.sessionId,
@@ -66,8 +66,8 @@ export interface AgentExecuteOptions {
66
66
  /**
67
67
  * Called once with the harness-native session/thread id (for resume).
68
68
  * Pass the harness's honest `resumed` judgment unconditionally — the
69
- * runtime surfaces it only on turns that requested a resume (false =
70
- * fresh-session fallback).
69
+ * runtime surfaces it only on turns that requested a resume. Built-in
70
+ * harnesses throw if a requested conversation cannot be loaded.
71
71
  */
72
72
  onNativeSession?: (nativeSessionId: string, info?: { resumed?: boolean }) => void;
73
73
  /** Engine-brokered approval round-trip (see PermissionRequestHandler). */
@@ -105,7 +105,9 @@ export interface Agent {
105
105
  cancel(sessionId: string): Promise<CancelResult>;
106
106
  /** Release all harness-owned state for one idle logical session. */
107
107
  release?(sessionId: string): Promise<void>;
108
- /** Tear down every live session (process shutdown). */
108
+ /** Mark retained MCP connections stale after host suspension; repair before the next turn. */
109
+ invalidateMcpConnections?(): void;
110
+ /** Tear down every live session (process shutdown). Built-ins reject subsequent turns. */
109
111
  terminateAll(): Promise<void>;
110
112
  }
111
113
 
@@ -120,6 +122,7 @@ export abstract class BaseAgent implements Agent {
120
122
  * removes only the specific controller it created.
121
123
  */
122
124
  protected readonly inflight = new Map<string, Set<AbortController>>();
125
+ private readonly unlinkAbort = new WeakMap<AbortController, () => void>();
123
126
 
124
127
  abstract execute(
125
128
  input: AgentInput,
@@ -160,16 +163,24 @@ export abstract class BaseAgent implements Agent {
160
163
  set.add(controller);
161
164
  if (external) {
162
165
  if (external.aborted) controller.abort();
163
- else external.addEventListener("abort", () => controller.abort(), { once: true });
166
+ else {
167
+ const onAbort = () => controller.abort();
168
+ external.addEventListener("abort", onAbort, { once: true });
169
+ this.unlinkAbort.set(controller, () => external.removeEventListener("abort", onAbort));
170
+ }
164
171
  }
165
172
  return controller;
166
173
  }
167
174
 
168
175
  protected endTurn(sessionId: string, controller?: AbortController): void {
169
176
  const set = this.inflight.get(sessionId);
170
- if (!set) return;
171
- if (controller) set.delete(controller);
172
- else set.clear();
173
- if (set.size === 0) this.inflight.delete(sessionId);
177
+ // release/terminateAll may already have removed the set while the turn
178
+ // was draining. Its external signal must still be detached on completion.
179
+ for (const turn of controller ? [controller] : (set ?? [])) {
180
+ this.unlinkAbort.get(turn)?.();
181
+ this.unlinkAbort.delete(turn);
182
+ set?.delete(turn);
183
+ }
184
+ if (set?.size === 0) this.inflight.delete(sessionId);
174
185
  }
175
186
  }