@agentclientprotocol/claude-agent-acp 0.85.2-preview.9 → 0.86.1-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/acp-agent.d.ts +67 -21
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +264 -68
  4. package/dist/async-tasks.d.ts +52 -3
  5. package/dist/async-tasks.d.ts.map +1 -1
  6. package/dist/async-tasks.js +185 -42
  7. package/dist/clear-context-coordinator.d.ts +1 -0
  8. package/dist/clear-context-coordinator.d.ts.map +1 -1
  9. package/dist/clear-context-coordinator.js +2 -1
  10. package/dist/diff.d.ts +45 -8
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/diff.js +111 -30
  13. package/dist/native-subagents.d.ts +7 -4
  14. package/dist/native-subagents.d.ts.map +1 -1
  15. package/dist/native-subagents.js +22 -5
  16. package/dist/permissions/presentation.d.ts +17 -2
  17. package/dist/permissions/presentation.d.ts.map +1 -1
  18. package/dist/permissions/presentation.js +4 -0
  19. package/dist/session-config-ids.d.ts +1 -0
  20. package/dist/session-config-ids.d.ts.map +1 -1
  21. package/dist/session-config-ids.js +1 -0
  22. package/dist/tool-calls/background.d.ts +5 -2
  23. package/dist/tool-calls/background.d.ts.map +1 -1
  24. package/dist/tool-calls/background.js +6 -11
  25. package/dist/tool-calls/client-capabilities.d.ts +20 -2
  26. package/dist/tool-calls/client-capabilities.d.ts.map +1 -1
  27. package/dist/tool-calls/client-capabilities.js +14 -4
  28. package/dist/tool-calls/facts.d.ts +8 -1
  29. package/dist/tool-calls/facts.d.ts.map +1 -1
  30. package/dist/tool-calls/renderer.d.ts +5 -1
  31. package/dist/tool-calls/renderer.d.ts.map +1 -1
  32. package/dist/tool-calls/renderer.js +14 -3
  33. package/dist/tool-calls/reporters/bash.d.ts.map +1 -1
  34. package/dist/tool-calls/reporters/bash.js +32 -5
  35. package/dist/tool-calls/reporters/file-edit.d.ts +1 -1
  36. package/dist/tool-calls/reporters/file-edit.d.ts.map +1 -1
  37. package/dist/tool-calls/reporters/file-edit.js +20 -14
  38. package/dist/v2/agent.d.ts +2 -0
  39. package/dist/v2/agent.d.ts.map +1 -1
  40. package/dist/v2/agent.js +109 -14
  41. package/dist/v2/initialize.d.ts +2 -5
  42. package/dist/v2/initialize.d.ts.map +1 -1
  43. package/dist/v2/initialize.js +42 -11
  44. package/dist/v2/permission.d.ts +25 -0
  45. package/dist/v2/permission.d.ts.map +1 -0
  46. package/dist/v2/permission.js +38 -0
  47. package/dist/v2/session-update.d.ts +6 -0
  48. package/dist/v2/session-update.d.ts.map +1 -1
  49. package/dist/v2/session-update.js +19 -0
  50. package/dist/v2/session.d.ts +29 -4
  51. package/dist/v2/session.d.ts.map +1 -1
  52. package/dist/v2/session.js +41 -6
  53. package/dist/v2/terminal.d.ts +42 -0
  54. package/dist/v2/terminal.d.ts.map +1 -0
  55. package/dist/v2/terminal.js +95 -0
  56. package/dist/v2/tool-call.d.ts +38 -0
  57. package/dist/v2/tool-call.d.ts.map +1 -0
  58. package/dist/v2/tool-call.js +49 -0
  59. package/package.json +1 -1
package/dist/acp-agent.js CHANGED
@@ -5,7 +5,7 @@ import { sanitizeTitle, SessionTitles } from "./session-titles.js";
5
5
  import { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, asSdkSessionNotification, clientSupportsSubagents, } from "./acp-subagents.js";
6
6
  import { isNativeSubagentControlTool, isNativeSubagentControlUpdate, NativeSubagentRuntime, nativeSubagentState, resumedNativeSubagentId, sendMessageResumePrompt, } from "./native-subagents.js";
7
7
  import { AIR_ASYNC_TASKS_CAPABILITY, AIR_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY, AIR_GOAL_KEY, AIR_KIND_KEY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, AIR_SKILL_PATH_KEY, clientSupportsAirCapability, withAirMeta, } from "./air-extension.js";
8
- import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, clientSupportsAsyncTasks, } from "./async-tasks.js";
8
+ import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, backgroundedBashToolCallIds, clientSupportsAsyncTasks, } from "./async-tasks.js";
9
9
  import { AUTH_STATUS_PROBE_TIMEOUT_MS, AUTH_STATUS_UPDATE_METHOD, authStatusCapability, fromAccountInfo, fromCliStatus, gatewayAuthStatus, mergeAuthStatus, notLoggedInAuthStatus, sameAuthStatus, } from "./auth-status.js";
10
10
  import { execFile } from "node:child_process";
11
11
  import { randomUUID } from "node:crypto";
@@ -21,7 +21,7 @@ import { SessionTiming } from "./session-timing.js";
21
21
  import { ALLOW_BYPASS } from "./permissions/modes.js";
