@agentclientprotocol/claude-agent-acp 0.85.2-preview.2 → 0.85.2-preview.21

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 (73) hide show
  1. package/README.md +1 -0
  2. package/dist/acp-agent.d.ts +100 -23
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +528 -183
  5. package/dist/air-extension.d.ts +2 -0
  6. package/dist/air-extension.d.ts.map +1 -1
  7. package/dist/air-extension.js +2 -0
  8. package/dist/clear-context-coordinator.d.ts +1 -0
  9. package/dist/clear-context-coordinator.d.ts.map +1 -1
  10. package/dist/clear-context-coordinator.js +2 -1
  11. package/dist/diff.d.ts +45 -8
  12. package/dist/diff.d.ts.map +1 -1
  13. package/dist/diff.js +111 -30
  14. package/dist/mcp-command.d.ts +51 -0
  15. package/dist/mcp-command.d.ts.map +1 -0
  16. package/dist/mcp-command.js +321 -0
  17. package/dist/permissions/presentation.d.ts +17 -2
  18. package/dist/permissions/presentation.d.ts.map +1 -1
  19. package/dist/permissions/presentation.js +4 -0
  20. package/dist/resumed-session.d.ts +16 -6
  21. package/dist/resumed-session.d.ts.map +1 -1
  22. package/dist/resumed-session.js +91 -25
  23. package/dist/session-config-ids.d.ts +1 -0
  24. package/dist/session-config-ids.d.ts.map +1 -1
  25. package/dist/session-config-ids.js +1 -0
  26. package/dist/session-mode.d.ts +14 -0
  27. package/dist/session-mode.d.ts.map +1 -1
  28. package/dist/session-mode.js +22 -0
  29. package/dist/tool-calls/client-capabilities.d.ts +20 -2
  30. package/dist/tool-calls/client-capabilities.d.ts.map +1 -1
  31. package/dist/tool-calls/client-capabilities.js +14 -4
  32. package/dist/tool-calls/facts.d.ts +8 -1
  33. package/dist/tool-calls/facts.d.ts.map +1 -1
  34. package/dist/tool-calls/renderer.d.ts +5 -1
  35. package/dist/tool-calls/renderer.d.ts.map +1 -1
  36. package/dist/tool-calls/renderer.js +14 -3
  37. package/dist/tool-calls/reporters/bash.d.ts.map +1 -1
  38. package/dist/tool-calls/reporters/bash.js +32 -5
  39. package/dist/tool-calls/reporters/file-edit.d.ts +1 -1
  40. package/dist/tool-calls/reporters/file-edit.d.ts.map +1 -1
  41. package/dist/tool-calls/reporters/file-edit.js +20 -14
  42. package/dist/turn-events.d.ts +11 -5
  43. package/dist/turn-events.d.ts.map +1 -1
  44. package/dist/usage-markdown.d.ts +2 -0
  45. package/dist/usage-markdown.d.ts.map +1 -1
  46. package/dist/usage-markdown.js +2 -1
  47. package/dist/utils.d.ts +12 -0
  48. package/dist/utils.d.ts.map +1 -1
  49. package/dist/utils.js +28 -0
  50. package/dist/v2/agent.d.ts.map +1 -1
  51. package/dist/v2/agent.js +121 -17
  52. package/dist/v2/initialize.d.ts +2 -5
  53. package/dist/v2/initialize.d.ts.map +1 -1
  54. package/dist/v2/initialize.js +23 -6
  55. package/dist/v2/permission.d.ts +25 -0
  56. package/dist/v2/permission.d.ts.map +1 -0
  57. package/dist/v2/permission.js +38 -0
  58. package/dist/v2/prompt.d.ts +47 -0
  59. package/dist/v2/prompt.d.ts.map +1 -0
  60. package/dist/v2/prompt.js +153 -0
  61. package/dist/v2/session-update.d.ts +6 -0
  62. package/dist/v2/session-update.d.ts.map +1 -1
  63. package/dist/v2/session-update.js +27 -0
  64. package/dist/v2/session.d.ts +15 -4
  65. package/dist/v2/session.d.ts.map +1 -1
  66. package/dist/v2/session.js +20 -6
  67. package/dist/v2/terminal.d.ts +42 -0
  68. package/dist/v2/terminal.d.ts.map +1 -0
  69. package/dist/v2/terminal.js +95 -0
  70. package/dist/v2/tool-call.d.ts +38 -0
  71. package/dist/v2/tool-call.d.ts.map +1 -0
  72. package/dist/v2/tool-call.js +49 -0
  73. package/package.json +2 -2
package/dist/acp-agent.js CHANGED
@@ -4,7 +4,7 @@ import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFr
4
4
  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
- import { AIR_ASYNC_TASKS_CAPABILITY, AIR_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY, AIR_GOAL_KEY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, clientSupportsAirCapability, withAirMeta, } from "./air-extension.js";
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
8
  import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, 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";
@@ -16,12 +16,12 @@ import packageJson from "../package.json" with { type: "json" };
16
16
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
17
17
  import { forkSession } from "./fork-session.js";
18
18
  import { subagentHistory } from "./subagent-history.js";
19
- import { readResumedModel, readResumedSession, } from "./resumed-session.js";
19
+ import { readResumedSession, readResumedTail, } from "./resumed-session.js";
20
20
  import { SessionTiming } from "./session-timing.js";
21
- import { ALLOW_BYPASS, resolvePermissionMode } from "./permissions/modes.js";
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";
@@ -36,11 +36,15 @@ import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
36
36
  import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
37
37
  import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
38
38
  import { AcpToolCallRenderer } from "./tool-calls/renderer.js";
39
- import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
39
+ import { resolveSkillPath } from "./tool-calls/reporters/interaction.js";
40
+ import { nodeToWebReadable, nodeToWebWritable, Pushable, raceTimeoutAndAbort, unreachable, } from "./utils.js";
40
41
  import { acceptedPlanToolResult, ExitPlanCoordinator, isExitPlanInterruptionResult, observeExitPlanToolResults, } from "./exit-plan.js";
42
+ import { DEFAULT_AGENT_ID } from "./session-config-ids.js";
41
43
  import { parseToolResultMeta } from "./tool-result-meta.js";
42
44
  import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
