@zvada/agent-server 0.3.9 → 0.3.11

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,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.11
4
+
5
+ - Report a Claude turn stopped while a tool runs as `cancelled`. Claude Code
6
+ ends it with `error_during_execution` and the aborted step's stop reason
7
+ (`tool_use`), which was reported as an error; an interrupted turn that did
8
+ not succeed is now a cancellation whatever its stop reason.
9
+ - Confirm a Claude cancel at once while the CLI is still starting: terminate
10
+ the process instead of waiting for it to acknowledge an interrupt. A slow
11
+ sandbox startup reported `confirmed: false` after the 2-second interrupt
12
+ timeout, and a prompt the CLI had not yet dequeued could survive the
13
+ interrupt and run anyway. The next turn starts a new process.
14
+
15
+ ## 0.3.10
16
+
17
+ - Support Codex app-server per-turn stdio and Streamable HTTP MCP configuration
18
+ in an explicitly isolated `CODEX_HOME`. Reuse unchanged connections, apply
19
+ credential changes before prompt submission, and remove omitted servers.
20
+ Verify effective settings before reload so repository overrides cannot
21
+ redirect supplied credentials; fail setup when requested tools are unavailable.
22
+ - Add the interactive CLI's explicit `--codex-home` path and preserve it for
23
+ continuation. Release the old logical session before `/new` reuses its home.
24
+ The ACP server binding rejects supplied Codex MCP configuration explicitly;
25
+ it has no session-specific home resource seam.
26
+ - Snapshot Claude MCP configuration so reusing and mutating a caller's server
27
+ map cannot hide credential changes or alter a queued configuration update.
28
+ - After host suspension, replace Claude and Codex app-server subprocesses
29
+ before the next prompt and strictly resume saved conversations. MCP reset
30
+ and unchanged-config reload can retain dead remote connections. Preserve
31
+ turn-admission receipts; ordinary warm turns still reuse the process.
32
+ CLI-owned stdio MCP processes and Claude host MCP instances are recreated.
33
+ - Fail closed when Claude, Codex, or ACP cannot resume saved context. Remove
34
+ automatic fresh-conversation retries, retain native targets across process
35
+ eviction and failed setup, and report missing context as `resume_failed`.
36
+ Starting a fresh conversation now requires an explicit new/closed session.
37
+ - Honor a different explicit Claude resume target on warm sessions; reject
38
+ mismatched Codex resume responses before accepting the replacement ID.
39
+ - Reject empty resume IDs. Share turn ownership and conversation preparation
40
+ across all built-in harnesses: overlapping turns on one logical session fail
41
+ before native setup or changes to saved context. Different sessions remain
42
+ parallel; custom harnesses retain their existing execution contract.
43
+ - Detach completed turns' cancellation signals so a late abort cannot interrupt
44
+ the next warm turn. Retain ownership until native cleanup finishes.
45
+ - Prevent Claude from creating an orphan query when release or shutdown occurs
46
+ during asynchronous startup configuration.
47
+ - Reject direct execution on built-in agents as soon as shutdown begins, before
48
+ starting native setup or creating another session.
49
+ - Preserve classified errors from the runtime in terminal events and client
50
+ results, including resume failures and overlapping turns. Cancellation cleanup
51
+ diagnostics no longer appear as terminal failures.
52
+ - Verify external MCP readiness on initial attachment and configuration changes,
53
+ including OAuth `needs-auth` states. Preserve early Claude resume errors when
54
+ startup MCP controls fail, and allow lazily initialized in-process SDK tools.
55
+
3
56
  ## 0.3.9
4
57
 
5
58
  - Recover transient Claude MCP setup failures by reattaching with the current
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,14 +263,77 @@ 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
+
238
319
  ### Resume a suspended host
239
320
 
240
321
  After thawing a VM, call `runtime.invalidateMcpConnections()` before admitting
241
- new turns. This embed-tier lifecycle signal retains conversation state and
242
- turn-admission receipts. The Claude harness reattaches remote MCP servers
243
- before the next prompt, using that turn's current `mcpServers` credentials,
244
- even when the configuration has not changed. It leaves in-process host tools
245
- attached. Other harnesses without this lifecycle hook are unaffected.
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.
246
337
 
247
338
  Claude MCP updates also repair a transient socket setup failure once. Recovery
248
339
  removes and re-adds the affected connection: an identical SDK update can cache
@@ -254,13 +345,18 @@ turn before prompt submission. Tool calls and model turns are never replayed.
254
345
 
255
346
  Register `onSessionEnd` (claude options) to release per-session resources —
256
347
  BYOK proxy keys, recorders — instead of re-deriving termination from side
257
- effects. Reasons: `idle` (idle-timeout eviction), `replaced` (config change
258
- 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),
259
350
  `released` (explicit close), `shutdown`. On the wire, call `session/close`
260
351
  when a logical session will not be resumed: it frees the replay buffer and
261
352
  the harness-native state (until then, memory cost ≈ `bufferSize` events per
262
353
  session).
263
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
+
264
360
  `session/close` also broadcasts `session.ended {reason: "released"}` — but
265
361
  only to subscribers **connected at that moment**, because the replay log is
266
362
  retired in the same operation. A subscriber that was detached does not get the