22
22
  import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js";
23
23
  import { buildClaudePermissionOptions } from "./permissions/options.js";
24
- import { buildClaudePermissionPresentation } from "./permissions/presentation.js";
24
+ import { buildClaudePermissionPresentation, } from "./permissions/presentation.js";
25
25
  import { decodeClaudePermissionResponse } from "./permissions/response.js";
26
26
  import { SettingsManager } from "./settings.js";
27
27
  import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
@@ -39,8 +39,10 @@ import { AcpToolCallRenderer } from "./tool-calls/renderer.js";
39
39
  import { resolveSkillPath } from "./tool-calls/reporters/interaction.js";
40
40
  import { nodeToWebReadable, nodeToWebWritable, Pushable, raceTimeoutAndAbort, unreachable, } from "./utils.js";
41
41
  import { acceptedPlanToolResult, ExitPlanCoordinator, isExitPlanInterruptionResult, observeExitPlanToolResults, } from "./exit-plan.js";
42
+ import { DEFAULT_AGENT_ID } from "./session-config-ids.js";
42
43
  import { parseToolResultMeta } from "./tool-result-meta.js";
43
44
  import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
45
+ export { DEFAULT_AGENT_ID } from "./session-config-ids.js";
44
46
  import { MCP_AVAILABLE_COMMAND, parseMcpCommand, runMcpCommand } from "./mcp-command.js";
45
47
  import { MODE_CONFIG_ID, resolveInitialPermissionMode, SessionModeManager, } from "./session-mode.js";