43
- import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
45
+ export { DEFAULT_AGENT_ID } from "./session-config-ids.js";
46
+ import { MCP_AVAILABLE_COMMAND, parseMcpCommand, runMcpCommand } from "./mcp-command.js";
47
+ import { MODE_CONFIG_ID, resolveInitialPermissionMode, SessionModeManager, } from "./session-mode.js";
44
48
  import { applyAvailableModelsAllowlist, buildModelConfigOption, getAvailableModels, MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js";
45
49
  import { buildEffortConfigOption, EFFORT_CONFIG_ID, effortFlagSettings, mergeEffortSettings, settingsEffortForModel, } from "./session-effort.js";
46
50
  export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
@@ -77,6 +81,10 @@ const DEFAULT_CONTEXT_WINDOW = 200000;
77
81
  * pre-empt a slow-but-healthy interrupt. */
78
82
  const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
79
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;
80
88
  /** The number of settled subagents whose parent tool call a session keeps for
81
89
  * a later resume (see `resumableSubagents`). */
82
90
  const MAX_RESUMABLE_SUBAGENTS = 256;
@@ -105,32 +113,18 @@ function settleLiveBackgroundTask(session, taskId) {
105
113
  async function structuredUsageMarkdown(query, signal, logger) {
106
114
  if (signal.aborted)
107
115
  return null;
108
- let timeout;
109
- let onAbort;
110
116
  try {
111
- const response = await Promise.race([
112
- // Keeping the deliberately unstable method name visible makes an SDK
113
- // upgrade fail at compile time if Anthropic removes or renames it.
114
- query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
115
- new Promise((resolve) => {
116
- timeout = setTimeout(() => resolve(null), STRUCTURED_USAGE_TIMEOUT_MS);
117
- timeout.unref?.();
118
- }),
119
- new Promise((resolve) => {
120
- onAbort = () => resolve(null);
121
- if (signal.aborted)
122
- onAbort();
123
- else
124
- signal.addEventListener("abort", onAbort, { once: true });
125
- }),
126
- ]);
127
- if (response === null) {
128
- if (!signal.aborted) {
117
+ const outcome = await raceTimeoutAndAbort(
118
+ // Keeping the deliberately unstable method name visible makes an SDK
119
+ // upgrade fail at compile time if Anthropic removes or renames it.
120
+ query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), STRUCTURED_USAGE_TIMEOUT_MS, signal);
121
+ if (outcome.type !== "done") {
122
+ if (outcome.type === "timeout") {
129
123
  logger.error("Structured /usage timed out; preserving Claude Code output");
130
124
  }
131
125
  return null;
132
126
  }
133
- const usage = parseUsageResponse(response);
127
+ const usage = parseUsageResponse(outcome.value);
134
128
  if (!usage) {
135
129
  logger.error("Structured /usage returned an incompatible response; preserving Claude Code output");
136
130
  return null;
@@ -141,12 +135,6 @@ async function structuredUsageMarkdown(query, signal, logger) {
141
135
  logger.error(`Structured /usage failed; preserving Claude Code output: ${error}`);
142
136
  return null;
143
137
  }
144
- finally {
145
- if (timeout)
146
- clearTimeout(timeout);
147
- if (onAbort)
148
- signal.removeEventListener("abort", onAbort);
149
- }
150
138
  }
151
139
  /** Claude Code keeps the OAuth callback listener open in the background after
152
140
  * `mcpAuthenticate` returns the authorization URL. The SDK does not expose
@@ -680,6 +668,7 @@ const REPLAY_HIDDEN_COMMANDS = new Set([
680
668
  ...LOCAL_ONLY_COMMANDS,
681
669
  "/compact",
682
670
  "/model",
671
+ "/mcp",
683
672
  "/status",
684
673
  "/usage",
685
674
  ]);
@@ -1017,12 +1006,19 @@ const mcpAuthentications = new WeakMap();
1017
1006
  * authentication. This runs after session creation has returned so an
1018
1007
  * interactive browser flow never delays `session/new`. */
1019
1008
  function startMcpAuthentication(host, sessionId, mcpServers) {
1020
- if (!host.clientCapabilities?.elicitation?.url || mcpServers.length === 0)
1009
+ if (mcpServers.length === 0)
1010
+ return;
1011
+ startMcpServerAuthentication(host, sessionId, new Set(mcpServers.map((server) => server.name)));
1012
+ }
1013
+ /** Start OAuth in the background for the servers in `requestedServers` that
1014
+ * Claude reports as needing authentication. Startup and `/mcp` use it. It
1015
+ * does nothing while another OAuth run of the session is in progress. */
1016
+ function startMcpServerAuthentication(host, sessionId, requestedServers) {
1017
+ if (!host.clientCapabilities?.elicitation?.url)
1021
1018
  return;
1022
1019
  const session = host.sessions[sessionId];
1023
1020
  if (!session || mcpAuthentications.has(session))
1024
1021
  return;
1025
- const requestedServers = new Set(mcpServers.map((server) => server.name));
1026
1022
  const authentication = authenticateMcpServers(host, sessionId, session.query, requestedServers)
1027
1023
  .catch((error) => {
1028
1024
  if (!session.abortController.signal.aborted) {
@@ -1161,7 +1157,14 @@ export class ClaudeAcpAgent {
1161
1157
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
1162
1158
  * tests can shrink it. */
1163
1159
  forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
1164
- 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;
1165
1168
  this.sessions = {};
1166
1169
  this.client = new ChangedMetaClient(client, () => this.toolCallCapabilities.air.client);
1167
1170
  this.logger = logger ?? console;
@@ -1218,7 +1221,9 @@ export class ClaudeAcpAgent {
1218
1221
  }
1219
1222
  async initialize(request) {
1220
1223
  this.clientCapabilities = request.clientCapabilities;
1221
- this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities);
1224
+ this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities, {
1225
+ v2: this.v2,
1226
+ });
1222
1227
  // Learn the auth identity in the background: `initialize` never waits on
1223
1228
  // the CLI probe, and no snapshot rides in its response. When the probe
1224
1229
  // lands it calls `setAuthStatus`, which pushes `_auth/status_update` — the
@@ -1402,6 +1407,56 @@ export class ClaudeAcpAgent {
1402
1407
  }, 0);
1403
1408
  return response;
1404
1409
  }
1410
+ /**
1411
+ * Start the CLI of a loaded session, and replay the history while the CLI
1412
+ * starts. The CLI start takes most of the load time, and the replay needs
1413
+ * only the transcript and the {@link ReplayState}. An invalid `cwd` fails
1414
+ * before the first update. A failed CLI start stops the replay.
1415
+ */
1416
+ async createSessionWhileReplaying(params, timing) {
1417
+ await this.validateCwd(params.cwd);
1418
+ const pending = {
1419
+ state: {
1420
+ cwd: params.cwd,
1421
+ taskState: new Map(),
1422
+ forwardSubagentText: this.forwardsSubagentText(params._meta),
1423
+ messageIdToUuid: new Map(),
1424
+ sessionFailureState: createSessionFailureState(),
1425
+ },
1426
+ stopped: false,
1427
+ };
1428
+ const transcript = readResumedSession(params.sessionId, this.logger);
1429
+ const replay = transcript
1430
+ .then(({ messages }) => this.replaySessionHistory(params.sessionId, messages, pending))
1431
+ .then(() => ({ ok: true }), (error) => ({ ok: false, error }));
1432
+ let response;
1433
+ try {
1434
+ response = await this.createSession({
1435
+ cwd: params.cwd,
1436
+ mcpServers: params.mcpServers ?? [],
1437
+ additionalDirectories: params.additionalDirectories,
1438
+ _meta: params._meta,
1439
+ }, {
1440
+ resume: params.sessionId,
1441
+ resumedModelHint: transcript.then(({ model }) => model),
1442
+ replayState: pending.state,
1443
+ });
1444
+ }
1445
+ catch (error) {
1446
+ pending.stopped = true;
1447
+ await replay;
1448
+ throw error;
1449
+ }
1450
+ timing.phase("session-ready");
1451
+ const replayed = await replay;
1452
+ if (!replayed.ok)
1453
+ throw replayed.error;
1454
+ return {
1455
+ sessionId: response.sessionId,
1456
+ modes: response.modes,
1457
+ configOptions: response.configOptions,
1458
+ };
1459
+ }
1405
1460
  async unstable_forkSession(params) {
1406
1461
  if (this.providerUpdate)
1407
1462
  await this.providerUpdate;
@@ -1426,10 +1481,16 @@ export class ClaudeAcpAgent {
1426
1481
  const timing = new SessionTiming(this.logger, "load", params.sessionId);
1427
1482
  if (this.providerUpdate)
1428
1483
  await this.providerUpdate;
1429
- const resumedSession = await readResumedSession(params.sessionId, this.logger);
1430
- const result = await this.getOrCreateSession(params, resumedSession);
1431
- timing.phase("session-ready");
1432
- await this.replaySessionHistory(params.sessionId, resumedSession.messages);
1484
+ let result;
1485
+ if (this.sessions[params.sessionId]) {
1486
+ const resumedSession = await readResumedSession(params.sessionId, this.logger);
1487
+ result = await this.getOrCreateSession(params, resumedSession);
1488
+ timing.phase("session-ready");
1489
+ await this.replaySessionHistory(params.sessionId, resumedSession.messages);
1490
+ }
1491
+ else {
1492
+ result = await this.createSessionWhileReplaying(params, timing);
1493
+ }
1433
1494
  timing.phase("replay");
1434
1495
  // Send available commands after replay so it doesn't interleave with history
1435
1496
  setTimeout(() => {
@@ -1812,6 +1873,47 @@ export class ClaudeAcpAgent {
1812
1873
  this.setAuthStatus(notLoggedInAuthStatus());
1813
1874
  }
1814
1875
  }
1876
+ /** The structured replacement for a prompt that is exactly `/usage` or a
1877
+ * `/mcp` command. Claude Code runs the command, and the replacement takes
1878
+ * the place of its text. Claude Code refuses a `/mcp` reconnect, enable,
1879
+ * or disable in SDK mode. The `/mcp` producer runs them through the SDK
1880
+ * control API when the text arrives, so the action stays in the turn. */
1881
+ localCommandMarkdown(params) {
1882
+ const text = params.prompt.length === 1 && params.prompt[0]?.type === "text"
1883
+ ? params.prompt[0].text
1884
+ : undefined;
1885
+ if (text === undefined)
1886
+ return undefined;
1887
+ if (isUsageCommandText(text)) {
1888
+ return {
1889
+ startsAtActivation: true,
1890
+ produce: (query, signal) => structuredUsageMarkdown(query, signal, this.logger),
1891
+ };
1892
+ }
1893
+ const command = parseMcpCommand(text);
1894
+ if (!command)
1895
+ return undefined;
1896
+ let reconnected = [];
1897
+ return {
1898
+ startsAtActivation: false,
1899
+ produce: async (query, signal, originalOutput) => {
1900
+ const outcome = await runMcpCommand(query, command, originalOutput, signal, (message) => this.logger.error(message));
1901
+ reconnected = outcome.reconnected;
1902
+ return outcome.markdown;
1903
+ },
1904
+ // A reconnect starts OAuth for the ACP servers that it tried, as the
1905
+ // session start does. A plain `/mcp` and a cancelled turn start none.
1906
+ afterDelivery: () => {
1907
+ const session = this.sessions[params.sessionId];
1908
+ if (!session || session.cancelled)
1909
+ return;
1910
+ const acpServers = new Set((session.creationParams?.mcpServers ?? []).map((server) => server.name));
1911
+ const servers = new Set(reconnected.filter((server) => acpServers.has(server)));
1912
+ if (servers.size > 0)
1913
+ startMcpServerAuthentication(this, params.sessionId, servers);
1914
+ },
1915
+ };
1916
+ }
1815
1917
  /**
1816
1918
  * Serves an ACP v1 `session/prompt`, which answers when the turn ends. The
1817
1919
  * outcome of a turn is field for field a v1 prompt response.
@@ -1878,10 +1980,8 @@ export class ClaudeAcpAgent {
1878
1980
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
1879
1981
  const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
1880
1982
  const fileChangeReport = session.fileChangeReporter?.request(params._meta);
1881
- const isUsageCommand = params.prompt.length === 1 &&
1882
- params.prompt[0]?.type === "text" &&
1883
- isUsageCommandText(params.prompt[0].text);
1884
- const usageMarkdownAbort = isUsageCommand ? new AbortController() : undefined;
1983
+ const localCommand = this.localCommandMarkdown(params);
1984
+ const localCommandAbort = localCommand ? new AbortController() : undefined;
1885
1985
  session.titles.onPrompt(params.prompt);
1886
1986
  // Each prompt is a Turn that the persistent consumer settles once the
1887
1987
  // turn's outcome is known. `startTurn()` owns no loop: it enqueues the
@@ -1892,8 +1992,8 @@ export class ClaudeAcpAgent {
1892
1992
  promptUuid,
1893
1993
  events,
1894
1994
  isLocalOnlyCommand,
1895
- ...(isUsageCommand ? { isUsageCommand: true } : {}),
1896
- ...(usageMarkdownAbort ? { usageMarkdownAbort } : {}),
1995
+ ...(localCommand ? { localCommand } : {}),
1996
+ ...(localCommandAbort ? { localCommandAbort } : {}),
1897
1997
  ...(fileChangeReport ? { fileChangeReport } : {}),
1898
1998
  settled: false,
1899
1999
  completion: new Promise((resolve) => {
@@ -1903,8 +2003,8 @@ export class ClaudeAcpAgent {
1903
2003
  events.ended(outcome);
1904
2004
  completeTurn();
1905
2005
  },
1906
- reject: (error) => {
1907
- events.failed(error);
2006
+ reject: (error, title) => {
2007
+ events.failed(error, title);
1908
2008
  completeTurn();
1909
2009
  },
1910
2010
  };
@@ -2015,7 +2115,10 @@ export class ClaudeAcpAgent {
2015
2115
  try {
2016
2116
  // `resume` names the Claude session, which shares the ACP session id, so
2017
2117
  // the new query continues the same conversation under the same id.
2018
- await this.createSession(creationParams, { resume: sessionId });
2118
+ await this.createSession(creationParams, {
2119
+ resume: sessionId,
2120
+ permissionMode: session.modes.currentModeId,
2121
+ });
2019
2122
  }
2020
2123
  catch (error) {
2021
2124
  if (error instanceof RequestError && error.code === RequestError.resourceNotFound().code) {
@@ -2025,7 +2128,10 @@ export class ClaudeAcpAgent {
2025
2128
  // better answer than a session the client can never use again.
2026
2129
  this.logger.log(`session ${sessionId} was never persisted; starting a fresh query under the same id`);
2027
2130
  try {
2028
- await this.createSession(creationParams, { reuseSessionId: sessionId });
2131
+ await this.createSession(creationParams, {
2132
+ reuseSessionId: sessionId,
2133
+ permissionMode: session.modes.currentModeId,
2134
+ });
2029
2135
  return;
2030
2136
  }
2031
2137
  catch (fallbackError) {
@@ -2487,11 +2593,11 @@ export class ClaudeAcpAgent {
2487
2593
  };
2488
2594
  };
2489
2595
  const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
2490
- const ensureUsageMarkdown = (turn) => {
2491
- if (!turn.isUsageCommand || !turn.usageMarkdownAbort)
2596
+ const ensureLocalCommandMarkdown = (turn, originalOutput = "") => {
2597
+ if (!turn.localCommand || !turn.localCommandAbort)
2492
2598
  return undefined;
2493
- turn.usageMarkdown ??= structuredUsageMarkdown(session.query, turn.usageMarkdownAbort.signal, this.logger);
2494
- return turn.usageMarkdown;
2599
+ turn.localCommandMarkdown ??= turn.localCommand.produce(session.query, turn.localCommandAbort.signal, originalOutput);
2600
+ return turn.localCommandMarkdown;
2495
2601
  };
2496
2602
  const resetTurnScratch = () => {
2497
2603
  lastAssistantTotalUsage = null;
@@ -2534,7 +2640,8 @@ export class ClaudeAcpAgent {
2534
2640
  session.activeTurn = turn;
2535
2641
  session.cancelled = false;
2536
2642
  compaction.resume();
2537
- ensureUsageMarkdown(turn);
2643
+ if (turn.localCommand?.startsAtActivation)
2644
+ ensureLocalCommandMarkdown(turn);
2538
2645
  session.pendingOrphanResults = 0;
2539
2646
  session.orphanCommands?.clear();
2540
2647
  // Two-phase sweep of registry entries the level signal ended (see
@@ -2562,18 +2669,23 @@ export class ClaudeAcpAgent {
2562
2669
  }
2563
2670
  }
2564
2671
  resetTurnScratch();
2565
- // Activation is when Claude Code takes the prompt in: its echo, or for an
2566
- // echo-less command, its result. That inserts it into the conversation.
2567
- // A turn can be settled by now: an echo hand-off awaits settling the
2568
- // previous turn, and a cancel in that window settles the queued one.
2569
- if (!turn.insertedReported && !turn.settled) {
2570
- turn.insertedReported = true;
2571
- turn.events.inserted(turn.promptUuid);
2572
- }
2672
+ // Without a command_lifecycle "started" frame before it (see that
2673
+ // handler), activation is the first sign that Claude Code took the
2674
+ // prompt in: its echo, or for an echo-less command, its result.
2675
+ reportInserted(turn);
2573
2676
  // A request can already be open: Claude Code asks for a queued prompt
2574
2677
  // before the consumer reaches its echo.
2575
2678
  this.syncAwaitingUser(session);
2576
2679
  };
2680
+ /** Report that Claude Code took the turn's prompt in, once. A turn can be
2681
+ * settled by now: an echo hand-off awaits settling the previous turn, and
2682
+ * a cancel in that window settles the queued one. */
2683
+ const reportInserted = (turn) => {
2684
+ if (turn.insertedReported || turn.settled)
2685
+ return;
2686
+ turn.insertedReported = true;
2687
+ turn.events.inserted(turn.promptUuid);
2688
+ };
2577
2689
  /** Ensure there is an active turn before a user-turn result that carries no
2578
2690
  * echo to activate it, by promoting the queue head. Most turns are
2579
2691
  * activated by their replayed user message before their result, but some
@@ -2732,27 +2844,28 @@ export class ClaudeAcpAgent {
2732
2844
  * the autonomous stretch-close guard. */
2733
2845
  const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
2734
2846
  /** Claim the structured replacement for the turn currently producing a
2735
- * local-command output. Undefined means this is not a structured usage
2736
- * turn (or its request failed), null means another SDK message shape
2737
- * already delivered it, and string is the one replacement to publish. */
2738
- const takeUsageMarkdown = async (originalOutput) => {
2847
+ * local-command output. Undefined means this is not a structured
2848
+ * local-command turn (or its request failed), null means another SDK
2849
+ * message shape already delivered it, and string is the one replacement
2850
+ * to publish. */
2851
+ const takeLocalCommandMarkdown = async (originalOutput) => {
2739
2852
  const turn = session.activeTurn ?? firstUnsettledQueuedTurn();
2740
2853
  if (!turn)
2741
2854
  return undefined;
2742
- const usageMarkdown = ensureUsageMarkdown(turn);
2743
- if (!usageMarkdown)
2855
+ const pending = ensureLocalCommandMarkdown(turn, originalOutput);
2856
+ if (!pending)
2744
2857
  return undefined;
2745
- const markdown = await usageMarkdown;
2858
+ const markdown = await pending;
2746
2859
  if (markdown === null)
2747
2860
  return undefined;
2748
- if (turn.usageMarkdownDelivered) {
2861
+ if (turn.localCommandDelivered) {
2749
2862
  // Different SDK message shapes can mirror the same local-command
2750
2863
  // output. Suppress an exact mirror, but let a later, distinct frame
2751
2864
  // (for example an interruption diagnostic) follow the normal path.
2752
- return turn.usageOriginalOutput === originalOutput ? null : undefined;
2865
+ return turn.localCommandOriginalOutput === originalOutput ? null : undefined;
2753
2866
  }
2754
- turn.usageMarkdownDelivered = true;
2755
- turn.usageOriginalOutput = originalOutput;
2867
+ turn.localCommandDelivered = true;
2868
+ turn.localCommandOriginalOutput = originalOutput;
2756
2869
  return markdown;
2757
2870
  };
2758
2871
  /** Registers a live background task (see `liveBackgroundTasks`). A
@@ -2905,7 +3018,9 @@ export class ClaudeAcpAgent {
2905
3018
  // Captured before the settled flip below (isHeldOpen tests !settled).
2906
3019
  const wasHeld = isHeldOpen(turn);
2907
3020
  turn.settled = true;
2908
- turn.usageMarkdownAbort?.abort();
3021
+ turn.localCommandAbort?.abort();
3022
+ if (turn.localCommandDelivered)
3023
+ turn.localCommand?.afterDelivery?.();
2909
3024
  disarmForceCancel(session);
2910
3025
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
2911
3026
  session.activeTurn = null;
@@ -2929,7 +3044,7 @@ export class ClaudeAcpAgent {
2929
3044
  };
2930
3045
  /** Reject the active turn (auth required, error result, …) without tearing
2931
3046
  * down the consumer: the stream continues to idle and later turns proceed. */
2932
- const failActive = (error) => {
3047
+ const failActive = (error, title) => {
2933
3048
  disarmForceCancel(session);
2934
3049
  const turn = session.activeTurn;
2935
3050
  if (!turn || turn.settled) {
@@ -2946,7 +3061,7 @@ export class ClaudeAcpAgent {
2946
3061
  // start the next stretch clean, or its stale delivery record would
2947
3062
  // suppress the next turn's issue-#453 result-text fallback.
2948
3063
  session.emittedAssistantText = false;
2949
- turn.reject(error);
3064
+ turn.reject(error, title);
2950
3065
  };
2951
3066
  /** Complete a negotiated terminal failure on the prompt response itself,
2952
3067
  * which is the canonical AIR carrier. Legacy clients keep the historical
@@ -2970,7 +3085,7 @@ export class ClaudeAcpAgent {
2970
3085
  return;
2971
3086
  }
2972
3087
  if (!supportsAirSessionFailures(this.clientCapabilities)) {
2973
- failActive(error);
3088
+ failActive(error, title);
2974
3089
  return;
2975
3090
  }
2976
3091
  if (!session.activeTurn || session.activeTurn.settled) {
@@ -2980,7 +3095,7 @@ export class ClaudeAcpAgent {
2980
3095
  }
2981
3096
  const failure = await createSessionFailure(kind, { title });
2982
3097
  if (!failure) {
2983
- failActive(error);
3098
+ failActive(error, title);
2984
3099
  return;
2985
3100
  }
2986
3101
  sessionFailures.recordActive(failure);
@@ -3030,11 +3145,18 @@ export class ClaudeAcpAgent {
3030
3145
  .then((result) => ({ kind: "message", result }));
3031
3146
  const nextMessage = pendingNext;
3032
3147
  // Fresh abort listener per iteration, removed when next() wins, so a
3033
- // long-lived session doesn't accumulate listeners on one signal.
3148
+ // long-lived session doesn't accumulate listeners on one signal. An
3149
+ // abort that fired while the consumer was busy elsewhere, such as
3150
+ // sending an update, still wakes it: a listener added to an aborted
3151
+ // signal never fires. The consumer re-arms after each abort it
3152
+ // handles, so an aborted signal here is always an unhandled abort.
3034
3153
  let onAbort;
3035
3154
  const abortRace = new Promise((resolve) => {
3036
3155
  onAbort = () => resolve("abort");
3037
- cancelController.signal.addEventListener("abort", onAbort, { once: true });
3156
+ if (cancelController.signal.aborted)
3157
+ onAbort();
3158
+ else
3159
+ cancelController.signal.addEventListener("abort", onAbort, { once: true });
3038
3160
  });
3039
3161
  const raced = await Promise.race([nextMessage, abortRace]);
3040
3162
  cancelController.signal.removeEventListener("abort", onAbort);
@@ -3084,7 +3206,7 @@ export class ClaudeAcpAgent {
3084
3206
  }
3085
3207
  }
3086
3208
  await compaction.interrupt();
3087
- await settleActive(turnOutcome(session, "cancelled"));
3209
+ await settleActive(cancelledOutcome(session, session.activeTurn));
3088
3210
  // The cancelled turn's result may never come (that's why the
3089
3211
  // backstop fired) — close its delivery stretch here so partial
3090
3212
  // streamed text can't suppress the next turn's issue-#453 fallback.
@@ -3140,7 +3262,7 @@ export class ClaudeAcpAgent {
3140
3262
  const inFlight = session.activeTurn;
3141
3263
  await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "at end of stream");
3142
3264
  await settleActive(session.cancelled
3143
- ? turnOutcome(session, "cancelled")
3265
+ ? cancelledOutcome(session, inFlight)
3144
3266
  : (inFlight?.deferredSettle ?? turnOutcome(session, stopReason)));
3145
3267
  // Queued turns the SDK never started never ran, so reject them rather
3146
3268
  // than reporting a success (end_turn) — or a misleading "cancelled" —
@@ -3175,10 +3297,10 @@ export class ClaudeAcpAgent {
3175
3297
  // prompt() stamps a uuid on every message. The frame is @internal and
3176
3298
  // absent from the SDKMessage union, so handle it BEFORE the exhaustive
3177
3299
  // switch: it must not reach `unreachable`'s error log, and a `case`
3178
- // for it wouldn't typecheck. It feeds only the orphan accounting (see
3179
- // Session.orphanCommands); turn settlement stays driven by
3180
- // echoes/results/idle. (Raw-mode emission above still forwards these
3181
- // frames.)
3300
+ // for it wouldn't typecheck. It feeds the orphan accounting (see
3301
+ // Session.orphanCommands) and reports a turn inserted at "started";
3302
+ // turn activation and settlement stay driven by echoes/results/idle.
3303
+ // (Raw-mode emission above still forwards these frames.)
3182
3304
  if (message.type === "command_lifecycle") {
3183
3305
  const frame = message;
3184
3306
  switch (frame.state) {
@@ -3189,6 +3311,17 @@ export class ClaudeAcpAgent {
3189
3311
  if (queued) {
3190
3312
  queued.commandStarted = true;
3191
3313
  compaction.resume();
3314
+ // Claude Code took the prompt in: it drained into a turn. A
3315
+ // fresh turn reports that before anything it produces, its echo
3316
+ // and first stream events included, so this is the earliest
3317
+ // insertion point. (A prompt folded into a running turn reports
3318
+ // it after its echo, which already activated it.) While another
3319
+ // turn is still active (held for background work, steered, or
3320
+ // cancelled and awaiting its trailing idle), insertion waits
3321
+ // for this turn's echo, whose hand-off ends that turn first.
3322
+ const active = session.activeTurn;
3323
+ if (!active || active.settled || active === queued)
3324
+ reportInserted(queued);
3192
3325
  }
3193
3326
  // ...and promote an already-orphaned command: once dispatched,
3194
3327
  // a bare `cancelled` no longer means "dropped without running".
@@ -3365,17 +3498,18 @@ export class ClaudeAcpAgent {
3365
3498
  if (compaction.consumeDuplicateErrorOutput(message.content)) {
3366
3499
  break;
3367
3500
  }
3368
- const usageTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
3369
- const usageMarkdown = await takeUsageMarkdown(message.content);
3370
- if (usageTurn?.isUsageCommand && session.cancelled)
3501
+ const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
3502
+ const commandMarkdown = await takeLocalCommandMarkdown(message.content);
3503
+ if (commandTurn?.localCommand && session.cancelled)
3371
3504
  break;
3372
- if (usageMarkdown === null)
3505
+ if (commandMarkdown === null)
3373
3506
  break;
3374
3507
  await sendUpdate({
3375
3508
  sessionId: params.sessionId,
3376
3509
  update: {
3377
3510
  sessionUpdate: "agent_message_chunk",
3378
- content: { type: "text", text: usageMarkdown ?? message.content },
3511
+ content: { type: "text", text: commandMarkdown ?? message.content },
3512
+ messageId: messageIdForGrouping(message),
3379
3513
  },
3380
3514
  });
3381
3515
  break;
@@ -3432,7 +3566,13 @@ export class ClaudeAcpAgent {
3432
3566
  // the interrupted turn's tokens entirely (issue #844). Zero
3433
3567
  // when the cancel pre-empted the result (wedge/force-cancel).
3434
3568
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
3435
- await settleActive(turnOutcome(session, "cancelled"));
3569
+ // A held turn's result passed long ago; this idle is the
3570
+ // trailer of the followup cycle the cancel interrupted,
3571
+ // whose result (an autonomous one) recorded it as owed.
3572
+ if (isHeldOpen(session.activeTurn) && session.owedTrailingIdles > 0) {
3573
+ session.owedTrailingIdles--;
3574
+ }
3575
+ await settleActive(cancelledOutcome(session, session.activeTurn));
3436
3576
  // An interrupt can pre-empt the turn's result entirely
3437
3577
  // (nothing ran the result-case `finally`), so close the
3438
3578
  // delivery stretch here: idle is the SDK's authoritative
@@ -3534,11 +3674,12 @@ export class ClaudeAcpAgent {
3534
3674
  // list with this payload. Forward message.commands directly —
3535
3675
  // it's authoritative, and re-querying supportedCommands()
3536
3676
  // would just return the same list with an extra round-trip.
3677
+ session.skillPaths = new Map();
3537
3678
  await sendUpdate({
3538
3679
  sessionId: params.sessionId,
3539
3680
  update: {
3540
3681
  sessionUpdate: "available_commands_update",
3541
- availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
3682
+ availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, session.skillPaths),
3542
3683
  },
3543
3684
  });
3544
3685
  break;
@@ -4310,6 +4451,7 @@ export class ClaudeAcpAgent {
4310
4451
  update: {
4311
4452
  sessionUpdate: "agent_message_chunk",
4312
4453
  content: { type: "text", text: lastRefusalExplanation },
4454
+ messageId: messageIdForGrouping(message),
4313
4455
  },
4314
4456
  });
4315
4457
  }
@@ -4384,10 +4526,13 @@ export class ClaudeAcpAgent {
4384
4526
  (message.usage.output_tokens ?? 0) === 0 &&
4385
4527
  !session.activeTurn?.noticeTexts?.includes(normalizeNoticeText(message.result)));
4386
4528
  if (shouldForwardResult) {
4387
- const usageMarkdown = await takeUsageMarkdown(message.result);
4388
- if (usageMarkdown === null)
4529
+ const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
4530
+ const commandMarkdown = await takeLocalCommandMarkdown(message.result);
4531
+ if (commandTurn?.localCommand && session.cancelled)
4389
4532
  break;
4390
- for (const notification of toAcpNotifications(usageMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
4533
+ if (commandMarkdown === null)
4534
+ break;
4535
+ for (const notification of toAcpNotifications(commandMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4391
4536
  await sendUpdate(notification);
4392
4537
  }
4393
4538
  }
@@ -4600,6 +4745,7 @@ export class ClaudeAcpAgent {
4600
4745
  }
4601
4746
  for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, this.client, this.logger, {
4602
4747
  clientCapabilities: this.clientCapabilities,
4748
+ toolCallCapabilities: this.toolCallCapabilities,
4603
4749
  cwd: session.cwd,
4604
4750
  taskState: session.taskState,
4605
4751
  emittedToolCalls: session.emittedToolCalls,
@@ -4669,7 +4815,7 @@ export class ClaudeAcpAgent {
4669
4815
  session.owedTrailingIdles++;
4670
4816
  // Before activateTurn resets the accumulator, so the
4671
4817
  // usage still belongs to the cancelled turn.
4672
- await settleActive(turnOutcome(session, "cancelled"));
4818
+ await settleActive(cancelledOutcome(session, session.activeTurn));
4673
4819
  }
4674
4820
  else if (isHeldOpen(session.activeTurn)) {
4675
4821
  // A turn held open for its background subagents (see
@@ -4780,16 +4926,16 @@ export class ClaudeAcpAgent {
4780
4926
  // Depending on the Claude Code build, a local command can arrive
4781
4927
  // as the dedicated system message above or as a synthetic
4782
4928
  // assistant message. Replace only the output owned by the exact
4783
- // /usage turn; no content signatures or text parsing are involved.
4929
+ // /usage or /mcp turn; no content signatures or text parsing are involved.
4784
4930
  if (message.type === "assistant" &&
4785
4931
  message.parent_tool_use_id === null &&
4786
4932
  message.message.model === "<synthetic>") {
4787
- const usageMarkdown = await takeUsageMarkdown(assistantMessageText(message.message) ?? "");
4933
+ const commandMarkdown = await takeLocalCommandMarkdown(assistantMessageText(message.message) ?? "");
4788
4934
  if (session.cancelled)
4789
4935
  break;
4790
- if (usageMarkdown !== undefined) {
4791
- if (usageMarkdown !== null) {
4792
- for (const notification of toAcpNotifications(usageMarkdown, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4936
+ if (commandMarkdown !== undefined) {
4937
+ if (commandMarkdown !== null) {
4938
+ for (const notification of toAcpNotifications(commandMarkdown, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4793
4939
  await sendUpdate(notification);
4794
4940
  }
4795
4941
  }
@@ -4803,6 +4949,7 @@ export class ClaudeAcpAgent {
4803
4949
  if (typeof stripped === "string") {
4804
4950
  for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
4805
4951
  clientCapabilities: this.clientCapabilities,
4952
+ toolCallCapabilities: this.toolCallCapabilities,
4806
4953
  parentToolUseId: message.parent_tool_use_id,
4807
4954
  cwd: session.cwd,
4808
4955
  taskState: session.taskState,
@@ -4884,6 +5031,7 @@ export class ClaudeAcpAgent {
4884
5031
  }
4885
5032
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
4886
5033
  clientCapabilities: this.clientCapabilities,
5034
+ toolCallCapabilities: this.toolCallCapabilities,
4887
5035
  parentToolUseId: message.parent_tool_use_id,
4888
5036
  cwd: session.cwd,
4889
5037
  taskState: session.taskState,
@@ -5073,6 +5221,36 @@ export class ClaudeAcpAgent {
5073
5221
  session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
5074
5222
  }
5075
5223
  }
5224
+ /** Ask Claude Code to drop user messages it still has queued, so that a
5225
+ * prompt a cancel settled is not run after all. Claude Code keeps queued
5226
+ * messages through an interrupt and runs them next (the receipt's
5227
+ * `still_queued`), unless they are cancelled first with
5228
+ * `cancel_async_message`: a no-op for a message it already dequeued, which
5229
+ * then still runs, as an orphan (see `orphanCommands`).
5230
+ *
5231
+ * Not awaited: the SDK writes control requests in call order, so the drops
5232
+ * reach Claude Code before a request sent after them, and a wedged CLI must
5233
+ * not hold up the cancel. The orphan accounting needs no update here: a
5234
+ * dropped message gets a `cancelled` lifecycle frame and is missing from the
5235
+ * interrupt receipt's `still_queued`, which both already account for. The
5236
+ * SDK implements the request as `Query.cancelAsyncMessage` without typing
5237
+ * it, so it is looked up, and a CLI that lacks it rejects the request. */
5238
+ dropQueuedMessages(sessionId, query, uuids) {
5239
+ const cancelAsyncMessage = query
5240
+ .cancelAsyncMessage;
5241
+ if (typeof cancelAsyncMessage !== "function")
5242
+ return;
5243
+ const logFailure = (uuid, error) => this.logger.error(`Session ${sessionId}: could not drop the queued message ${uuid} from Claude Code:`, error);
5244
+ for (const uuid of uuids) {
5245
+ // Called synchronously: deferring it would let the interrupt go first.
5246
+ try {
5247
+ Promise.resolve(cancelAsyncMessage.call(query, uuid)).catch((error) => logFailure(uuid, error));
5248
+ }
5249
+ catch (error) {
5250
+ logFailure(uuid, error);
5251
+ }
5252
+ }
5253
+ }
5076
5254
  async cancel(params) {
5077
5255
  await this.cancelTurns(params, { awaitInterrupt: true });
5078
5256
  }
@@ -5089,7 +5267,7 @@ export class ClaudeAcpAgent {
5089
5267
  }
5090
5268
  session.cancelled = true;
5091
5269
  for (const turn of session.turnQueue ?? [])
5092
- turn.usageMarkdownAbort?.abort();
5270
+ turn.localCommandAbort?.abort();
5093
5271
  session.pendingExitPlanModeInterruption = undefined;
5094
5272
  session.pendingExitPlanContextReset = undefined;
5095
5273
  // The stream already ended (see closeQueryStream): every in-flight turn was
@@ -5118,12 +5296,16 @@ export class ClaudeAcpAgent {
5118
5296
  session.toolCallFields?.clear();
5119
5297
  clearHookCallbacks(params.sessionId);
5120
5298
  }
5299
+ // The user messages this cancel abandons while Claude Code may still have
5300
+ // them queued: they are dropped from its queue before the interrupt.
5301
+ const abandoned = [];
5121
5302
  // A priority steer may still be queued in the SDK when cancellation
5122
5303
  // settles its owning turn. Its later echo matches no live turn, and its
5123
5304
  // result must be skipped rather than promoted onto the next prompt.
5124
5305
  if (isSteering(session.activeTurn)) {
5125
5306
  for (const uuid of session.activeTurn.steeredEchoes) {
5126
5307
  this.trackOrphanCommand(session, uuid, "pending");
5308
+ abandoned.push(uuid);
5127
5309
  }
5128
5310
  }
5129
5311
  // Capture the orphan-accounting lane before anything can await: the
@@ -5147,11 +5329,13 @@ export class ClaudeAcpAgent {
5147
5329
  // accumulator (the active turn's tally) is not its spend.
5148
5330
  turn.resolve({ stopReason: "cancelled" });
5149
5331
  orphanedTurns.push(turn);
5332
+ abandoned.push(turn.promptUuid);
5150
5333
  }
5151
5334
  }
5152
- // Each removed queued turn's user message was already pushed to the SDK,
5153
- // which processes input FIFO and will still emit a result for it with no
5154
- // user echo to match (0.3.246+ CLIs do stamp results with the
5335
+ // Each removed queued turn's user message was already pushed to the SDK.
5336
+ // It is dropped from Claude Code's queue below (see dropQueuedMessages),
5337
+ // but one Claude Code already dequeued still runs, FIFO, and emits a
5338
+ // result with no user echo to match (0.3.246+ CLIs do stamp results with the
5155
5339
  // triggering send's user_message_uuid, which ensureActiveTurn uses as
5156
5340
  // an exact join when present). Track those so the consumer skips them
5157
5341
  // (see ensureActiveTurn) rather than misattributing them to the head.
@@ -5214,15 +5398,23 @@ export class ClaudeAcpAgent {
5214
5398
  }
5215
5399
  // A deferred active turn (see Turn.deferredSettle) already has its result
5216
5400
  // and is only held open for subagents. A settling turn likewise has its
5217
- // result but is awaiting the bounded checkpoint preview. Settle either one
5218
- // "cancelled" NOW: the consumer cannot process the interrupt's trailing
5219
- // idle while blocked in that preview, and a held turn may already be idle.
5220
- // The captured outcome preserves usage and metadata (issue #844), while
5221
- // reporter state makes a late checkpoint response harmless.
5401
+ // result but is awaiting the bounded checkpoint preview. Settle it
5402
+ // "cancelled" NOW where the consumer cannot: it cannot process the
5403
+ // interrupt's trailing idle while blocked in that preview, and a held turn
5404
+ // whose session sits idle gets no trailing idle (the interrupt emits
5405
+ // nothing; with no state events consumed yet, the same is assumed).
5406
+ // A held turn whose followup cycle is live (running, or blocked on a
5407
+ // permission request) is left to that idle, like any live turn: the
5408
+ // interrupt still flushes the cycle's output, which must reach the client
5409
+ // before the turn ends cancelled. The captured outcome preserves usage and
5410
+ // metadata (issue #844), while reporter state makes a late checkpoint
5411
+ // response harmless.
5222
5412
  {
5223
5413
  const active = session.activeTurn;
5224
- const pendingOutcome = active?.deferredSettle ?? active?.settlingOutcome;
5225
- if (active && pendingOutcome && (isHeldOpen(active) || active.settling)) {
5414
+ const followupLive = session.lastSessionState === "running" || session.lastSessionState === "requires_action";
5415
+ if (active &&
5416
+ (active.settling || (isHeldOpen(active) && !followupLive)) &&
5417
+ (active.deferredSettle ?? active.settlingOutcome)) {
5226
5418
  session.fileChangeReporter?.finish(active.fileChangeReport, "cancelled");
5227
5419
  active.settled = true;
5228
5420
  // Mirror settleActive's invariants (it is consumer-scoped and
@@ -5236,17 +5428,16 @@ export class ClaudeAcpAgent {
5236
5428
  // text since the last boundary was its followups', and left latched
5237
5429
  // it would suppress a following replayed turn's issue-#453 fallback.
5238
5430
  session.emittedAssistantText = false;
5239
- // When the interrupt below pre-empts a live cycle — running, or
5240
- // blocked on a permission request (requires_action, the #866 shape
5241
- // users cancel out of) — it produces a trailer idle with no counted
5242
- // result; with the hold's own trailer typically already absorbed,
5243
- // that idle would be un-owed and could lag past the next prompt's
5244
- // echo — read as the fresh turn ending without a result (issue #825
5245
- // false-fail). Pre-count it unless the session sits idle: there the
5246
- // interrupt emits nothing, and a debt that never drains would mask
5247
- // one future #825 detection. (lastSessionState is last-CONSUMED, so
5248
- // both stale reads exist and both are accepted one-cycle windows: a
5249
- // running transition still in the backlog reads as stale idle and
5431
+ // When the interrupt below pre-empts a live cycle (a settling turn's,
5432
+ // or one no state event reported), it produces a trailer idle with no
5433
+ // counted result; that idle would be un-owed and could lag past the
5434
+ // next prompt's echo — read as the fresh turn ending without a result
5435
+ // (issue #825 false-fail).
5436
+ // Pre-count it unless the session sits idle: there the interrupt
5437
+ // emits nothing, and a debt that never drains would mask one future
5438
+ // #825 detection. (lastSessionState is last-CONSUMED, so both stale
5439
+ // reads exist and both are accepted one-cycle windows: a running
5440
+ // transition still in the backlog reads as stale idle and
5250
5441
  // under-counts — that false-fail additionally needs the trailer to
5251
5442
  // lag past the next echo — while a cycle already completed into the
5252
5443
  // backlog reads as stale non-idle and over-counts, masking one
@@ -5256,14 +5447,7 @@ export class ClaudeAcpAgent {
5256
5447
  if (session.lastSessionState !== "idle") {
5257
5448
  session.owedTrailingIdles++;
5258
5449
  }
5259
- // Carries the held outcome's `_meta` too: a deferred outcome's only
5260
- // metadata is its quota breakdown, the counterpart of the usage taken
5261
- // from it here.
5262
- active.resolve({
5263
- stopReason: "cancelled",
5264
- usage: pendingOutcome.usage,
5265
- ...(pendingOutcome._meta ? { _meta: pendingOutcome._meta } : {}),
5266
- });
5450
+ active.resolve(cancelledOutcome(session, active));
5267
5451
  }
5268
5452
  }
5269
5453
  // A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
@@ -5296,6 +5480,9 @@ export class ClaudeAcpAgent {
5296
5480
  cancelController.abort();
5297
5481
  }, this.forceCancelGraceMs);
5298
5482
  }
5483
+ // Before the interrupt: once it stops the running turn, Claude Code starts
5484
+ // the next queued message right away.
5485
+ this.dropQueuedMessages(params.sessionId, session.query, abandoned);
5299
5486
  const interrupt = session.query.interrupt();
5300
5487
  if (!options.awaitInterrupt) {
5301
5488
  // The caller closes the query next, which rejects the pending reply.
@@ -5392,12 +5579,18 @@ export class ClaudeAcpAgent {
5392
5579
  session.query.close();
5393
5580
  }
5394
5581
  /** Cleanly tear down a session: cancel in-flight work, release stream
5395
- * resources, and remove it from the session map. */
5582
+ * resources, and remove it from the session map. Returns once the turns it
5583
+ * cancels have ended, so their end (a v1 `session/prompt` response, a v2
5584
+ * `idle` state) goes out before the caller answers: ACP v2's
5585
+ * `session/close` cancels "as if `session/cancel` had been called", then
5586
+ * frees the session. A wedged consumer holds it at most
5587
+ * {@link TEARDOWN_TURN_END_TIMEOUT_MS}. */
5396
5588
  async teardownSession(sessionId) {
5397
5589
  const session = this.sessions[sessionId];
5398
5590
  if (!session) {
5399
5591
  return;
5400
5592
  }
5593
+ const turnsEnded = Promise.all((session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])));
5401
5594
  try {
5402
5595
  await this.cancelTurns({ sessionId }, { awaitInterrupt: false });
5403
5596
  }
@@ -5425,6 +5618,10 @@ export class ClaudeAcpAgent {
5425
5618
  session.nativeSubagentRuntime?.clear();
5426
5619
  session.asyncTaskRuntime?.clear();
5427
5620
  delete this.sessions[sessionId];
5621
+ const ended = await raceTimeoutAndAbort(turnsEnded, TEARDOWN_TURN_END_TIMEOUT_MS, new AbortController().signal);
5622
+ if (ended.type === "timeout") {
5623
+ this.logger.error(`Session ${sessionId}: its cancelled turns did not end within ${TEARDOWN_TURN_END_TIMEOUT_MS}ms of the teardown`);
5624
+ }
5428
5625
  }
5429
5626
  /** Tear down all active sessions. Called when the ACP connection closes. */
5430
5627
  async dispose() {
@@ -5531,13 +5728,15 @@ export class ClaudeAcpAgent {
5531
5728
  await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
5532
5729
  return { configOptions: session.configOptions };
5533
5730
  }
5534
- async replaySessionHistory(sessionId, resumedMessages) {
5731
+ async replaySessionHistory(sessionId, resumedMessages, pending) {
5535
5732
  const replayStartedAt = performance.now();
5536
5733
  const toolUseCache = {};
5537
5734
  const messages = resumedMessages ?? (await getSessionMessages(sessionId));
5538
5735
  const historyLoadedAt = performance.now();
5539
5736
  this.logger.log(`[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`);
5540
- const session = this.sessions[sessionId];
5737
+ // A pending replay has no session record yet. It uses the state that the record takes later.
5738
+ const replayState = () => pending?.state ?? this.sessions[sessionId];
5739
+ const session = replayState();
5541
5740
  // A replay rebuilds the client view, so its plan goes out again.
5542
5741
  if (session?.taskState)
5543
5742
  forgetPublishedTaskPlan(session.taskState);
@@ -5549,7 +5748,12 @@ export class ClaudeAcpAgent {
5549
5748
  sessionId,
5550
5749
  state: session.sessionFailureState,
5551
5750
  capabilities: this.clientCapabilities,
5552
- isCurrent: () => this.sessions[sessionId] === session,
5751
+ isCurrent: () => {
5752
+ const live = this.sessions[sessionId];
5753
+ if (!live)
5754
+ return pending !== undefined && !pending.stopped;
5755
+ return live.sessionFailureState === session.sessionFailureState;
5756
+ },
5553
5757
  sendUpdate: (notification) => this.client.sessionUpdate(notification),
5554
5758
  logger: this.logger,
5555
5759
  })
@@ -5704,7 +5908,7 @@ export class ClaudeAcpAgent {
5704
5908
  await replayAsyncTasks.taskNotification(notification);
5705
5909
  };
5706
5910
  const replayMessage = async (message) => {
5707
- if (isReplayHiddenMetaMessage(message)) {
5911
+ if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
5708
5912
  return;
5709
5913
  }
5710
5914
  if (message.type === "user" &&
@@ -5718,7 +5922,7 @@ export class ClaudeAcpAgent {
5718
5922
  // a client-supplied id without an extra getSessionMessages read. Not read
5719
5923
  // yet (see Session.messageIdToUuid).
5720
5924
  const replayMessageId = messageIdForGrouping(message);
5721
- const replaySession = this.sessions[sessionId];
5925
+ const replaySession = replayState();
5722
5926
  if (replaySession && replayMessageId && message.uuid) {
5723
5927
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
5724
5928
  }
@@ -5812,8 +6016,9 @@ export class ClaudeAcpAgent {
5812
6016
  registerHooks: false,
5813
6017
  replay: true,
5814
6018
  clientCapabilities: this.clientCapabilities,
5815
- cwd: this.sessions[sessionId]?.cwd,
5816
- taskState: this.sessions[sessionId]?.taskState,
6019
+ toolCallCapabilities: this.toolCallCapabilities,
6020
+ cwd: replayState()?.cwd,
6021
+ taskState: replayState()?.taskState,
5817
6022
  messageId: replayMessageId,
5818
6023
  parentToolUseId,
5819
6024
  })) {
@@ -5858,7 +6063,7 @@ export class ClaudeAcpAgent {
5858
6063
  };
5859
6064
  for (const message of messages)
5860
6065
  await replayMessage(message);
5861
- if (nativeReplayEnabled) {
6066
+ if (nativeReplayEnabled && !pending?.stopped) {
5862
6067
  // Claude history persists sidechain messages and Agent/Task tool uses,
5863
6068
  // but not task_started/task_updated lifecycle frames. Recover terminal
5864
6069
  // state from the launch tool_result. Missing results and malformed or
@@ -5951,8 +6156,10 @@ export class ClaudeAcpAgent {
5951
6156
  // Do not rely on every ACP client settling requestPermission after the
5952
6157
  // cancellation signal. The local race guarantees that Claude's tool call
5953
6158
  // is released even when an older or broken client ignores $/cancel_request.
6159
+ // The request goes to a subagent's child session for a tool call of that
6160
+ // subagent, but it blocks the turn of the owner session.
5954
6161
  try {
5955
- return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
6162
+ return await this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
5956
6163
  }
5957
6164
  catch (error) {
5958
6165
  if (signal.aborted) {
@@ -6062,7 +6269,7 @@ export class ClaudeAcpAgent {
6062
6269
  // Like permission requests, the elicitation references this toolUseID, so
6063
6270
  // make sure the tool_call has surfaced to the client before we send it.
6064
6271
  await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal, permissionSessionId);
6065
- return this.handleAskUserQuestion(permissionSessionId, toolInput, toolUseID, signal);
6272
+ return this.handleAskUserQuestion(sessionId, permissionSessionId, toolInput, toolUseID, signal);
6066
6273
  }
6067
6274
  // Do not auto-allow here based on the session's advertised mode. Claude
6068
6275
  // Code applies bypassPermissions before invoking canUseTool; a request
@@ -6078,8 +6285,8 @@ export class ClaudeAcpAgent {
6078
6285
  const noPersistentRule = matchedAskRule !== undefined || suppressAlwaysAllowRule === true;
6079
6286
  const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, noPersistentRule);
6080
6287
  const capabilities = this.toolCallCapabilities;
6081
- const previewContent = capabilities.diffPatch
6082
- ? await previewPatchContent(toolName, toolInput, session.cwd)
6288
+ const previewContent = capabilities.diffPatch || capabilities.v2
6289
+ ? await previewPatchContent(toolName, toolInput, session.cwd, capabilities.v2 ? "v2" : "air")
6083
6290
  : undefined;
6084
6291
  const presentation = buildClaudePermissionPresentation({
6085
6292
  toolName,
@@ -6216,13 +6423,15 @@ export class ClaudeAcpAgent {
6216
6423
  * Present the built-in AskUserQuestion tool's questions as an ACP form
6217
6424
  * elicitation and return the answers as the tool's `updatedInput`. Called from
6218
6425
  * `canUseTool` since that is where the SDK routes the tool's permission check.
6426
+ * The elicitation goes to `requestSessionId`, a subagent's child session for
6427
+ * a question it asks, and blocks the turn of `sessionId`.
6219
6428
  */
6220
- async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal) {
6429
+ async handleAskUserQuestion(sessionId, requestSessionId, toolInput, toolUseID, signal) {
6221
6430
  const questions = extractAskUserQuestions(toolInput);
6222
6431
  if (!questions) {
6223
6432
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
6224
6433
  }
6225
- const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, this.toolCallCapabilities.air.client);
6434
+ const createRequest = askUserQuestionsToCreateRequest(questions, requestSessionId, toolUseID, this.toolCallCapabilities.air.client);
6226
6435
  let response;
6227
6436
  try {
6228
6437
  response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal));
@@ -6292,7 +6501,7 @@ export class ClaudeAcpAgent {
6292
6501
  sessionId,
6293
6502
  update: {
6294
6503
  sessionUpdate: "available_commands_update",
6295
- availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
6504
+ availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, (session.skillPaths ??= new Map())),
6296
6505
  },
6297
6506
  });
6298
6507
  }
@@ -6400,7 +6609,7 @@ export class ClaudeAcpAgent {
6400
6609
  const seedEffort = effortPinnedForNewModel
6401
6610
  ? pinnedEffort
6402
6611
  : settingsEffortForModel(mergeEffortSettings(session.settingsManager.getSettings(), session.effortSettingsOverride), newModelInfo, value);
6403
- session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, seedEffort, {
6612
+ session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, seedEffort, session.agents, session.currentAgent, {
6404
6613
  // The toggle follows the newly selected model: it disappears when the
6405
6614
  // model lacks fast support and reappears (with the retained user
6406
6615
  // intent) when a supporting model is selected again.
@@ -6479,6 +6688,18 @@ export class ClaudeAcpAgent {
6479
6688
  if (modelChanged)
6480
6689
  this.refreshContextWindowInBackground(sessionId, session);
6481
6690
  }
6691
+ else if (configId === AGENT_CONFIG_ID) {
6692
+ // Live agent switch — no subprocess restart needed. Apply the SDK flag
6693
+ // first so a rejected control request leaves both `currentAgent` and the
6694
+ // config option untouched (no UI/SDK desync). Passing `null` clears the
6695
+ // flag layer back to the standard Claude Code agent; the change takes
6696
+ // effect on the next turn (SDK >= 0.3.161).
6697
+ await session.query.applyFlagSettings({
6698
+ agent: value === DEFAULT_AGENT_ID ? null : value,
6699
+ });
6700
+ session.currentAgent = value;
6701
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
6702
+ }
6482
6703
  else if (configId === EFFORT_CONFIG_ID) {
6483
6704
  // Apply first so a rejected control request cannot leave the displayed
6484
6705
  // value ahead of the SDK flag layer.
@@ -6613,6 +6834,8 @@ export class ClaudeAcpAgent {
6613
6834
  }
6614
6835
  async getOrCreateSession(params, resumedSession) {
6615
6836
  const existingSession = this.sessions[params.sessionId];
6837
+ // A recreated live session keeps its mode, not the mode of the transcript.
6838
+ const livePermissionMode = existingSession?.modes.currentModeId;
6616
6839
  if (existingSession) {
6617
6840
  const fingerprint = computeSessionFingerprint(params);
6618
6841
  if (fingerprint === existingSession.sessionFingerprint) {
@@ -6628,9 +6851,6 @@ export class ClaudeAcpAgent {
6628
6851
  // up the new values.
6629
6852
  await this.teardownSession(params.sessionId);
6630
6853
  }
6631
- const resumedModelHint = resumedSession
6632
- ? resumedSession.model
6633
- : await readResumedModel(params.sessionId, this.logger);
6634
6854
  const response = await this.createSession({
6635
6855
  cwd: params.cwd,
6636
6856
  mcpServers: params.mcpServers ?? [],
@@ -6638,7 +6858,8 @@ export class ClaudeAcpAgent {
6638
6858
  _meta: params._meta,
6639
6859
  }, {
6640
6860
  resume: params.sessionId,
6641
- resumedModelHint,
6861
+ permissionMode: livePermissionMode,
6862
+ ...(resumedSession ? { resumedModelHint: resumedSession.model } : {}),
6642
6863
  });
6643
6864
  return {
6644
6865
  sessionId: response.sessionId,
@@ -6667,6 +6888,12 @@ export class ClaudeAcpAgent {
6667
6888
  throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
6668
6889
  }
6669
6890
  }
6891
+ /** Whether the session forwards the text and the thinking of a subagent. */
6892
+ forwardsSubagentText(meta) {
6893
+ return (clientSupportsSubagents(this.clientCapabilities) ||
6894
+ supportsSubagentTranscript(this.clientCapabilities) ||
6895
+ meta?.claudeCode?.options?.forwardSubagentText === true);
6896
+ }
6670
6897
  async createSession(params, creationOpts = {}) {
6671
6898
  const createStartedAt = performance.now();
6672
6899
  // Validate `cwd` up front. The ACP spec requires an absolute path, and the
@@ -6704,10 +6931,15 @@ export class ClaudeAcpAgent {
6704
6931
  // sign-out respawn, provider rerouting), so fill the same fast local hint
6705
6932
  // here when the caller did not provide it. Never fall back to the slow
6706
6933
  // getContextUsage control request.
6934
+ // The same tail read gives the permission mode of the resumed session.
6707
6935
  let resumedModelHint = creationOpts.resumedModelHint;
6708
- if (creationOpts.resume !== undefined &&
6709
- !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint")) {
6710
- resumedModelHint = await readResumedModel(creationOpts.resume, this.logger);
6936
+ let resumedPermissionMode;
6937
+ if (creationOpts.resume !== undefined) {
6938
+ const withModel = !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint");
6939
+ const tail = await readResumedTail(creationOpts.resume, this.logger, withModel);
6940
+ if (withModel)
6941
+ resumedModelHint = tail.model;
6942
+ resumedPermissionMode = tail.permissionMode;
6711
6943
  timing.phase("resume-transcript");
6712
6944
  }
6713
6945
  const input = new Pushable();
@@ -6769,18 +7001,13 @@ export class ClaudeAcpAgent {
6769
7001
  const allowBypass = ALLOW_BYPASS &&
6770
7002
  settingsManager.getSettings().permissions?.disableBypassPermissionsMode !== "disable" &&
6771
7003
  sessionMeta?.claudeCode?.options?.allowDangerouslySkipPermissions !== false;
6772
- const initialPermissionMode = resolvePermissionMode(creationOpts.permissionMode ?? settingsManager.getSettings().permissions?.defaultMode, this.logger, allowBypass);
6773
- const userProvidedOptions = sessionMeta?.claudeCode?.options
6774
- ? { ...sessionMeta.claudeCode.options }
6775
- : undefined;
6776
- // Main-thread agent selection is intentionally not part of this adapter's
6777
- // ACP contract. Ignore the provider-specific option instead of forwarding
6778
- // hidden state that the Client cannot inspect or change.
6779
- if (userProvidedOptions)
6780
- delete userProvidedOptions.agent;
6781
- const forwardSubagentText = clientSupportsSubagents(this.clientCapabilities) ||
6782
- supportsSubagentTranscript(this.clientCapabilities) ||
6783
- userProvidedOptions?.forwardSubagentText === true;
7004
+ const initialPermissionMode = resolveInitialPermissionMode({
7005
+ explicit: creationOpts.permissionMode,
7006
+ resumed: resumedPermissionMode,
7007
+ defaultMode: settingsManager.getSettings().permissions?.defaultMode,
7008
+ }, this.logger, allowBypass);
7009
+ const userProvidedOptions = sessionMeta?.claudeCode?.options;
7010
+ const forwardSubagentText = this.forwardsSubagentText(params._meta);
6784
7011
  // Configure thinking behavior from environment variable
6785
7012
  const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
6786
7013
  // Parse model configuration from environment (e.g. Bedrock model overrides)
@@ -6806,7 +7033,7 @@ export class ClaudeAcpAgent {
6806
7033
  // Per-session task state. Created here (rather than in the session record
6807
7034
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
6808
7035
  // the same Map that the streaming message handler will read from.
6809
- const taskState = new Map();
7036
+ const taskState = creationOpts.replayState?.taskState ?? new Map();
6810
7037
  // Resolve every workspace root once. The native checkpoint report uses
6811
7038
  // this same set for lexical path validation, and the SDK receives it below.
6812
7039
  const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
@@ -7099,7 +7326,7 @@ export class ClaudeAcpAgent {
7099
7326
  const allowedModels = Array.isArray(settingsAvailableModels)
7100
7327
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, settingsManager.getManagedDeniedModels())
7101
7328
  : initializationResult.models;
7102
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, resumedModelHint);
7329
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, await resumedModelHint);
7103
7330
  timing.phase("models");
7104
7331
  // Resolve the current model's capabilities separately from the stable
7105
7332
  // permission-mode catalog advertised to ACP clients.
@@ -7144,6 +7371,17 @@ export class ClaudeAcpAgent {
7144
7371
  allowBypass,
7145
7372
  });
7146
7373
  timing.phase("modes");
7374
+ const agents = await discoverCustomAgents(q);
7375
+ timing.phase("agents");
7376
+ // Only adopt the requested agent as the selected value if it's one we
7377
+ // actually surface in the picker. A built-in (filtered out above) or
7378
+ // otherwise-unknown name would leave the config option's `currentValue`
7379
+ // pointing at an entry not in its own `options` list, which clients render
7380
+ // as a blank/invalid selection.
7381
+ const requestedAgent = userProvidedOptions?.agent;
7382
+ const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
7383
+ ? requestedAgent
7384
+ : DEFAULT_AGENT_ID;
7147
7385
  // Seed Fast mode from the SDK's reported state so the UI reflects reality
7148
7386
  // (the CLI may start a session with fast mode already on, or force it off
7149
7387
  // when `fastModePerSessionOptIn` is set). The toggle is only surfaced while
@@ -7169,7 +7407,7 @@ export class ClaudeAcpAgent {
7169
7407
  // Legacy clients still leave effort resolution entirely to the CLI.
7170
7408
  const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities);
7171
7409
  const effortSettings = mergeEffortSettings(settingsManager.getSettings(), configuredSettingsObject);
7172
- const configOptions = buildConfigOptions(modes, models, modelInfos, userProvidedOptions?.effort ?? settingsEffortForModel(effortSettings, currentModelInfo), fastMode, {
7410
+ const configOptions = buildConfigOptions(modes, models, modelInfos, userProvidedOptions?.effort ?? settingsEffortForModel(effortSettings, currentModelInfo), agents, currentAgent, fastMode, {
7173
7411
  useRecommendedValue,
7174
7412
  });
7175
7413
  const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
@@ -7230,6 +7468,8 @@ export class ClaudeAcpAgent {
7230
7468
  initialEffort?.currentValue === userProvidedOptions.effort
7231
7469
  ? userProvidedOptions.effort
7232
7470
  : undefined,
7471
+ agents,
7472
+ currentAgent,
7233
7473
  fastModeEnabled,
7234
7474
  fastModeDisabledReason,
7235
7475
  abortController,
@@ -7248,8 +7488,8 @@ export class ClaudeAcpAgent {
7248
7488
  nativeSubagentParentByToolUseId: new Map(),
7249
7489
  emittedAssistantText: false,
7250
7490
  owedTrailingIdles: 0,
7251
- messageIdToUuid: new Map(),
7252
- sessionFailureState: createSessionFailureState(),
7491
+ messageIdToUuid: creationOpts.replayState?.messageIdToUuid ?? new Map(),
7492
+ sessionFailureState: creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(),
7253
7493
  claudeSubscriptionGuard,
7254
7494
  accountKind: fromAccountInfo(initializationResult.account)?.kind,
7255
7495
  fileChangeReporter,
@@ -7290,7 +7530,10 @@ export class ClaudeAcpAgent {
7290
7530
  this.closeQueryStream(session);
7291
7531
  delete this.sessions[sessionId];
7292
7532
  try {
7293
- await this.createSession(session.creationParams, { resume: sessionId });
7533
+ await this.createSession(session.creationParams, {
7534
+ resume: sessionId,
7535
+ permissionMode: session.modes.currentModeId,
7536
+ });
7294
7537
  }
7295
7538
  catch (error) {
7296
7539
  // One session that cannot come back must not abort the switch. The
@@ -7384,6 +7627,20 @@ function turnOutcome(session, stopReason, extraMeta) {
7384
7627
  _meta: { ...turnQuotaMeta(session), ...extraMeta },
7385
7628
  };
7386
7629
  }
7630
+ /** The outcome of `turn` when a cancel ends it. A turn that already has its
7631
+ * result, because it is held open for its background subagents (see
7632
+ * Turn.deferredSettle) or is settling (see Turn.settlingOutcome), keeps the
7633
+ * usage and metadata that result recorded (issue #844). */
7634
+ function cancelledOutcome(session, turn) {
7635
+ const recorded = turn?.deferredSettle ?? turn?.settlingOutcome;
7636
+ if (!recorded)
7637
+ return turnOutcome(session, "cancelled");
7638
+ return {
7639
+ stopReason: "cancelled",
7640
+ usage: recorded.usage,
7641
+ ...(recorded._meta ? { _meta: recorded._meta } : {}),
7642
+ };
7643
+ }
7387
7644
  /** `_meta.quota` for a prompt response: what the turn spent, shaped like
7388
7645
  * codex-acp's (snake_case container keys, camelCase counters) so a client
7389
7646
  * reads one shape from either agent.
@@ -7645,11 +7902,43 @@ function isValidBaseUrl(baseUrl) {
7645
7902
  }
7646
7903
  return parsed.protocol === "http:" || parsed.protocol === "https:";
7647
7904
  }
7905
+ // `supportedAgents()` always returns Claude Code's built-in subagents — the
7906
+ // ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
7907
+ // has configured none of their own. Those aren't meaningful *main-thread*
7908
+ // personas, so we filter them out and only surface the Agent picker when the
7909
+ // user (or a plugin/project) has configured custom agents. Update this set if
7910
+ // the SDK's built-in roster changes.
7911
+ export const BUILTIN_AGENT_NAMES = new Set([
7912
+ "claude",
7913
+ "general-purpose",
7914
+ "Explore",
7915
+ "Plan",
7916
+ "statusline-setup",
7917
+ ]);
7918
+ // Value of the synthetic "Default" entry in the agent picker, which maps to the
7919
+ // standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
7920
+ // reserved sentinel: a custom agent named exactly this would collide with it
7921
+ // (two options sharing the value, selection silently routing to `null`), so we
7922
+ // exclude that name from discovery.
7923
+ /** Discover user/plugin/project-configured main-thread agents, excluding the
7924
+ * built-in subagents and the reserved "default" sentinel. Returns an empty
7925
+ * list if discovery fails so a flaky control request never blocks session
7926
+ * creation. */
7927
+ export async function discoverCustomAgents(q) {
7928
+ try {
7929
+ const agents = await q.supportedAgents();
7930
+ return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
7931
+ }
7932
+ catch {
7933
+ return [];
7934
+ }
7935
+ }
7648
7936
  /** Stable ids for the session config options surfaced via `configOptions`.
7649
7937
  * Centralized so the option declarations in `buildConfigOptions` and the
7650
7938
  * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
7651
7939
  * same identifiers and can't drift apart. */
7652
7940
  export { MODE_CONFIG_ID };
7941
+ export const AGENT_CONFIG_ID = "agent";
7653
7942
  export const FAST_MODE_CONFIG_ID = "fast";
7654
7943
  /** Select-fallback values used when the client has not opted into boolean
7655
7944
  * config options (see {@link createFastModeConfigOption}). */
@@ -7751,7 +8040,7 @@ export function resolveFastModeEnabled(params) {
7751
8040
  }
7752
8041
  throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`);
7753
8042
  }
7754
- export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, fastMode, presentation) {
8043
+ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode, presentation) {
7755
8044
  const options = [
7756
8045
  SessionModeManager.configOption(modes),
7757
8046
  buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true),
@@ -7765,13 +8054,47 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
7765
8054
  if (fastMode?.supported) {
7766
8055
  options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption, fastMode.disabledReason));
7767
8056
  }
8057
+ // Only surface the Agent picker when there's a real choice — i.e. the user
8058
+ // has configured at least one custom agent (built-ins are filtered out in
8059
+ // discoverCustomAgents). With none configured, "Default" would be the only
8060
+ // entry, so we omit the option entirely.
8061
+ if (agents.length > 0) {
8062
+ options.push({
8063
+ id: AGENT_CONFIG_ID,
8064
+ name: "Agent",
8065
+ description: "Main-thread agent persona",
8066
+ type: "select",
8067
+ currentValue: currentAgent,
8068
+ options: [
8069
+ { value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
8070
+ ...agents.map((a) => ({
8071
+ value: a.name,
8072
+ name: a.name,
8073
+ description: a.description || undefined,
8074
+ })),
8075
+ ],
8076
+ });
8077
+ }
7768
8078
  return options;
7769
8079
  }
7770
8080
  function getAvailableSlashCommands(commands,
7771
8081
  // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
7772
8082
  // the CLI's own terminal, which ACP clients aren't) — filtered alongside
7773
8083
  // the static list. Raw CLI names, matched before the MCP rename.
7774
- terminalCommands) {
8084
+ terminalCommands,
8085
+ // The session cwd of an AIR client, or undefined for every other client.
8086
+ // AIR gets the kind and the SKILL.md path of each skill command.
8087
+ airSkillCwd,
8088
+ // The resolved SKILL.md paths of the session, keyed by the cwd and the name.
8089
+ skillPaths) {
8090
+ const skillPath = (name, cwd) => {
8091
+ const key = `${cwd}\0${name}`;
8092
+ if (skillPaths?.has(key))
8093
+ return skillPaths.get(key);
8094
+ const resolved = resolveSkillPath(name, cwd);
8095
+ skillPaths?.set(key, resolved);
8096
+ return resolved;
8097
+ };
7775
8098
  const UNSUPPORTED_COMMANDS = [
7776
8099
  "clear",
7777
8100
  "cost",
@@ -7782,8 +8105,10 @@ terminalCommands) {
7782
8105
  "release-notes",
7783
8106
  "todos",
7784
8107
  ];
7785
- return commands
7786
- .filter((command) => !terminalCommands?.includes(command.name))
8108
+ // The adapter replaces the terminal text of `/mcp` and runs its actions,
8109
+ // even when Claude Code tags the command terminal-bound.
8110
+ const advertised = commands
8111
+ .filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name))
7787
8112
  .map((command) => {
7788
8113
  const input = command.argumentHint
7789
8114
  ? {
@@ -7793,16 +8118,26 @@ terminalCommands) {
7793
8118
  }
7794
8119
  : null;
7795
8120
  let name = command.name;
7796
- if (command.name.endsWith(" (MCP)")) {
8121
+ const mcpPrompt = command.name.endsWith(" (MCP)");
8122
+ if (mcpPrompt) {
7797
8123
  name = `mcp:${name.replace(" (MCP)", "")}`;
7798
8124
  }
8125
+ const skillMdPath = airSkillCwd && !command.builtin && !mcpPrompt
8126
+ ? skillPath(command.name, airSkillCwd)
8127
+ : undefined;
7799
8128
  return {
7800
8129
  name,
7801
8130
  description: command.description || "",
7802
8131
  input,
8132
+ ...(skillMdPath
8133
+ ? {
8134
+ _meta: withAirMeta(withAirMeta(undefined, AIR_KIND_KEY, "skill"), AIR_SKILL_PATH_KEY, skillMdPath),
8135
+ }
8136
+ : {}),
7803
8137
  };
7804
8138
  })
7805
8139
  .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
8140
+ return [...advertised, MCP_AVAILABLE_COMMAND];
7806
8141
  }
7807
8142
  function formatUriAsLink(uri) {
7808
8143
  try {
@@ -7977,10 +8312,19 @@ function toolCallFieldsOf(session) {
7977
8312
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
7978
8313
  * Only handles text, image, and thinking chunks for now.
7979
8314
  */
8315
+ /**
8316
+ * The renderer of the notification functions: the agent's tool call
8317
+ * capabilities when they are passed, else those of the ACP capabilities.
8318
+ */
8319
+ function toolCallRenderer(options) {
8320
+ return options?.toolCallCapabilities
8321
+ ? new AcpToolCallRenderer(options.toolCallCapabilities, options.replay)
8322
+ : AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
8323
+ }
7980
8324
  export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
7981
8325
  const taskState = options?.taskState ?? new Map();
7982
8326
  const registerHooks = options?.registerHooks !== false;
7983
- const renderer = AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
8327
+ const renderer = toolCallRenderer(options);
7984
8328
  if (typeof content === "string") {
7985
8329
  if (content.length === 0) {
7986
8330
  return [];
@@ -8318,6 +8662,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8318
8662
  const streamedToolInputs = options?.streamedToolInputs;
8319
8663
  const forwardedOptions = {
8320
8664
  clientCapabilities: options?.clientCapabilities,
8665
+ toolCallCapabilities: options?.toolCallCapabilities,
8321
8666
  parentToolUseId: message.parent_tool_use_id,
8322
8667
  cwd: options?.cwd,
8323
8668
  taskState: options?.taskState,
@@ -8381,7 +8726,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8381
8726
  // TodoWrite and the Task* tools never surfaced a tool_call to refine.
8382
8727
  if (!shouldEmitToolCall(streamedInput.name))
8383
8728
  return [];
8384
- const update = AcpToolCallRenderer.for(options?.clientCapabilities).partialRefinement(streamedInput, input, options?.cwd);
8729
+ const update = toolCallRenderer(options).partialRefinement(streamedInput, input, options?.cwd);
8385
8730
  if (message.parent_tool_use_id)
8386
8731
  stampParentToolUseId(update, message.parent_tool_use_id);
8387
8732
  applyMessageId(update, options?.messageId);