@@ -271,7 +367,7 @@ Treat `unknownSession` on replay as "session over", not as an error to retry.
271
367
  ## Listen to the engine's diagnostics
272
368
 
273
369
  Pass `onDiagnostic` (registry/runtime/proxy options) and log what arrives:
274
- `interruptTimeout`, `resumeFallback`, `sinkError`, `proxyUpstreamAuth`.
370
+ `interruptTimeout`, `sinkError`, `proxyUpstreamAuth`.
275
371
  These are the signals the engine deliberately does not fail turns over — a
276
372
  product that doesn't surface them debugs blind.
277
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.9",
3
+ "version": "0.3.11",
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). */
@@ -107,7 +107,7 @@ export interface Agent {
107
107
  release?(sessionId: string): Promise<void>;
108
108
  /** Mark retained MCP connections stale after host suspension; repair before the next turn. */
109
109
  invalidateMcpConnections?(): void;
110
- /** Tear down every live session (process shutdown). */
110
+ /** Tear down every live session (process shutdown). Built-ins reject subsequent turns. */
111
111
  terminateAll(): Promise<void>;
112
112
  }
113
113
 
@@ -122,6 +122,7 @@ export abstract class BaseAgent implements Agent {
122
122
  * removes only the specific controller it created.
123
123
  */
124
124
  protected readonly inflight = new Map<string, Set<AbortController>>();
125
+ private readonly unlinkAbort = new WeakMap<AbortController, () => void>();
125
126
 
126
127
  abstract execute(
127
128
  input: AgentInput,
@@ -162,16 +163,24 @@ export abstract class BaseAgent implements Agent {
162
163
  set.add(controller);
163
164
  if (external) {
164
165
  if (external.aborted) controller.abort();
165
- 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
+ }
166
171
  }
167
172
  return controller;
168
173
  }
169
174
 
170
175
  protected endTurn(sessionId: string, controller?: AbortController): void {
171
176
  const set = this.inflight.get(sessionId);
172
- if (!set) return;
173
- if (controller) set.delete(controller);
174
- else set.clear();
175
- 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);
176
185
  }
177
186
  }
@@ -240,7 +240,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
240
240
  private error?: string;
241
241
  private interrupted = false;
242
242
  private execFailure = false;
243
- private sawResult = false;
243
+ private succeeded = false;
244
244
 
245
245
  constructor(ctx: { sessionId: string }) {
246
246
  this.ctx = { sessionId: ctx.sessionId, messageId: "" };
@@ -281,17 +281,19 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
281
281
  }
282
282
 
283
283
  finish(): TransformResult {
284
- // error_during_execution + null stop_reason is cancellation ONLY when the
285
- // agent confirmed an interrupt; otherwise it is a real failure (a silent
286
- // "cancelled" here is how a bad resume id used to vanish without a trace).
287
- // A confirmed interrupt with NO result at all (aborted before the SDK
288
- // emitted one) is also a cancellation, not a completed turn.
289
- const cancelled = this.interrupted && (this.execFailure || !this.sawResult);
290
- const error =
291
- this.error ??
292
- (this.execFailure && !this.interrupted
293
- ? "Claude turn failed during execution (error_during_execution — e.g. an invalid resumeSessionId fails this way)"
294
- : undefined);
284
+ // An interrupted turn that did not succeed was cancelled. Claude ends it with
285
+ // error_during_execution and the stop reason of the step it aborted (null
286
+ // while streaming, "tool_use" while a tool runs), or with no result when
287
+ // the process was stopped first. Without an interrupt, error_during_execution
288
+ // is a real failure (a silent "cancelled" here is how a bad resume id used
289
+ // to vanish without a trace).
290
+ const cancelled = this.interrupted && !this.succeeded;
291
+ const error = cancelled
292
+ ? undefined
293
+ : (this.error ??
294
+ (this.execFailure
295
+ ? "Claude turn failed during execution (error_during_execution — e.g. an invalid resumeSessionId fails this way)"
296
+ : undefined));
295
297
  return {
296
298
  usage: this.usage,
297
299
  ...(this.reportedModels.size && { reportedModels: [...this.reportedModels] }),
@@ -503,7 +505,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
503
505
  }
504
506
 
505
507
  private captureResult(msg: Extract<ClaudeMessage, { type: "result" }>): AdapterEvent[] {
506
- this.sawResult = true;
508
+ this.succeeded = msg.subtype === "success";
507
509
  if (msg.usage) {
508
510
  const creation = msg.usage.cache_creation;
509
511
  this.usage = {
@@ -528,7 +530,8 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
528
530
  if (msg.subtype === "error_during_execution" && msg.stop_reason === null) {
529
531
  // Ambiguous shape: an interrupt AND an execution failure (e.g. a bad
530
532
  // resume id) both surface this way. The agent's `turn_interrupted`
531
- // marker (which arrives after this message) disambiguates in finish().
533
+ // marker (which arrives after this message) disambiguates in finish(),
534
+ // as it does for an interrupt that aborted a tool (stop reason "tool_use").
532
535
  this.execFailure = true;
533
536
  } else if (msg.subtype === "error_max_turns") {
534
537
  // Hitting the turn budget is a stop condition, not a failure.