46
48
  import { applyAvailableModelsAllowlist, buildModelConfigOption, getAvailableModels, MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js";
@@ -79,6 +81,10 @@ const DEFAULT_CONTEXT_WINDOW = 200000;
79
81
  * pre-empt a slow-but-healthy interrupt. */
80
82
  const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
81
83
  const STRUCTURED_USAGE_TIMEOUT_MS = 5_000;
84
+ /** How long a session teardown waits for the turns that it cancels to end.
85
+ * The teardown wakes the consumer itself, so they end within a few ticks
86
+ * unless the consumer is wedged. */
87
+ const TEARDOWN_TURN_END_TIMEOUT_MS = 5_000;
82
88
  /** The number of settled subagents whose parent tool call a session keeps for
83
89
  * a later resume (see `resumableSubagents`). */
84
90
  const MAX_RESUMABLE_SUBAGENTS = 256;
@@ -1151,7 +1157,14 @@ export class ClaudeAcpAgent {
1151
1157
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
1152
1158
  * tests can shrink it. */
1153
1159
  forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
1154
- constructor(client, logger) {
1160
+ /**
1161
+ * The connection serves ACP v2 (`src/v2/`). The agent still speaks v1
1162
+ * types, except for the tool call content that v2 needs and v1 cannot
1163
+ * express: see {@link ToolCallClientCapabilities.v2}.
1164
+ */
1165
+ v2;
1166
+ constructor(client, logger, options = {}) {
1167
+ this.v2 = options.v2 ?? false;
1155
1168
  this.sessions = {};
1156
1169
  this.client = new ChangedMetaClient(client, () => this.toolCallCapabilities.air.client);
1157
1170
  this.logger = logger ?? console;
@@ -1208,7 +1221,9 @@ export class ClaudeAcpAgent {
1208
1221
  }
1209
1222
  async initialize(request) {
1210
1223
  this.clientCapabilities = request.clientCapabilities;
1211
- this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities);
1224
+ this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities, {
1225
+ v2: this.v2,
1226
+ });
1212
1227
  // Learn the auth identity in the background: `initialize` never waits on
1213
1228
  // the CLI probe, and no snapshot rides in its response. When the probe
1214
1229
  // lands it calls `setAuthStatus`, which pushes `_auth/status_update` — the
@@ -2450,6 +2465,7 @@ export class ClaudeAcpAgent {
2450
2465
  // A task that a subagent tool call started belongs to the child
2451
2466
  // session of that tool call, like the tool call itself.
2452
2467
  routeOf: (toolCallId) => session.nativeSubagentRuntime?.routeOfToolCall(toolCallId, session.eagerToolCallSessions?.get(toolCallId)),
2468
+ toolNameOf: (toolCallId) => session.toolUseCache[toolCallId]?.name ?? session.resolvedToolNames?.get(toolCallId),
2453
2469
  }));
2454
2470
  const compaction = new ContextCompactionLifecycle((notification) => sendUpdate(notification), {
2455
2471
  sessionId: params.sessionId,
@@ -2510,6 +2526,7 @@ export class ClaudeAcpAgent {
2510
2526
  update.sessionUpdate === "tool_call_update" &&
2511
2527
  (update.status === "completed" || update.status === "failed")) {
2512
2528
  session.eagerToolCallSessions?.delete(toolCallId);
2529
+ session.dispatchedToolCalls?.delete(toolCallId);
2513
2530
  }
2514
2531
  };
2515
2532
  // toAcpNotifications registers deferred tool hooks that publish through
@@ -2951,38 +2968,48 @@ export class ClaudeAcpAgent {
2951
2968
  // Check only this turn's tools. A tool_result removes its id from
2952
2969
  // emittedToolCalls even when its PostToolUse hook has not arrived yet.
2953
2970
  const unfinished = [...(turn.foregroundToolCallIds ?? [])].filter((id) => session.emittedToolCalls.has(id) && !backgroundTools.has(id));
2954
- if (unfinished.length > 0) {
2955
- const message = `Claude ended the turn without returning results for tool calls: ${unfinished.join(", ")}`;
2971
+ /** Closes a tool call as failed. Returns false when the turn ended meanwhile. */
2972
+ const failToolCall = async (toolCallId, text) => {
2973
+ // A late hook must not overwrite the failure we are about to send.
2974
+ unregisterHookCallback(toolCallId);
2975
+ session.emittedToolCalls.delete(toolCallId);
2976
+ delete session.toolUseCache[toolCallId];
2977
+ session.toolCallFields?.delete(toolCallId);
2978
+ session.dispatchedToolCalls?.delete(toolCallId);
2979
+ await sendUpdate({
2980
+ sessionId: params.sessionId,
2981
+ update: {
2982
+ sessionUpdate: "tool_call_update",
2983
+ toolCallId,
2984
+ status: "failed",
2985
+ content: [{ type: "content", content: { type: "text", text } }],
2986
+ },
2987
+ });
2988
+ // Cancellation can arrive while we await the client update.
2989
+ if (turn.settled || session.activeTurn !== turn)
2990
+ return false;
2991
+ if (session.cancelled) {
2992
+ await settleActive({ ...result, stopReason: "cancelled" });
2993
+ return false;
2994
+ }
2995
+ return true;
2996
+ };
2997
+ // A streamed tool_use that never reached a complete assistant message
2998
+ // never ran: Claude abandoned it, for example for a steering message.
2999
+ // It is not a failure of the turn.
3000
+ const abandoned = unfinished.filter((id) => !session.dispatchedToolCalls?.has(id));
3001
+ const stuck = unfinished.filter((id) => session.dispatchedToolCalls?.has(id));
3002
+ for (const toolCallId of abandoned) {
3003
+ if (!(await failToolCall(toolCallId, "Claude stopped this tool call before it ran."))) {
3004
+ return;
3005
+ }
3006
+ }
3007
+ if (stuck.length > 0) {
3008
+ const message = `Claude ended the turn without returning results for tool calls: ${stuck.join(", ")}`;
2956
3009
  this.logger.error(`Session ${params.sessionId}, turn ${turn.promptUuid}, stopReason=${result.stopReason}: ${message}`);
2957
3010
  // Fail every unfinished tool before reporting one error for the prompt.
2958
- for (const toolCallId of unfinished) {
2959
- // A late hook must not overwrite the failure we are about to send.
2960
- unregisterHookCallback(toolCallId);
2961
- session.emittedToolCalls.delete(toolCallId);
2962
- delete session.toolUseCache[toolCallId];
2963
- session.toolCallFields?.delete(toolCallId);
2964
- await sendUpdate({
2965
- sessionId: params.sessionId,
2966
- update: {
2967
- sessionUpdate: "tool_call_update",
2968
- toolCallId,
2969
- status: "failed",
2970
- content: [
2971
- {
2972
- type: "content",
2973
- content: {
2974
- type: "text",
2975
- text: "Claude ended the turn without returning a result for this tool.",
2976
- },
2977
- },
2978
- ],
2979
- },
2980
- });
2981
- // Cancellation can arrive while we await the client update.
2982
- if (turn.settled || session.activeTurn !== turn)
2983
- return;
2984
- if (session.cancelled) {
2985
- await settleActive({ ...result, stopReason: "cancelled" });
3011
+ for (const toolCallId of stuck) {
3012
+ if (!(await failToolCall(toolCallId, "Claude ended the turn without returning a result for this tool."))) {
2986
3013
  return;
2987
3014
  }
2988
3015
  }
@@ -3130,11 +3157,18 @@ export class ClaudeAcpAgent {
3130
3157
  .then((result) => ({ kind: "message", result }));
3131
3158
  const nextMessage = pendingNext;
3132
3159
  // Fresh abort listener per iteration, removed when next() wins, so a
3133
- // long-lived session doesn't accumulate listeners on one signal.
3160
+ // long-lived session doesn't accumulate listeners on one signal. An
3161
+ // abort that fired while the consumer was busy elsewhere, such as
3162
+ // sending an update, still wakes it: a listener added to an aborted
3163
+ // signal never fires. The consumer re-arms after each abort it
3164
+ // handles, so an aborted signal here is always an unhandled abort.
3134
3165
  let onAbort;
3135
3166
  const abortRace = new Promise((resolve) => {
3136
3167
  onAbort = () => resolve("abort");
3137
- cancelController.signal.addEventListener("abort", onAbort, { once: true });
3168
+ if (cancelController.signal.aborted)
3169
+ onAbort();
3170
+ else
3171
+ cancelController.signal.addEventListener("abort", onAbort, { once: true });
3138
3172
  });
3139
3173
  const raced = await Promise.race([nextMessage, abortRace]);
3140
3174
  cancelController.signal.removeEventListener("abort", onAbort);
@@ -4723,6 +4757,7 @@ export class ClaudeAcpAgent {
4723
4757
  }
4724
4758
  for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, this.client, this.logger, {
4725
4759
  clientCapabilities: this.clientCapabilities,
4760
+ toolCallCapabilities: this.toolCallCapabilities,
4726
4761
  cwd: session.cwd,
4727
4762
  taskState: session.taskState,
4728
4763
  emittedToolCalls: session.emittedToolCalls,
@@ -4747,6 +4782,8 @@ export class ClaudeAcpAgent {
4747
4782
  }
4748
4783
  case "user":
4749
4784
  case "assistant": {
4785
+ if (message.type === "assistant")
4786
+ recordDispatchedToolUses(session, message.message.content);
4750
4787
  // Record the ACP messageId -> SDK uuid mapping for this message
4751
4788
  // (including replays). The consolidated message carries both ids, so
4752
4789
  // this is where we learn the uuid the SDK's rewind/resume APIs key on
@@ -4926,6 +4963,7 @@ export class ClaudeAcpAgent {
4926
4963
  if (typeof stripped === "string") {
4927
4964
  for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
4928
4965
  clientCapabilities: this.clientCapabilities,
4966
+ toolCallCapabilities: this.toolCallCapabilities,
4929
4967
  parentToolUseId: message.parent_tool_use_id,
4930
4968
  cwd: session.cwd,
4931
4969
  taskState: session.taskState,
@@ -4992,11 +5030,14 @@ export class ClaudeAcpAgent {
4992
5030
  content = message.message.content;
4993
5031
  }
4994
5032
  const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
4995
- let backgroundBashTask;
5033
+ let backgroundedToolCalls = new Set();
4996
5034
  if (message.type === "user") {
4997
- backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
5035
+ rememberResolvedToolNames(session, content);
5036
+ const backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
4998
5037
  if (backgroundBashTask)
4999
5038
  await asyncTasks.taskBackgrounded(backgroundBashTask);
5039
+ await asyncTasks.toolResults(content);
5040
+ backgroundedToolCalls = backgroundedBashToolCallIds(content, session.toolUseCache, asyncTasks, backgroundBashTask);
5000
5041
  const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
5001
5042
  if (resumedAgentId) {
5002
5043
  resumeLiveTask(resumedAgentId);
@@ -5007,6 +5048,7 @@ export class ClaudeAcpAgent {
5007
5048
  }
5008
5049
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
5009
5050
  clientCapabilities: this.clientCapabilities,
5051
+ toolCallCapabilities: this.toolCallCapabilities,
5010
5052
  parentToolUseId: message.parent_tool_use_id,
5011
5053
  cwd: session.cwd,
5012
5054
  taskState: session.taskState,
@@ -5024,7 +5066,7 @@ export class ClaudeAcpAgent {
5024
5066
  // filtered out of `content` above; blocks that do pass through
5025
5067
  // (e.g. a subagent image) carry the stamped parentToolUseId
5026
5068
  // meta and are excluded there.
5027
- await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundBashTask, asyncTasks.enabled));
5069
+ await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled));
5028
5070
  }
5029
5071
  break;
5030
5072
  }
@@ -5094,6 +5136,7 @@ export class ClaudeAcpAgent {
5094
5136
  await finishLifecycle("failed", "failed", "during conversation reset");
5095
5137
  subagents.clear();
5096
5138
  asyncTasks.clear();
5139
+ session.resolvedToolNames?.clear();
5097
5140
  session.eagerToolCallSessions?.clear();
5098
5141
  session.toolCallFields?.clear();
5099
5142
  clearHookCallbacks(params.sessionId);
@@ -5554,12 +5597,18 @@ export class ClaudeAcpAgent {
5554
5597
  session.query.close();
5555
5598
  }
5556
5599
  /** Cleanly tear down a session: cancel in-flight work, release stream
5557
- * resources, and remove it from the session map. */
5600
+ * resources, and remove it from the session map. Returns once the turns it
5601
+ * cancels have ended, so their end (a v1 `session/prompt` response, a v2
5602
+ * `idle` state) goes out before the caller answers: ACP v2's
5603
+ * `session/close` cancels "as if `session/cancel` had been called", then
5604
+ * frees the session. A wedged consumer holds it at most
5605
+ * {@link TEARDOWN_TURN_END_TIMEOUT_MS}. */
5558
5606
  async teardownSession(sessionId) {
5559
5607
  const session = this.sessions[sessionId];
5560
5608
  if (!session) {
5561
5609
  return;
5562
5610
  }
5611
+ const turnsEnded = Promise.all((session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])));
5563
5612
  try {
5564
5613
  await this.cancelTurns({ sessionId }, { awaitInterrupt: false });
5565
5614
  }
@@ -5587,6 +5636,10 @@ export class ClaudeAcpAgent {
5587
5636
  session.nativeSubagentRuntime?.clear();
5588
5637
  session.asyncTaskRuntime?.clear();
5589
5638
  delete this.sessions[sessionId];
5639
+ const ended = await raceTimeoutAndAbort(turnsEnded, TEARDOWN_TURN_END_TIMEOUT_MS, new AbortController().signal);
5640
+ if (ended.type === "timeout") {
5641
+ this.logger.error(`Session ${sessionId}: its cancelled turns did not end within ${TEARDOWN_TURN_END_TIMEOUT_MS}ms of the teardown`);
5642
+ }
5590
5643
  }
5591
5644
  /** Tear down all active sessions. Called when the ACP connection closes. */
5592
5645
  async dispose() {
@@ -5843,6 +5896,7 @@ export class ClaudeAcpAgent {
5843
5896
  ? (notification) => ({ ...notification, sessionId: target })
5844
5897
  : undefined;
5845
5898
  },
5899
+ toolNameOf: (toolCallId) => replayToolUses.get(toolCallId)?.name,
5846
5900
  });
5847
5901
  // The replay counterpart of the live `task_notification` frame.
5848
5902
  const restoreTaskNotification = async (notification) => {
@@ -5926,6 +5980,8 @@ export class ClaudeAcpAgent {
5926
5980
  }
5927
5981
  // @ts-expect-error - untyped in SDK but we handle all of these
5928
5982
  if (message.message.role === "user") {
5983
+ // Like live, a tool result gives the output path of a known task.
5984
+ await replayAsyncTasks.toolResults(content);
5929
5985
  for (const notification of taskNotificationsOf(content)) {
5930
5986
  await restoreTaskNotification(notification);
5931
5987
  }
@@ -5981,6 +6037,7 @@ export class ClaudeAcpAgent {
5981
6037
  registerHooks: false,
5982
6038
  replay: true,
5983
6039
  clientCapabilities: this.clientCapabilities,
6040
+ toolCallCapabilities: this.toolCallCapabilities,
5984
6041
  cwd: replayState()?.cwd,
5985
6042
  taskState: replayState()?.taskState,
5986
6043
  messageId: replayMessageId,
@@ -6120,8 +6177,10 @@ export class ClaudeAcpAgent {
6120
6177
  // Do not rely on every ACP client settling requestPermission after the
6121
6178
  // cancellation signal. The local race guarantees that Claude's tool call
6122
6179
  // is released even when an older or broken client ignores $/cancel_request.
6180
+ // The request goes to a subagent's child session for a tool call of that
6181
+ // subagent, but it blocks the turn of the owner session.
6123
6182
  try {
6124
- return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
6183
+ return await this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
6125
6184
  }
6126
6185
  catch (error) {
6127
6186
  if (signal.aborted) {
@@ -6154,6 +6213,8 @@ export class ClaudeAcpAgent {
6154
6213
  toolCallFieldsOf(session).pinContent(toolCallId, previewContent);
6155
6214
  }
6156
6215
  };
6216
+ // A permission request comes only for a tool call that is about to run.
6217
+ recordDispatchedToolCall(session, toolCallId);
6157
6218
  if (session.emittedToolCalls.has(toolCallId)) {
6158
6219
  pinPreview();
6159
6220
  return;
@@ -6231,7 +6292,7 @@ export class ClaudeAcpAgent {
6231
6292
  // Like permission requests, the elicitation references this toolUseID, so
6232
6293
  // make sure the tool_call has surfaced to the client before we send it.
6233
6294
  await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal, permissionSessionId);
6234
- return this.handleAskUserQuestion(permissionSessionId, toolInput, toolUseID, signal);
6295
+ return this.handleAskUserQuestion(sessionId, permissionSessionId, toolInput, toolUseID, signal);
6235
6296
  }
6236
6297
  // Do not auto-allow here based on the session's advertised mode. Claude
6237
6298
  // Code applies bypassPermissions before invoking canUseTool; a request
@@ -6247,8 +6308,8 @@ export class ClaudeAcpAgent {
6247
6308
  const noPersistentRule = matchedAskRule !== undefined || suppressAlwaysAllowRule === true;
6248
6309
  const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, noPersistentRule);
6249
6310
  const capabilities = this.toolCallCapabilities;
6250
- const previewContent = capabilities.diffPatch
6251
- ? await previewPatchContent(toolName, toolInput, session.cwd)
6311
+ const previewContent = capabilities.diffPatch || capabilities.v2
6312
+ ? await previewPatchContent(toolName, toolInput, session.cwd, capabilities.v2 ? "v2" : "air")
6252
6313
  : undefined;
6253
6314
  const presentation = buildClaudePermissionPresentation({
6254
6315
  toolName,
@@ -6385,13 +6446,15 @@ export class ClaudeAcpAgent {
6385
6446
  * Present the built-in AskUserQuestion tool's questions as an ACP form
6386
6447
  * elicitation and return the answers as the tool's `updatedInput`. Called from
6387
6448
  * `canUseTool` since that is where the SDK routes the tool's permission check.
6449
+ * The elicitation goes to `requestSessionId`, a subagent's child session for
6450
+ * a question it asks, and blocks the turn of `sessionId`.
6388
6451
  */
6389
- async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal) {
6452
+ async handleAskUserQuestion(sessionId, requestSessionId, toolInput, toolUseID, signal) {
6390
6453
  const questions = extractAskUserQuestions(toolInput);
6391
6454
  if (!questions) {
6392
6455
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
6393
6456
  }
6394
- const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, this.toolCallCapabilities.air.client);
6457
+ const createRequest = askUserQuestionsToCreateRequest(questions, requestSessionId, toolUseID, this.toolCallCapabilities.air.client);
6395
6458
  let response;
6396
6459
  try {
6397
6460
  response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal));
@@ -6479,15 +6542,21 @@ export class ClaudeAcpAgent {
6479
6542
  });
6480
6543
  }
6481
6544
  /**
6482
- * Replace a heuristic context window with `getContextUsage().rawMaxTokens`
6545
+ * Refine a heuristic context window with `getContextUsage().rawMaxTokens`,
6483
6546
  * without blocking the caller. The text heuristic misses natively-1M models
6484
- * whose picker rows carry no "1m" token (`sonnet`, and since CLI 2.1.283
6485
- * `opus`/`default`), which would otherwise report 200k until the first
6486
- * result's modelUsage. Never awaited: SDK control requests are serialized,
6487
- * so an awaited call would delay session/new or a model switch (before the
6488
- * first turn it took ~15s on older CLIs, issues #886/#880; ~0.5s on 2.1.283).
6489
- * Not written to `contextWindowCache` — that stays keyed to the
6490
- * `result.modelUsage` spellings — and a result still overwrites it.
6547
+ * whose picker rows carry no "1m" token (e.g. `sonnet`, and since CLI
6548
+ * 2.1.283 `opus`/`default`), so those would otherwise show 200k until the
6549
+ * first result's modelUsage arrives.
6550
+ *
6551
+ * Never awaited: SDK control requests are serialized, so awaiting here
6552
+ * would delay session/new or a model switch (~15s on older CLIs, issues
6553
+ * #886/#880; ~0.5s on 2.1.283). Requests the `summary` detail instead of
6554
+ * the default `full` breakdown, which issues one `messages/count_tokens`
6555
+ * call per category (~18 for a bare session) and hit rate limits on every
6556
+ * model switch; `summary` answers locally with the same `rawMaxTokens`.
6557
+ *
6558
+ * Does not write `contextWindowCache` (that stays keyed to the
6559
+ * `result.modelUsage` spellings) — a later result still overwrites this.
6491
6560
  */
6492
6561
  refreshContextWindowInBackground(sessionId, session) {
6493
6562
  if (session.contextWindowAuthoritative)
@@ -6499,7 +6568,7 @@ export class ClaudeAcpAgent {
6499
6568
  session.models.currentModelId === modelId;
6500
6569
  // A synchronous throw must not fail the caller either.
6501
6570
  Promise.resolve()
6502
- .then(() => query.getContextUsage())
6571
+ .then(() => query.getContextUsage({ detail: "summary" }))
6503
6572
  .then((usage) => {
6504
6573
  if (!stillCurrent() || session.contextWindowAuthoritative)
6505
6574
  return;
@@ -6569,7 +6638,7 @@ export class ClaudeAcpAgent {
6569
6638
  const seedEffort = effortPinnedForNewModel
6570
6639
  ? pinnedEffort
6571
6640
  : settingsEffortForModel(mergeEffortSettings(session.settingsManager.getSettings(), session.effortSettingsOverride), newModelInfo, value);
6572
- session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, seedEffort, {
6641
+ session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, seedEffort, session.agents, session.currentAgent, {
6573
6642
  // The toggle follows the newly selected model: it disappears when the
6574
6643
  // model lacks fast support and reappears (with the retained user
6575
6644
  // intent) when a supporting model is selected again.
@@ -6648,6 +6717,18 @@ export class ClaudeAcpAgent {
6648
6717
  if (modelChanged)
6649
6718
  this.refreshContextWindowInBackground(sessionId, session);
6650
6719
  }
6720
+ else if (configId === AGENT_CONFIG_ID) {
6721
+ // Live agent switch — no subprocess restart needed. Apply the SDK flag
6722
+ // first so a rejected control request leaves both `currentAgent` and the
6723
+ // config option untouched (no UI/SDK desync). Passing `null` clears the
6724
+ // flag layer back to the standard Claude Code agent; the change takes
6725
+ // effect on the next turn (SDK >= 0.3.161).
6726
+ await session.query.applyFlagSettings({
6727
+ agent: value === DEFAULT_AGENT_ID ? null : value,
6728
+ });
6729
+ session.currentAgent = value;
6730
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
6731
+ }
6651
6732
  else if (configId === EFFORT_CONFIG_ID) {
6652
6733
  // Apply first so a rejected control request cannot leave the displayed
6653
6734
  // value ahead of the SDK flag layer.
@@ -6954,14 +7035,7 @@ export class ClaudeAcpAgent {
6954
7035
  resumed: resumedPermissionMode,
6955
7036
  defaultMode: settingsManager.getSettings().permissions?.defaultMode,
6956
7037
  }, this.logger, allowBypass);
6957
- const userProvidedOptions = sessionMeta?.claudeCode?.options
6958
- ? { ...sessionMeta.claudeCode.options }
6959
- : undefined;
6960
- // Main-thread agent selection is intentionally not part of this adapter's
6961
- // ACP contract. Ignore the provider-specific option instead of forwarding
6962
- // hidden state that the Client cannot inspect or change.
6963
- if (userProvidedOptions)
6964
- delete userProvidedOptions.agent;
7038
+ const userProvidedOptions = sessionMeta?.claudeCode?.options;
6965
7039
  const forwardSubagentText = this.forwardsSubagentText(params._meta);
6966
7040
  // Configure thinking behavior from environment variable
6967
7041
  const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
@@ -7326,6 +7400,17 @@ export class ClaudeAcpAgent {
7326
7400
  allowBypass,
7327
7401
  });
7328
7402
  timing.phase("modes");
7403
+ const agents = await discoverCustomAgents(q);
7404
+ timing.phase("agents");
7405
+ // Only adopt the requested agent as the selected value if it's one we
7406
+ // actually surface in the picker. A built-in (filtered out above) or
7407
+ // otherwise-unknown name would leave the config option's `currentValue`
7408
+ // pointing at an entry not in its own `options` list, which clients render
7409
+ // as a blank/invalid selection.
7410
+ const requestedAgent = userProvidedOptions?.agent;
7411
+ const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
7412
+ ? requestedAgent
7413
+ : DEFAULT_AGENT_ID;
7329
7414
  // Seed Fast mode from the SDK's reported state so the UI reflects reality
7330
7415
  // (the CLI may start a session with fast mode already on, or force it off
7331
7416
  // when `fastModePerSessionOptIn` is set). The toggle is only surfaced while
@@ -7351,7 +7436,7 @@ export class ClaudeAcpAgent {
7351
7436
  // Legacy clients still leave effort resolution entirely to the CLI.
7352
7437
  const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities);
7353
7438
  const effortSettings = mergeEffortSettings(settingsManager.getSettings(), configuredSettingsObject);
7354
- const configOptions = buildConfigOptions(modes, models, modelInfos, userProvidedOptions?.effort ?? settingsEffortForModel(effortSettings, currentModelInfo), fastMode, {
7439
+ const configOptions = buildConfigOptions(modes, models, modelInfos, userProvidedOptions?.effort ?? settingsEffortForModel(effortSettings, currentModelInfo), agents, currentAgent, fastMode, {
7355
7440
  useRecommendedValue,
7356
7441
  });
7357
7442
  const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
@@ -7412,6 +7497,8 @@ export class ClaudeAcpAgent {
7412
7497
  initialEffort?.currentValue === userProvidedOptions.effort
7413
7498
  ? userProvidedOptions.effort
7414
7499
  : undefined,
7500
+ agents,
7501
+ currentAgent,
7415
7502
  fastModeEnabled,
7416
7503
  fastModeDisabledReason,
7417
7504
  abortController,
@@ -7844,11 +7931,43 @@ function isValidBaseUrl(baseUrl) {
7844
7931
  }
7845
7932
  return parsed.protocol === "http:" || parsed.protocol === "https:";
7846
7933
  }
7934
+ // `supportedAgents()` always returns Claude Code's built-in subagents — the
7935
+ // ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
7936
+ // has configured none of their own. Those aren't meaningful *main-thread*
7937
+ // personas, so we filter them out and only surface the Agent picker when the
7938
+ // user (or a plugin/project) has configured custom agents. Update this set if
7939
+ // the SDK's built-in roster changes.
7940
+ export const BUILTIN_AGENT_NAMES = new Set([
7941
+ "claude",
7942
+ "general-purpose",
7943
+ "Explore",
7944
+ "Plan",
7945
+ "statusline-setup",
7946
+ ]);
7947
+ // Value of the synthetic "Default" entry in the agent picker, which maps to the
7948
+ // standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
7949
+ // reserved sentinel: a custom agent named exactly this would collide with it
7950
+ // (two options sharing the value, selection silently routing to `null`), so we
7951
+ // exclude that name from discovery.
7952
+ /** Discover user/plugin/project-configured main-thread agents, excluding the
7953
+ * built-in subagents and the reserved "default" sentinel. Returns an empty
7954
+ * list if discovery fails so a flaky control request never blocks session
7955
+ * creation. */
7956
+ export async function discoverCustomAgents(q) {
7957
+ try {
7958
+ const agents = await q.supportedAgents();
7959
+ return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
7960
+ }
7961
+ catch {
7962
+ return [];
7963
+ }
7964
+ }
7847
7965
  /** Stable ids for the session config options surfaced via `configOptions`.
7848
7966
  * Centralized so the option declarations in `buildConfigOptions` and the
7849
7967
  * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
7850
7968
  * same identifiers and can't drift apart. */
7851
7969
  export { MODE_CONFIG_ID };
7970
+ export const AGENT_CONFIG_ID = "agent";
7852
7971
  export const FAST_MODE_CONFIG_ID = "fast";
7853
7972
  /** Select-fallback values used when the client has not opted into boolean
7854
7973
  * config options (see {@link createFastModeConfigOption}). */
@@ -7950,7 +8069,7 @@ export function resolveFastModeEnabled(params) {
7950
8069
  }
7951
8070
  throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`);
7952
8071
  }
7953
- export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, fastMode, presentation) {
8072
+ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode, presentation) {
7954
8073
  const options = [
7955
8074
  SessionModeManager.configOption(modes),
7956
8075
  buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true),
@@ -7964,6 +8083,27 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
7964
8083
  if (fastMode?.supported) {
7965
8084
  options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption, fastMode.disabledReason));
7966
8085
  }
8086
+ // Only surface the Agent picker when there's a real choice — i.e. the user
8087
+ // has configured at least one custom agent (built-ins are filtered out in
8088
+ // discoverCustomAgents). With none configured, "Default" would be the only
8089
+ // entry, so we omit the option entirely.
8090
+ if (agents.length > 0) {
8091
+ options.push({
8092
+ id: AGENT_CONFIG_ID,
8093
+ name: "Agent",
8094
+ description: "Main-thread agent persona",
8095
+ type: "select",
8096
+ currentValue: currentAgent,
8097
+ options: [
8098
+ { value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
8099
+ ...agents.map((a) => ({
8100
+ value: a.name,
8101
+ name: a.name,
8102
+ description: a.description || undefined,
8103
+ })),
8104
+ ],
8105
+ });
8106
+ }
7967
8107
  return options;
7968
8108
  }
7969
8109
  function getAvailableSlashCommands(commands,
@@ -8182,6 +8322,31 @@ function isTaskTool(toolName) {
8182
8322
  function shouldEmitToolCall(toolName) {
8183
8323
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
8184
8324
  }
8325
+ const MAX_DISPATCHED_TOOL_CALLS = 1000;
8326
+ /** Records a tool call that Claude Code sent to the tool runner. */
8327
+ function recordDispatchedToolCall(session, toolCallId) {
8328
+ const calls = (session.dispatchedToolCalls ??= new Set());
8329
+ calls.delete(toolCallId);
8330
+ calls.add(toolCallId);
8331
+ if (calls.size > MAX_DISPATCHED_TOOL_CALLS) {
8332
+ const oldest = calls.values().next().value;
8333
+ if (oldest !== undefined)
8334
+ calls.delete(oldest);
8335
+ }
8336
+ }
8337
+ /** Records the tool_use blocks of a complete assistant message. */
8338
+ function recordDispatchedToolUses(session, content) {
8339
+ if (!Array.isArray(content))
8340
+ return;
8341
+ for (const block of content) {
8342
+ if ((block?.type === "tool_use" ||
8343
+ block?.type === "server_tool_use" ||
8344
+ block?.type === "mcp_tool_use") &&
8345
+ typeof block.id === "string") {
8346
+ recordDispatchedToolCall(session, block.id);
8347
+ }
8348
+ }
8349
+ }
8185
8350
  /** Streamed and permission-surfaced tools can precede the SDK's user echo. */
8186
8351
  function recordForegroundToolCall(session, toolCallId) {
8187
8352
  const turn = session.activeTurn ?? session.turnQueue?.find((queued) => !queued.settled);
@@ -8201,10 +8366,40 @@ function toolCallFieldsOf(session) {
8201
8366
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
8202
8367
  * Only handles text, image, and thinking chunks for now.
8203
8368
  */
8369
+ /**
8370
+ * The renderer of the notification functions: the agent's tool call
8371
+ * capabilities when they are passed, else those of the ACP capabilities.
8372
+ */
8373
+ function toolCallRenderer(options) {
8374
+ return options?.toolCallCapabilities
8375
+ ? new AcpToolCallRenderer(options.toolCallCapabilities, options.replay)
8376
+ : AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
8377
+ }
8378
+ const MAX_RESOLVED_TOOL_NAMES = 1000;
8379
+ /** Keeps the tool names of the tool results in `content` before the tool use cache drops them. */
8380
+ function rememberResolvedToolNames(session, content) {
8381
+ if (!Array.isArray(content))
8382
+ return;
8383
+ for (const block of content) {
8384
+ if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string")
8385
+ continue;
8386
+ const name = session.toolUseCache[block.tool_use_id]?.name;
8387
+ if (!name)
8388
+ continue;
8389
+ const names = (session.resolvedToolNames ??= new Map());
8390
+ names.delete(block.tool_use_id);
8391
+ names.set(block.tool_use_id, name);
8392
+ if (names.size > MAX_RESOLVED_TOOL_NAMES) {
8393
+ const oldest = names.keys().next().value;
8394
+ if (oldest !== undefined)
8395
+ names.delete(oldest);
8396
+ }
8397
+ }
8398
+ }
8204
8399
  export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
8205
8400
  const taskState = options?.taskState ?? new Map();
8206
8401
  const registerHooks = options?.registerHooks !== false;
8207
- const renderer = AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
8402
+ const renderer = toolCallRenderer(options);
8208
8403
  if (typeof content === "string") {
8209
8404
  if (content.length === 0) {
8210
8405
  return [];
@@ -8542,6 +8737,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8542
8737
  const streamedToolInputs = options?.streamedToolInputs;
8543
8738
  const forwardedOptions = {
8544
8739
  clientCapabilities: options?.clientCapabilities,
8740
+ toolCallCapabilities: options?.toolCallCapabilities,
8545
8741
  parentToolUseId: message.parent_tool_use_id,
8546
8742
  cwd: options?.cwd,
8547
8743
  taskState: options?.taskState,
@@ -8605,7 +8801,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8605
8801
  // TodoWrite and the Task* tools never surfaced a tool_call to refine.
8606
8802
  if (!shouldEmitToolCall(streamedInput.name))
8607
8803
  return [];
8608
- const update = AcpToolCallRenderer.for(options?.clientCapabilities).partialRefinement(streamedInput, input, options?.cwd);
8804
+ const update = toolCallRenderer(options).partialRefinement(streamedInput, input, options?.cwd);
8609
8805
  if (message.parent_tool_use_id)
8610
8806
  stampParentToolUseId(update, message.parent_tool_use_id);
8611
8807
  applyMessageId(update, options?.messageId);