@agentclientprotocol/claude-agent-acp 0.90.0 → 0.90.1-preview.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/dist/acp-agent.js CHANGED
@@ -16,7 +16,7 @@ import { promisify } from "node:util";
16
16
  import packageJson from "../package.json" with { type: "json" };
17
17
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
18
18
  import { forkSession } from "./fork-session.js";
19
- import { readSubagentMessages, subagentHistory } from "./subagent-history.js";
19
+ import { readSubagentMessages, subagentHistory, subagentLaunchIds, subagentLineage, } from "./subagent-history.js";
20
20
  import { RfdSubagentReplay, replaySubagentTerminalState, } from "./subagent-replay.js";
21
21
  import { readResumedSession, readResumedTail, } from "./resumed-session.js";
22
22
  import { SessionTiming } from "./session-timing.js";
@@ -87,6 +87,11 @@ const STRUCTURED_USAGE_TIMEOUT_MS = 5_000;
87
87
  * The teardown wakes the consumer itself, so they end within a few ticks
88
88
  * unless the consumer is wedged. */
89
89
  const TEARDOWN_TURN_END_TIMEOUT_MS = 5_000;
90
+ /** How long `canUseTool` or a hook waits, while a cancel's window may be
91
+ * open, for the consumer to reach a tool use in the stream. Claude Code
92
+ * streams the tool use before it asks, so the consumer is usually there
93
+ * within a few ticks. */
94
+ const TOOL_USE_WAIT_MS = 2_000;
90
95
  /** The number of settled subagents whose parent tool call a session keeps for
91
96
  * a later resume (see `resumableSubagents`). */
92
97
  const MAX_RESUMABLE_SUBAGENTS = 256;
@@ -285,6 +290,15 @@ const AUTONOMOUS_RESULT_ORIGINS = new Set([
285
290
  * or a model cycle that Claude Code started on its own. At most one cycle
286
291
  * runs at a time, so a constant names it. */
287
292
  const AUTONOMOUS_CYCLE = "autonomous-cycle";
293
+ /** The events of a connection that reports no foreground work: v1, whose
294
+ * client has no session state. The agent still tracks the work, which
295
+ * decides what the window after a cancel drops (see `workEndReported`). */
296
+ const UNREPORTED_FOREGROUND = {
297
+ running: () => { },
298
+ awaitingUser: () => { },
299
+ ended: () => { },
300
+ failed: () => { },
301
+ };
288
302
  /** Whether this turn's terminal result arrived but its settlement is being
289
303
  * held for background subagents it spawned (see Turn.deferredSettle). The
290
304
  * single spelling of the hold predicate, shared by the consumer's settle
@@ -537,18 +551,6 @@ function parentToolUseIdOf(message) {
537
551
  return null;
538
552
  return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null;
539
553
  }
540
- /** The ids of the Agent and Task tool uses in the content of a message. */
541
- function subagentLaunchIds(content) {
542
- if (!Array.isArray(content))
543
- return [];
544
- return content.flatMap((block) => typeof block === "object" &&
545
- block !== null &&
546
- block.type === "tool_use" &&
547
- typeof block.id === "string" &&
548
- isNativeSubagentControlTool(block.name)
549
- ? [block.id]
550
- : []);
551
- }
552
554
  function stripSubagentTextAndThinking(content) {
553
555
  if (!Array.isArray(content))
554
556
  return content;
@@ -1173,6 +1175,9 @@ export class ClaudeAcpAgent {
1173
1175
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
1174
1176
  * tests can shrink it. */
1175
1177
  forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
1178
+ /** How long `canUseTool` or a hook waits for the consumer to reach a tool
1179
+ * use (see `isStoppedWorkToolUse`). Mutable so tests can shrink it. */
1180
+ toolUseWaitMs = TOOL_USE_WAIT_MS;
1176
1181
  /**
1177
1182
  * The connection serves ACP v2 (`src/v2/`). The agent still speaks v1
1178
1183
  * types, except for the tool call content that v2 needs and v1 cannot
@@ -1183,7 +1188,8 @@ export class ClaudeAcpAgent {
1183
1188
  foregroundEvents;
1184
1189
  /**
1185
1190
  * The foreground work of each session while it runs, by the session id that
1186
- * the client knows. A clear-context restart replaces the Claude Code session
1191
+ * the client knows, on every connection: only the `foreground` option
1192
+ * reports it. A clear-context restart replaces the Claude Code session
1187
1193
  * behind that id and moves the running turn to it, so the work belongs to the
1188
1194
  * id, not to a {@link Session}.
1189
1195
  */
@@ -2564,7 +2570,7 @@ export class ClaudeAcpAgent {
2564
2570
  * recognizable by the `parentToolUseId` meta that toAcpNotifications
2565
2571
  * stamps from `parent_tool_use_id`, and never reach the top-level feed
2566
2572
  * as the turn's answer. */
2567
- const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(subagentForm(this.clientCapabilities), params.sessionId, session, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.logger));
2573
+ const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(subagentForm(this.clientCapabilities), params.sessionId, session, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.logger, (agentId) => subagentLineage(params.sessionId, agentId, this.logger)));
2568
2574
  // Adapter-composed advisories (hook feedback, model fallbacks) are live
2569
2575
  // events, not something the model said. Clients on the notice contract get
2570
2576
  // them as `notice` updates; the rest keep the bold-label transcript line.
@@ -3162,8 +3168,14 @@ export class ClaudeAcpAgent {
3162
3168
  /** Sends `report`, which ends the tool call `toolCallId` without its result
3163
3169
  * (see `AcpToolCallRenderer.unfinished`), and forgets the call. Forgetting
3164
3170
  * comes first, so that a late hook or a second close cannot overwrite the
3165
- * report. */
3171
+ * report. A command has a display terminal on a client that renders them,
3172
+ * and no result will end it, so the report ends it too. */
3166
3173
  const closeToolCall = async (toolCallId, report) => {
3174
+ // Read before the cache entry goes: it names the tool.
3175
+ const toolUse = session.toolUseCache[toolCallId];
3176
+ const terminalExit = toolUse && new AcpToolCallRenderer(this.toolCallCapabilities).terminalExit(toolUse);
3177
+ if (terminalExit)
3178
+ report._meta = { ...report._meta, ...terminalExit };
3167
3179
  unregisterHookCallback(toolCallId);
3168
3180
  session.emittedToolCalls.delete(toolCallId);
3169
3181
  delete session.toolUseCache[toolCallId];
@@ -3178,26 +3190,24 @@ export class ClaudeAcpAgent {
3178
3190
  task.backgrounded === true &&
3179
3191
  !task.endedPerLevel &&
3180
3192
  task.parentToolUseId === agentToolUseId);
3181
- /** Closes the tool calls that the work a cancel stopped left open, as that
3182
- * work ends. Claude Code's interrupt ends each running tool of the main
3183
- * conversation with an error result, which `sendInterruptedToolResults`
3184
- * delivers. What is still open here has no result to deliver:
3185
- * - a tool of a subagent: Claude Code writes its interrupted result only to
3186
- * the subagent's own transcript, not to the stream;
3187
- * - a tool call that Claude abandoned before it ran;
3188
- * - every tool call, when the stream gives no results at all, as when the
3189
- * force-cancel backstop ends a wedged turn.
3190
- * A foreground subagent ends with the work that waits on it. A background
3191
- * subagent keeps its tool calls open until the SDK reports its end, which
3192
- * Claude Code 2.1.293 does for the ones it stops, before the turn's result
3193
- * (see `NativeSubagentRuntime.parentCancelled`).
3194
- * Runs once per turn: a second call, from the other settling path, waits
3195
- * for the first (see `Turn.closingCancelledToolCalls`). */
3196
- const closeCancelledToolCalls = (turn) => (turn.closingCancelledToolCalls ??= (async () => {
3193
+ /** Whether output of the conversation `parentToolUseId` (null for the main
3194
+ * one) is a background subagent's that still runs. A cancel does not stop
3195
+ * such a subagent's work (agentclientprotocol/agent-client-protocol#2308):
3196
+ * the closing at the cancel leaves its tool calls open for their results
3197
+ * (`closedAtCancel`), and the window after the cancel lets its output
3198
+ * through (see the `session.cancelled` checks of the stream and the
3199
+ * user/assistant cases). Claude Code currently stops background subagents
3200
+ * at an interrupt, so this decides only the output that comes before the
3201
+ * SDK reports a subagent's end. */
3202
+ const isRunningBackgroundSubagentOutput = (parentToolUseId) => parentToolUseId !== null && runsInBackground(parentToolUseId);
3203
+ /** Ends the open tool calls that `include` accepts, with the report of a call
3204
+ * that a cancel stopped and sent no result for. `include` gets the Agent/Task
3205
+ * call of the subagent that made the tool call, if a subagent did. */
3206
+ const closeOpenToolCalls = async (include) => {
3197
3207
  const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
3198
3208
  for (const toolCallId of [...session.emittedToolCalls]) {
3199
3209
  const parentToolUseId = session.toolCallParents?.get(toolCallId);
3200
- if (parentToolUseId !== undefined && runsInBackground(parentToolUseId))
3210
+ if (!include(parentToolUseId))
3201
3211
  continue;
3202
3212
  const report = renderer.unfinished({
3203
3213
  toolCallId,
@@ -3210,8 +3220,48 @@ export class ClaudeAcpAgent {
3210
3220
  stampParentToolUseId(report, parentToolUseId);
3211
3221
  await closeToolCall(toolCallId, report);
3212
3222
  }
3213
- })());
3223
+ };
3224
+ /** Whether the closing at the end of the work a cancel stopped takes a tool
3225
+ * call that the subagent started by the Agent call `parentToolUseId` made
3226
+ * (undefined for a call of the main conversation): not one of a running
3227
+ * background subagent, which the cancel may not have stopped. */
3228
+ const closedAtCancel = (parentToolUseId) => parentToolUseId === undefined || !runsInBackground(parentToolUseId);
3229
+ /** Closes the tool calls that the work a cancel stopped left open, as that
3230
+ * work ends. Claude Code's interrupt ends each running tool of the main
3231
+ * conversation with an error result, which `sendInterruptedToolResults`
3232
+ * delivers. What is still open here has no result to deliver:
3233
+ * - a tool of a subagent: Claude Code writes its interrupted result only to
3234
+ * the subagent's own transcript, not to the stream;
3235
+ * - a tool call that Claude abandoned before it ran;
3236
+ * - every tool call, when the stream gives no results at all, as when the
3237
+ * force-cancel backstop ends a wedged turn.
3238
+ * A foreground subagent ends with the work that waits on it. A background
3239
+ * subagent keeps its tool calls open until the SDK reports its end, which
3240
+ * Claude Code 2.1.293 does for the ones it stops, before the turn's result
3241
+ * (see `NativeSubagentRuntime.parentCancelled`). When the SDK reports it
3242
+ * only after the work ended, `closeStoppedSubagentToolCalls` closes them.
3243
+ * Runs once per turn: a second call, from the other settling path, waits
3244
+ * for the first (see `Turn.closingCancelledToolCalls`). */
3245
+ const closeCancelledToolCalls = (turn) => (turn.closingCancelledToolCalls ??= closeOpenToolCalls(closedAtCancel));
3214
3246
  session.closeCancelledToolCalls = closeCancelledToolCalls;
3247
+ /** Closes the tool calls of the subagent that the Agent call `agentToolUseId`
3248
+ * started, once the SDK reports the subagent stopped, if a cancel stopped it
3249
+ * after the work that waited on it ended. Nothing else ends them then.
3250
+ * Example: the cancel settles a turn that v1 holds for a background
3251
+ * subagent, so `closeCancelledToolCalls` leaves the subagent's command
3252
+ * open, since the subagent runs. Claude Code's interrupt then stops the
3253
+ * subagent. Claude Code 2.1.296 sends the command's interrupted result
3254
+ * after that, but the result is dropped like all output after the work's
3255
+ * end (see the `session.cancelled` check of the user/assistant case).
3256
+ * While the work still runs, the calls of a stopped subagent get Claude
3257
+ * Code's result, or `closeCancelledToolCalls` at the work's end. */
3258
+ const closeStoppedSubagentToolCalls = async (agentToolUseId) => {
3259
+ if (!agentToolUseId || !session.cancelled)
3260
+ return;
3261
+ if (!this.workEndReported(params.sessionId, session))
3262
+ return;
3263
+ await closeOpenToolCalls((parentToolUseId) => parentToolUseId === agentToolUseId);
3264
+ };
3215
3265
  /** Delivers the results that the work a cancel stopped sends for tool calls
3216
3266
  * that the client holds open. Claude Code's interrupt ends each running tool
3217
3267
  * of the main conversation with an error result, before the result that
@@ -3245,6 +3295,47 @@ export class ClaudeAcpAgent {
3245
3295
  await sendUpdate(notification);
3246
3296
  }
3247
3297
  };
3298
+ /** The model cycle that Claude Code started on its own ends, and its end is
3299
+ * about to be reported: closes the tool calls that it left open first. A
3300
+ * cycle is foreground work that no turn settles, so neither
3301
+ * `closeCancelledToolCalls` nor the check of `settleActive` for unfinished
3302
+ * tools runs for it. A turn that took the cycle over closes its own calls,
3303
+ * when it settles.
3304
+ *
3305
+ * After a cancel, Claude Code's interrupt gives the tools of the main
3306
+ * conversation results, which `sendInterruptedToolResults` delivers while
3307
+ * the cycle runs. What is still open has none, such as the command of a
3308
+ * foreground subagent. The follow-up of a turn that v1 holds for its
3309
+ * background subagents is a cycle too, and the cancel ends the held turn
3310
+ * as well, possibly in `cancelTurns` while these reports are in flight. So
3311
+ * the cycle's calls close with the held turn's closing, which each settle
3312
+ * of the turn waits for, and the response never comes before them.
3313
+ *
3314
+ * At any other end, every call that ran has its result. What is open is a
3315
+ * call of the main conversation that Claude abandoned before it ran, such
3316
+ * as a streamed tool_use that never reached a complete message, which a
3317
+ * turn's end closes the same way (`closeUnfinished`). The calls of a
3318
+ * subagent are not the cycle's: a background subagent runs on. */
3319
+ const closeCycleToolCalls = async () => {
3320
+ if (!this.runsAutonomousCycle(params.sessionId))
3321
+ return;
3322
+ if (session.cancelled) {
3323
+ const held = session.activeTurn;
3324
+ if (held && !held.settled)
3325
+ await closeCancelledToolCalls(held);
3326
+ else
3327
+ await closeOpenToolCalls(closedAtCancel);
3328
+ return;
3329
+ }
3330
+ const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
3331
+ for (const toolCallId of [...session.emittedToolCalls]) {
3332
+ if (session.toolCallParents?.has(toolCallId))
3333
+ continue;
3334
+ if (session.dispatchedToolCalls?.has(toolCallId))
3335
+ continue;
3336
+ await closeToolCall(toolCallId, renderer.unfinished({ toolCallId, ran: false }));
3337
+ }
3338
+ };
3248
3339
  /** At the actual turn boundary, preview its checkpoint, settle the active
3249
3340
  * turn exactly once, disarm the force-cancel backstop, and drop it from
3250
3341
  * the queue. Cancellation and provider failures skip checkpoint I/O. */
@@ -3894,6 +3985,7 @@ export class ClaudeAcpAgent {
3894
3985
  // here. An owed idle belongs to an earlier result: it can lag
3895
3986
  // past the cycle's first output.
3896
3987
  if (session.owedTrailingIdles === 0) {
3988
+ await closeCycleToolCalls();
3897
3989
  this.endAutonomousCycle(params.sessionId, {
3898
3990
  outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" },
3899
3991
  });
@@ -4244,6 +4336,9 @@ export class ClaudeAcpAgent {
4244
4336
  droppedStreams.delete(message.tool_use_id);
4245
4337
  }
4246
4338
  await endBackgroundTerminal(message.task_id);
4339
+ if (message.status === "stopped") {
4340
+ await closeStoppedSubagentToolCalls(message.tool_use_id);
4341
+ }
4247
4342
  settleLiveTask(message.task_id);
4248
4343
  break;
4249
4344
  case "task_updated":
@@ -4275,6 +4370,9 @@ export class ClaudeAcpAgent {
4275
4370
  droppedStreams.delete(parentToolUseId);
4276
4371
  }
4277
4372
  await endBackgroundTerminal(message.task_id);
4373
+ if (message.patch.status === "killed") {
4374
+ await closeStoppedSubagentToolCalls(parentToolUseId);
4375
+ }
4278
4376
  settleLiveTask(message.task_id);
4279
4377
  }
4280
4378
  else if (message.patch.status === "running" ||
@@ -4710,6 +4808,7 @@ export class ClaudeAcpAgent {
4710
4808
  pendingExitPlanContextReset?.toolUseId ===
4711
4809
  pendingExitPlanModeInterruption?.toolUseId;
4712
4810
  if (!continuesPlan && !(isAutonomousResult && message.num_turns === 0)) {
4811
+ await closeCycleToolCalls();
4713
4812
  this.endAutonomousCycle(params.sessionId, autonomousCycleEnd(message, resultModelUsage, session.cancelled || exitPlanInterruption || isAbortedResult(message)));
4714
4813
  }
4715
4814
  if (session.cancelled) {
@@ -5168,7 +5267,8 @@ export class ClaudeAcpAgent {
5168
5267
  // `cancelled` check in the user/assistant case). Otherwise the
5169
5268
  // answer to a prompt the cancel abandoned, which Claude Code had
5170
5269
  // already taken off its queue, would stream to the client after the
5171
- // cancel was reported.
5270
+ // cancel was reported. A background subagent that still runs is not
5271
+ // work the cancel stopped (see isRunningBackgroundSubagentOutput).
5172
5272
  //
5173
5273
  // Where the consolidated message delivers the text (the same
5174
5274
  // condition as in the user/assistant case), a stream that loses a
@@ -5180,7 +5280,13 @@ export class ClaudeAcpAgent {
5180
5280
  // prefix of the text, so the text would be sent again (see
5181
5281
  // unstreamedRemainder).
5182
5282
  const streamKey = message.parent_tool_use_id ?? "";
5183
- if (session.cancelled && this.workEndReported(params.sessionId, session)) {
5283
+ const stoppedWorkOutput = this.dropsStoppedWorkOutput(params.sessionId, session) &&
5284
+ !isRunningBackgroundSubagentOutput(message.parent_tool_use_id);
5285
+ if (message.event.type === "content_block_start" &&
5286
+ isToolUseBlock(message.event.content_block)) {
5287
+ recordReachedToolUse(session, message.event.content_block.id, stoppedWorkOutput);
5288
+ }
5289
+ if (stoppedWorkOutput) {
5184
5290
  if (message.parent_tool_use_id === null || forwardsSubagentText()) {
5185
5291
  droppedStreams.add(streamKey);
5186
5292
  }
@@ -5250,8 +5356,18 @@ export class ClaudeAcpAgent {
5250
5356
  }
5251
5357
  case "user":
5252
5358
  case "assistant": {
5253
- if (message.type === "assistant")
5359
+ if (message.type === "assistant") {
5254
5360
  recordDispatchedToolUses(session, message.message.content);
5361
+ // A tool use that did not stream is reached here first.
5362
+ const content = message.message.content;
5363
+ const stoppedWorkOutput = this.dropsStoppedWorkOutput(params.sessionId, session) &&
5364
+ !isRunningBackgroundSubagentOutput(message.parent_tool_use_id);
5365
+ for (const block of Array.isArray(content) ? content : []) {
5366
+ if (isToolUseBlock(block)) {
5367
+ recordReachedToolUse(session, block.id, stoppedWorkOutput);
5368
+ }
5369
+ }
5370
+ }
5255
5371
  // Record the ACP messageId -> SDK uuid mapping for this message
5256
5372
  // (including replays). The consolidated message carries both ids, so
5257
5373
  // this is where we learn the uuid the SDK's rewind/resume APIs key on
@@ -5355,11 +5471,13 @@ export class ClaudeAcpAgent {
5355
5471
  break;
5356
5472
  }
5357
5473
  }
5358
- if (session.cancelled) {
5474
+ if (session.cancelled &&
5475
+ !isRunningBackgroundSubagentOutput(message.parent_tool_use_id)) {
5359
5476
  // The work the cancel stopped ends its tools with error results.
5360
5477
  // Until the client is told that the work ended, they close the
5361
5478
  // tool calls it opened. Everything else that work sends is
5362
- // dropped, and so is everything after that report.
5479
+ // dropped, and so is everything after that report. A background
5480
+ // subagent that still runs is not that work: its messages go on.
5363
5481
  if (message.type === "user" && !this.workEndReported(params.sessionId, session)) {
5364
5482
  await sendInterruptedToolResults(message);
5365
5483
  }
@@ -5790,6 +5908,10 @@ export class ClaudeAcpAgent {
5790
5908
  return;
5791
5909
  }
5792
5910
  session.cancelled = true;
5911
+ // The interrupt below makes Claude Code withdraw the requests that are open
5912
+ // now (see `withPendingUserInput`).
5913
+ for (const input of session.openUserInputs ?? [])
5914
+ input.cancelled = true;
5793
5915
  // Before anything awaits, so a model request that the consumer reads in
5794
5916
  // the meantime is already judged against these prompts.
5795
5917
  session.cancelledPrompts ??= new Set();
@@ -6718,19 +6840,29 @@ export class ClaudeAcpAgent {
6718
6840
  /** Runs `request`, a request that `requestSessionId` sends for the session
6719
6841
  * `ownerSessionId`. When that is a subagent's child session, the child
6720
6842
  * reports that it awaits the user while the request is open (the RFD form,
6721
- * see `NativeSubagentRuntime.awaitingUser`). */
6722
- withChildAwaitingUser(ownerSessionId, requestSessionId, request) {
6843
+ * see `NativeSubagentRuntime.awaitingUser`). `signal` aborts when Claude
6844
+ * Code withdraws the request. */
6845
+ withChildAwaitingUser(ownerSessionId, requestSessionId, signal, request) {
6723
6846
  const runtime = this.sessions[ownerSessionId]?.nativeSubagentRuntime;
6724
6847
  return runtime && requestSessionId !== ownerSessionId
6725
- ? runtime.awaitingUser(requestSessionId, request)
6848
+ ? runtime.awaitingUser(requestSessionId, request, signal)
6726
6849
  : request();
6727
6850
  }
6728
6851
  /** Mark a client request of the subagent `agentID` (absent for the main
6729
6852
  * conversation) as awaiting user input for exactly the lifetime of its
6730
6853
  * promise (see `Session.openUserInputs`). Steering consults the open
6731
6854
  * requests synchronously, so a message arriving while any permission or
6732
- * elicitation card is open uses non-interrupting SDK delivery. */
6733
- async withPendingUserInput(sessionId, request, agentID) {
6855
+ * elicitation card is open uses non-interrupting SDK delivery.
6856
+ *
6857
+ * `signal` aborts when Claude Code withdraws the request, which is not an
6858
+ * answer. Example: the user cancels while a permission request is open.
6859
+ * Claude Code's interrupt withdraws it as it stops the work, and the work
6860
+ * ends `idle cancelled` right after. The end of the request then reports
6861
+ * no `running`: the client would see `requires_action`, `running`, `idle
6862
+ * cancelled`. A withdrawal without a cancel means that something else
6863
+ * answered the request in the user's place, such as a `PermissionRequest`
6864
+ * hook, and the work goes on, so the end reports `running`. */
6865
+ async withPendingUserInput(sessionId, signal, request, agentID) {
6734
6866
  const session = this.sessions[sessionId];
6735
6867
  if (!session)
6736
6868
  return request();
@@ -6742,7 +6874,8 @@ export class ClaudeAcpAgent {
6742
6874
  }
6743
6875
  finally {
6744
6876
  session.openUserInputs.delete(input);
6745
- this.syncAwaitingUser(sessionId, session);
6877
+ const withdrawnByCancel = signal.aborted && input.cancelled === true;
6878
+ this.syncAwaitingUser(sessionId, session, withdrawnByCancel);
6746
6879
  }
6747
6880
  }
6748
6881
  /** Report whether the session's foreground work awaits the user: exactly
@@ -6751,8 +6884,13 @@ export class ClaudeAcpAgent {
6751
6884
  * and Claude Code can ask for a queued prompt before the consumer activates
6752
6885
  * it, so a request is not tied to the work that was running when it
6753
6886
  * opened. Called when a request opens or closes, when a turn activates, and
6754
- * when a task registers or moves to the background. */
6755
- syncAwaitingUser(sessionId, session) {
6887
+ * when a task registers or moves to the background.
6888
+ *
6889
+ * When the last request ends, the work reports `running` again, unless a
6890
+ * cancel withdrew that request (`withdrawnByCancel`, see
6891
+ * {@link withPendingUserInput}): the work is being stopped, and reports its
6892
+ * end next. */
6893
+ syncAwaitingUser(sessionId, session, withdrawnByCancel = false) {
6756
6894
  const work = this.foregroundWork.get(sessionId);
6757
6895
  if (!work)
6758
6896
  return;
@@ -6762,7 +6900,7 @@ export class ClaudeAcpAgent {
6762
6900
  work.awaitingUser = waiting;
6763
6901
  if (waiting)
6764
6902
  work.events.awaitingUser();
6765
- else
6903
+ else if (!withdrawnByCancel)
6766
6904
  work.events.running();
6767
6905
  }
6768
6906
  /** Make `owner` the foreground work of the session `sessionId` (see
@@ -6771,14 +6909,12 @@ export class ClaudeAcpAgent {
6771
6909
  * reports nothing new: a turn takes over a cycle that Claude Code started on
6772
6910
  * its own, and only the owner's end ends the work. */
6773
6911
  startForeground(sessionId, owner) {
6774
- if (!this.foregroundEvents)
6775
- return;
6776
6912
  const current = this.foregroundWork.get(sessionId);
6777
6913
  if (current) {
6778
6914
  current.owner = owner;
6779
6915
  return;
6780
6916
  }
6781
- const events = this.foregroundEvents(sessionId);
6917
+ const events = this.foregroundEvents?.(sessionId) ?? UNREPORTED_FOREGROUND;
6782
6918
  this.foregroundWork.set(sessionId, { owner, events, awaitingUser: false });
6783
6919
  events.running();
6784
6920
  const session = this.sessions[sessionId];
@@ -6804,7 +6940,7 @@ export class ClaudeAcpAgent {
6804
6940
  * whose work ended at its result, so its follow-up is a cycle. Output of
6805
6941
  * the work a cancel stopped starts nothing (see Session.cancelled). */
6806
6942
  startAutonomousCycle(sessionId, session, message) {
6807
- if (!this.foregroundEvents || this.foregroundWork.has(sessionId) || session.cancelled)
6943
+ if (this.foregroundWork.has(sessionId) || session.cancelled)
6808
6944
  return;
6809
6945
  if (message.type !== "assistant" && message.type !== "stream_event")
6810
6946
  return;
@@ -6822,15 +6958,79 @@ export class ClaudeAcpAgent {
6822
6958
  /** Whether the client has been told that the session's work ended: on v2 by
6823
6959
  * an idle state, so no foreground work is open, including a cycle that
6824
6960
  * Claude Code started on its own; on v1 by the response to the turn's
6825
- * prompt, so no turn is active. */
6961
+ * prompt, so no turn is active. v1 reports nothing for a cycle, so its end
6962
+ * is its result or idle: until then, Claude Code's results for the cycle's
6963
+ * tool calls still reach the client after a cancel. */
6826
6964
  workEndReported(sessionId, session) {
6827
- return this.foregroundEvents ? !this.foregroundWork.has(sessionId) : !session.activeTurn;
6965
+ return this.foregroundEvents
6966
+ ? !this.foregroundWork.has(sessionId)
6967
+ : !session.activeTurn && !this.runsAutonomousCycle(sessionId);
6968
+ }
6969
+ /** Whether the consumer drops what it reaches now as output of the work a
6970
+ * cancel stopped: the client has been told that the work ended, and the
6971
+ * window after the cancel is still open (see Session.cancelled). */
6972
+ dropsStoppedWorkOutput(sessionId, session) {
6973
+ return session.cancelled && this.workEndReported(sessionId, session);
6974
+ }
6975
+ /**
6976
+ * Whether the tool use `toolUseId`, which reaches `canUseTool` or a hook, is
6977
+ * output of the work a cancel stopped that the consumer dropped (see
6978
+ * `dropsStoppedWorkOutput`). Example: Claude Code runs a prompt that the
6979
+ * cancel abandoned, and it asks to run a command. The consumer drops the
6980
+ * tool use and its result, so a tool call shown for it would never end, and
6981
+ * the client would keep it open (issue #1061).
6982
+ *
6983
+ * Claude Code calls `canUseTool` and hooks on its control channel, after it
6984
+ * streamed the tool use, and the consumer can lag behind. So the session can
6985
+ * still look cancelled while the consumer has the next prompt's echo, which
6986
+ * ends the window, still ahead of the tool use. While a cancel's window may
6987
+ * be open, this waits until the consumer reaches the tool use, for at most
6988
+ * {@link toolUseWaitMs} or until `signal` aborts: the stream never shows
6989
+ * some tool uses, such as SandboxNetworkAccess's. Without a cancel nothing
6990
+ * is dropped, so it answers at once. It returns a promise only when it
6991
+ * waits, so a permission request without a cancel goes out as soon as
6992
+ * before.
6993
+ */
6994
+ isStoppedWorkToolUse(session, toolUseId, signal) {
6995
+ const reached = session.reachedToolUses?.get(toolUseId);
6996
+ if (reached !== undefined)
6997
+ return reached;
6998
+ if (!session.cancelled || signal?.aborted)
6999
+ return false;
7000
+ return this.reachedInStream(session, toolUseId, signal);
7001
+ }
7002
+ /** Waits until the consumer reaches the tool use `toolUseId` (see
7003
+ * `isStoppedWorkToolUse`), then says whether it dropped it. */
7004
+ async reachedInStream(session, toolUseId, signal) {
7005
+ await new Promise((resolve) => {
7006
+ const waiters = (session.toolUseWaiters ??= new Map());
7007
+ let waiting = waiters.get(toolUseId);
7008
+ if (!waiting)
7009
+ waiters.set(toolUseId, (waiting = new Set()));
7010
+ const done = () => {
7011
+ clearTimeout(timer);
7012
+ signal?.removeEventListener("abort", done);
7013
+ waiting.delete(done);
7014
+ if (waiting.size === 0 && waiters.get(toolUseId) === waiting)
7015
+ waiters.delete(toolUseId);
7016
+ resolve();
7017
+ };
7018
+ const timer = setTimeout(done, this.toolUseWaitMs);
7019
+ signal?.addEventListener("abort", done, { once: true });
7020
+ waiting.add(done);
7021
+ });
7022
+ return session.reachedToolUses?.get(toolUseId) ?? false;
6828
7023
  }
6829
7024
  /** End the session's model cycle that Claude Code started on its own, if one
6830
7025
  * runs. */
6831
7026
  endAutonomousCycle(sessionId, end) {
6832
7027
  this.endForeground(sessionId, AUTONOMOUS_CYCLE, end);
6833
7028
  }
7029
+ /** Whether a model cycle that Claude Code started on its own is the session's
7030
+ * foreground work. A turn that took a cycle over owns the work from then on. */
7031
+ runsAutonomousCycle(sessionId) {
7032
+ return this.foregroundWork.get(sessionId)?.owner === AUTONOMOUS_CYCLE;
7033
+ }
6834
7034
  /** Forward a permission request to the client, wiring the tool call's
6835
7035
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
6836
7036
  * while the client's prompt is still open the signal aborts, the SDK sends
@@ -6855,7 +7055,7 @@ export class ClaudeAcpAgent {
6855
7055
  // The request goes to a subagent's child session for a tool call of that
6856
7056
  // subagent, but it blocks the turn of the owner session.
6857
7057
  try {
6858
- return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, () => this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID));
7058
+ return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, signal, () => this.withPendingUserInput(ownerSessionId, signal, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID));
6859
7059
  }
6860
7060
  catch (error) {
6861
7061
  if (signal.aborted) {
@@ -6864,6 +7064,17 @@ export class ClaudeAcpAgent {
6864
7064
  throw error;
6865
7065
  }
6866
7066
  }
7067
+ /** Send the client an elicitation for a callback of Claude Code. When the
7068
+ * callback's `signal` aborts, the SDK sends `$/cancel_request` and keeps
7069
+ * waiting for the client's reply, which a client that ignores the cancel
7070
+ * never sends. So the call also rejects at once on the abort, as
7071
+ * {@link requestPermissionFromClient} does for a permission request. Left
7072
+ * open, the request would stay in `openUserInputs` (see
7073
+ * {@link withPendingUserInput}), which keeps steering non-interrupting, and
7074
+ * a child's `openRequests` would not drop. */
7075
+ elicitFromClient(request, signal) {
7076
+ return raceWithAbort(this.client.createElicitation(request, signal), signal);
7077
+ }
6867
7078
  /** Emit the `tool_call` a permission request references if it hasn't been sent
6868
7079
  * yet, so the client has the tool call before being asked to approve it. The
6869
7080
  * matching streamed tool_use chunk later refines it with a `tool_call_update`
@@ -6960,17 +7171,21 @@ export class ClaudeAcpAgent {
6960
7171
  * decision, and a failure only leaves the preview out.
6961
7172
  */
6962
7173
  fileChangePreviewHook(sessionId) {
6963
- return async (input, toolUseID) => {
7174
+ return async (input, toolUseID, { signal }) => {
6964
7175
  if (input.hook_event_name === "PreToolUse" && toolUseID) {
6965
- await this.previewFileChange(sessionId, input, toolUseID).catch((error) => this.logger.error(`Session ${sessionId}: failed to preview ${toolUseID}: ${error}`));
7176
+ await this.previewFileChange(sessionId, input, toolUseID, signal).catch((error) => this.logger.error(`Session ${sessionId}: failed to preview ${toolUseID}: ${error}`));
6966
7177
  }
6967
7178
  return { continue: true };
6968
7179
  };
6969
7180
  }
6970
- async previewFileChange(sessionId, input, toolUseID) {
7181
+ async previewFileChange(sessionId, input, toolUseID, signal) {
6971
7182
  const session = this.sessions[sessionId];
6972
7183
  if (!session)
6973
7184
  return;
7185
+ // As in `canUseTool`: the work a cancel stopped shows nothing.
7186
+ const stopped = this.isStoppedWorkToolUse(session, toolUseID, signal);
7187
+ if (stopped instanceof Promise ? await stopped : stopped)
7188
+ return;
6974
7189
  const toolInput = (input.tool_input ?? {});
6975
7190
  const content = await previewPatchContent(input.tool_name, toolInput, session.cwd, "v2");
6976
7191
  if (!content)
@@ -7012,6 +7227,14 @@ export class ClaudeAcpAgent {
7012
7227
  message: "Session not found",
7013
7228
  };
7014
7229
  }
7230
+ // The work a cancel stopped shows the client nothing after it was told
7231
+ // that the work ended: neither a tool call nor a request. Its tool does
7232
+ // not run.
7233
+ const stopped = this.isStoppedWorkToolUse(session, toolUseID, signal);
7234
+ if (stopped instanceof Promise ? await stopped : stopped) {
7235
+ this.logger.log(`Session ${sessionId}: denied ${toolName} ${toolUseID}, which the cancelled work asked for`);
7236
+ return { behavior: "deny", message: "The user cancelled this prompt." };
7237
+ }
7015
7238
  const { parentToolUseId, toolCallSessionId: permissionSessionId } = this.toolUseOwner(session, sessionId, agentID);
7016
7239
  if (agentID && !parentToolUseId) {
7017
7240
  // The attribution rests on task_started.task_id === canUseTool's
@@ -7164,7 +7387,7 @@ export class ClaudeAcpAgent {
7164
7387
  return { action: "decline" };
7165
7388
  }
7166
7389
  try {
7167
- const response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal));
7390
+ const response = await this.withPendingUserInput(sessionId, signal, () => this.elicitFromClient(createRequest, signal));
7168
7391
  if (signal.aborted) {
7169
7392
  return { action: "cancel" };
7170
7393
  }
@@ -7196,7 +7419,7 @@ export class ClaudeAcpAgent {
7196
7419
  const createRequest = askUserQuestionsToCreateRequest(questions, requestSessionId, toolUseID, this.toolCallCapabilities.air.client);
7197
7420
  let response;
7198
7421
  try {
7199
- response = await this.withChildAwaitingUser(sessionId, requestSessionId, () => this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal), agentID));
7422
+ response = await this.withChildAwaitingUser(sessionId, requestSessionId, signal, () => this.withPendingUserInput(sessionId, signal, () => this.elicitFromClient(createRequest, signal), agentID));
7200
7423
  }
7201
7424
  catch (error) {
7202
7425
  // A cancellation we requested (signal aborted) settles as an aborted tool
@@ -7236,7 +7459,7 @@ export class ClaudeAcpAgent {
7236
7459
  }
7237
7460
  let response;
7238
7461
  try {
7239
- response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal));
7462
+ response = await this.withPendingUserInput(sessionId, signal, () => this.elicitFromClient(refusalFallbackToCreateRequest(prompt, sessionId), signal));
7240
7463
  }
7241
7464
  catch (error) {
7242
7465
  // A cancellation we requested (signal aborted) is expected teardown;
@@ -9229,14 +9452,38 @@ function recordDispatchedToolUses(session, content) {
9229
9452
  if (!Array.isArray(content))
9230
9453
  return;
9231
9454
  for (const block of content) {
9232
- if ((block?.type === "tool_use" ||
9233
- block?.type === "server_tool_use" ||
9234
- block?.type === "mcp_tool_use") &&
9235
- typeof block.id === "string") {
9455
+ if (isToolUseBlock(block))
9236
9456
  recordDispatchedToolCall(session, block.id);
9237
- }
9238
9457
  }
9239
9458
  }
9459
+ function isToolUseBlock(block) {
9460
+ const { type, id } = (block ?? {});
9461
+ return ((type === "tool_use" || type === "server_tool_use" || type === "mcp_tool_use") &&
9462
+ typeof id === "string");
9463
+ }
9464
+ const MAX_REACHED_TOOL_USES = 1000;
9465
+ /**
9466
+ * Records that the consumer reached the tool use `toolUseId`, and whether it
9467
+ * dropped it as output of the work a cancel stopped, then wakes what waits for
9468
+ * it (see `isStoppedWorkToolUse`). The first record of a tool use stays.
9469
+ */
9470
+ function recordReachedToolUse(session, toolUseId, dropped) {
9471
+ const reached = (session.reachedToolUses ??= new Map());
9472
+ if (reached.has(toolUseId))
9473
+ return;
9474
+ reached.set(toolUseId, dropped);
9475
+ if (reached.size > MAX_REACHED_TOOL_USES) {
9476
+ const oldest = reached.keys().next().value;
9477
+ if (oldest !== undefined)
9478
+ reached.delete(oldest);
9479
+ }
9480
+ const waiters = session.toolUseWaiters?.get(toolUseId);
9481
+ if (!waiters)
9482
+ return;
9483
+ session.toolUseWaiters?.delete(toolUseId);
9484
+ for (const wake of waiters)
9485
+ wake();
9486
+ }
9240
9487
  /** Streamed and permission-surfaced tools can precede the SDK's user echo. */
9241
9488
  function recordForegroundToolCall(session, toolCallId) {
9242
9489
  const turn = session.activeTurn ?? session.turnQueue?.find((queued) => !queued.settled);