@agentclientprotocol/claude-agent-acp 0.85.2-preview.4 → 0.85.2-preview.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acp-agent.js CHANGED
@@ -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,9 +16,9 @@ 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
24
  import { buildClaudePermissionPresentation } from "./permissions/presentation.js";
@@ -36,11 +36,13 @@ 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";
41
42
  import { parseToolResultMeta } from "./tool-result-meta.js";
42
43
  import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
43
- import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
44
+ import { MCP_AVAILABLE_COMMAND, parseMcpCommand, runMcpCommand } from "./mcp-command.js";
45
+ import { MODE_CONFIG_ID, resolveInitialPermissionMode, SessionModeManager, } from "./session-mode.js";
44
46
  import { applyAvailableModelsAllowlist, buildModelConfigOption, getAvailableModels, MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js";
45
47
  import { buildEffortConfigOption, EFFORT_CONFIG_ID, effortFlagSettings, mergeEffortSettings, settingsEffortForModel, } from "./session-effort.js";
46
48
  export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
@@ -105,32 +107,18 @@ function settleLiveBackgroundTask(session, taskId) {
105
107
  async function structuredUsageMarkdown(query, signal, logger) {
106
108
  if (signal.aborted)
107
109
  return null;
108
- let timeout;
109
- let onAbort;
110
110
  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) {
111
+ const outcome = await raceTimeoutAndAbort(
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(), STRUCTURED_USAGE_TIMEOUT_MS, signal);
115
+ if (outcome.type !== "done") {
116
+ if (outcome.type === "timeout") {
129
117
  logger.error("Structured /usage timed out; preserving Claude Code output");
130
118
  }
131
119
  return null;
132
120
  }
133
- const usage = parseUsageResponse(response);
121
+ const usage = parseUsageResponse(outcome.value);
134
122
  if (!usage) {
135
123
  logger.error("Structured /usage returned an incompatible response; preserving Claude Code output");
136
124
  return null;
@@ -141,12 +129,6 @@ async function structuredUsageMarkdown(query, signal, logger) {
141
129
  logger.error(`Structured /usage failed; preserving Claude Code output: ${error}`);
142
130
  return null;
143
131
  }
144
- finally {
145
- if (timeout)
146
- clearTimeout(timeout);
147
- if (onAbort)
148
- signal.removeEventListener("abort", onAbort);
149
- }
150
132
  }
151
133
  /** Claude Code keeps the OAuth callback listener open in the background after
152
134
  * `mcpAuthenticate` returns the authorization URL. The SDK does not expose
@@ -680,6 +662,7 @@ const REPLAY_HIDDEN_COMMANDS = new Set([
680
662
  ...LOCAL_ONLY_COMMANDS,
681
663
  "/compact",
682
664
  "/model",
665
+ "/mcp",
683
666
  "/status",
684
667
  "/usage",
685
668
  ]);
@@ -1017,12 +1000,19 @@ const mcpAuthentications = new WeakMap();
1017
1000
  * authentication. This runs after session creation has returned so an
1018
1001
  * interactive browser flow never delays `session/new`. */
1019
1002
  function startMcpAuthentication(host, sessionId, mcpServers) {
1020
- if (!host.clientCapabilities?.elicitation?.url || mcpServers.length === 0)
1003
+ if (mcpServers.length === 0)
1004
+ return;
1005
+ startMcpServerAuthentication(host, sessionId, new Set(mcpServers.map((server) => server.name)));
1006
+ }
1007
+ /** Start OAuth in the background for the servers in `requestedServers` that
1008
+ * Claude reports as needing authentication. Startup and `/mcp` use it. It
1009
+ * does nothing while another OAuth run of the session is in progress. */
1010
+ function startMcpServerAuthentication(host, sessionId, requestedServers) {
1011
+ if (!host.clientCapabilities?.elicitation?.url)
1021
1012
  return;
1022
1013
  const session = host.sessions[sessionId];
1023
1014
  if (!session || mcpAuthentications.has(session))
1024
1015
  return;
1025
- const requestedServers = new Set(mcpServers.map((server) => server.name));
1026
1016
  const authentication = authenticateMcpServers(host, sessionId, session.query, requestedServers)
1027
1017
  .catch((error) => {
1028
1018
  if (!session.abortController.signal.aborted) {
@@ -1402,6 +1392,56 @@ export class ClaudeAcpAgent {
1402
1392
  }, 0);
1403
1393
  return response;
1404
1394
  }
1395
+ /**
1396
+ * Start the CLI of a loaded session, and replay the history while the CLI
1397
+ * starts. The CLI start takes most of the load time, and the replay needs
1398
+ * only the transcript and the {@link ReplayState}. An invalid `cwd` fails
1399
+ * before the first update. A failed CLI start stops the replay.
1400
+ */
1401
+ async createSessionWhileReplaying(params, timing) {
1402
+ await this.validateCwd(params.cwd);
1403
+ const pending = {
1404
+ state: {
1405
+ cwd: params.cwd,
1406
+ taskState: new Map(),
1407
+ forwardSubagentText: this.forwardsSubagentText(params._meta),
1408
+ messageIdToUuid: new Map(),
1409
+ sessionFailureState: createSessionFailureState(),
1410
+ },
1411
+ stopped: false,
1412
+ };
1413
+ const transcript = readResumedSession(params.sessionId, this.logger);
1414
+ const replay = transcript
1415
+ .then(({ messages }) => this.replaySessionHistory(params.sessionId, messages, pending))
1416
+ .then(() => ({ ok: true }), (error) => ({ ok: false, error }));
1417
+ let response;
1418
+ try {
1419
+ response = await this.createSession({
1420
+ cwd: params.cwd,
1421
+ mcpServers: params.mcpServers ?? [],
1422
+ additionalDirectories: params.additionalDirectories,
1423
+ _meta: params._meta,
1424
+ }, {
1425
+ resume: params.sessionId,
1426
+ resumedModelHint: transcript.then(({ model }) => model),
1427
+ replayState: pending.state,
1428
+ });
1429
+ }
1430
+ catch (error) {
1431
+ pending.stopped = true;
1432
+ await replay;
1433
+ throw error;
1434
+ }
1435
+ timing.phase("session-ready");
1436
+ const replayed = await replay;
1437
+ if (!replayed.ok)
1438
+ throw replayed.error;
1439
+ return {
1440
+ sessionId: response.sessionId,
1441
+ modes: response.modes,
1442
+ configOptions: response.configOptions,
1443
+ };
1444
+ }
1405
1445
  async unstable_forkSession(params) {
1406
1446
  if (this.providerUpdate)
1407
1447
  await this.providerUpdate;
@@ -1426,10 +1466,16 @@ export class ClaudeAcpAgent {
1426
1466
  const timing = new SessionTiming(this.logger, "load", params.sessionId);
1427
1467
  if (this.providerUpdate)
1428
1468
  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);
1469
+ let result;
1470
+ if (this.sessions[params.sessionId]) {
1471
+ const resumedSession = await readResumedSession(params.sessionId, this.logger);
1472
+ result = await this.getOrCreateSession(params, resumedSession);
1473
+ timing.phase("session-ready");
1474
+ await this.replaySessionHistory(params.sessionId, resumedSession.messages);
1475
+ }
1476
+ else {
1477
+ result = await this.createSessionWhileReplaying(params, timing);
1478
+ }
1433
1479
  timing.phase("replay");
1434
1480
  // Send available commands after replay so it doesn't interleave with history
1435
1481
  setTimeout(() => {
@@ -1812,6 +1858,47 @@ export class ClaudeAcpAgent {
1812
1858
  this.setAuthStatus(notLoggedInAuthStatus());
1813
1859
  }
1814
1860
  }
1861
+ /** The structured replacement for a prompt that is exactly `/usage` or a
1862
+ * `/mcp` command. Claude Code runs the command, and the replacement takes
1863
+ * the place of its text. Claude Code refuses a `/mcp` reconnect, enable,
1864
+ * or disable in SDK mode. The `/mcp` producer runs them through the SDK
1865
+ * control API when the text arrives, so the action stays in the turn. */
1866
+ localCommandMarkdown(params) {
1867
+ const text = params.prompt.length === 1 && params.prompt[0]?.type === "text"
1868
+ ? params.prompt[0].text
1869
+ : undefined;
1870
+ if (text === undefined)
1871
+ return undefined;
1872
+ if (isUsageCommandText(text)) {
1873
+ return {
1874
+ startsAtActivation: true,
1875
+ produce: (query, signal) => structuredUsageMarkdown(query, signal, this.logger),
1876
+ };
1877
+ }
1878
+ const command = parseMcpCommand(text);
1879
+ if (!command)
1880
+ return undefined;
1881
+ let reconnected = [];
1882
+ return {
1883
+ startsAtActivation: false,
1884
+ produce: async (query, signal, originalOutput) => {
1885
+ const outcome = await runMcpCommand(query, command, originalOutput, signal, (message) => this.logger.error(message));
1886
+ reconnected = outcome.reconnected;
1887
+ return outcome.markdown;
1888
+ },
1889
+ // A reconnect starts OAuth for the ACP servers that it tried, as the
1890
+ // session start does. A plain `/mcp` and a cancelled turn start none.
1891
+ afterDelivery: () => {
1892
+ const session = this.sessions[params.sessionId];
1893
+ if (!session || session.cancelled)
1894
+ return;
1895
+ const acpServers = new Set((session.creationParams?.mcpServers ?? []).map((server) => server.name));
1896
+ const servers = new Set(reconnected.filter((server) => acpServers.has(server)));
1897
+ if (servers.size > 0)
1898
+ startMcpServerAuthentication(this, params.sessionId, servers);
1899
+ },
1900
+ };
1901
+ }
1815
1902
  /**
1816
1903
  * Serves an ACP v1 `session/prompt`, which answers when the turn ends. The
1817
1904
  * outcome of a turn is field for field a v1 prompt response.
@@ -1878,10 +1965,8 @@ export class ClaudeAcpAgent {
1878
1965
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
1879
1966
  const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
1880
1967
  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;
1968
+ const localCommand = this.localCommandMarkdown(params);
1969
+ const localCommandAbort = localCommand ? new AbortController() : undefined;
1885
1970
  session.titles.onPrompt(params.prompt);
1886
1971
  // Each prompt is a Turn that the persistent consumer settles once the
1887
1972
  // turn's outcome is known. `startTurn()` owns no loop: it enqueues the
@@ -1892,8 +1977,8 @@ export class ClaudeAcpAgent {
1892
1977
  promptUuid,
1893
1978
  events,
1894
1979
  isLocalOnlyCommand,
1895
- ...(isUsageCommand ? { isUsageCommand: true } : {}),
1896
- ...(usageMarkdownAbort ? { usageMarkdownAbort } : {}),
1980
+ ...(localCommand ? { localCommand } : {}),
1981
+ ...(localCommandAbort ? { localCommandAbort } : {}),
1897
1982
  ...(fileChangeReport ? { fileChangeReport } : {}),
1898
1983
  settled: false,
1899
1984
  completion: new Promise((resolve) => {
@@ -2015,7 +2100,10 @@ export class ClaudeAcpAgent {
2015
2100
  try {
2016
2101
  // `resume` names the Claude session, which shares the ACP session id, so
2017
2102
  // the new query continues the same conversation under the same id.
2018
- await this.createSession(creationParams, { resume: sessionId });
2103
+ await this.createSession(creationParams, {
2104
+ resume: sessionId,
2105
+ permissionMode: session.modes.currentModeId,
2106
+ });
2019
2107
  }
2020
2108
  catch (error) {
2021
2109
  if (error instanceof RequestError && error.code === RequestError.resourceNotFound().code) {
@@ -2025,7 +2113,10 @@ export class ClaudeAcpAgent {
2025
2113
  // better answer than a session the client can never use again.
2026
2114
  this.logger.log(`session ${sessionId} was never persisted; starting a fresh query under the same id`);
2027
2115
  try {
2028
- await this.createSession(creationParams, { reuseSessionId: sessionId });
2116
+ await this.createSession(creationParams, {
2117
+ reuseSessionId: sessionId,
2118
+ permissionMode: session.modes.currentModeId,
2119
+ });
2029
2120
  return;
2030
2121
  }
2031
2122
  catch (fallbackError) {
@@ -2487,11 +2578,11 @@ export class ClaudeAcpAgent {
2487
2578
  };
2488
2579
  };
2489
2580
  const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
2490
- const ensureUsageMarkdown = (turn) => {
2491
- if (!turn.isUsageCommand || !turn.usageMarkdownAbort)
2581
+ const ensureLocalCommandMarkdown = (turn, originalOutput = "") => {
2582
+ if (!turn.localCommand || !turn.localCommandAbort)
2492
2583
  return undefined;
2493
- turn.usageMarkdown ??= structuredUsageMarkdown(session.query, turn.usageMarkdownAbort.signal, this.logger);
2494
- return turn.usageMarkdown;
2584
+ turn.localCommandMarkdown ??= turn.localCommand.produce(session.query, turn.localCommandAbort.signal, originalOutput);
2585
+ return turn.localCommandMarkdown;
2495
2586
  };
2496
2587
  const resetTurnScratch = () => {
2497
2588
  lastAssistantTotalUsage = null;
@@ -2534,7 +2625,8 @@ export class ClaudeAcpAgent {
2534
2625
  session.activeTurn = turn;
2535
2626
  session.cancelled = false;
2536
2627
  compaction.resume();
2537
- ensureUsageMarkdown(turn);
2628
+ if (turn.localCommand?.startsAtActivation)
2629
+ ensureLocalCommandMarkdown(turn);
2538
2630
  session.pendingOrphanResults = 0;
2539
2631
  session.orphanCommands?.clear();
2540
2632
  // Two-phase sweep of registry entries the level signal ended (see
@@ -2732,27 +2824,28 @@ export class ClaudeAcpAgent {
2732
2824
  * the autonomous stretch-close guard. */
2733
2825
  const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
2734
2826
  /** 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) => {
2827
+ * local-command output. Undefined means this is not a structured
2828
+ * local-command turn (or its request failed), null means another SDK
2829
+ * message shape already delivered it, and string is the one replacement
2830
+ * to publish. */
2831
+ const takeLocalCommandMarkdown = async (originalOutput) => {
2739
2832
  const turn = session.activeTurn ?? firstUnsettledQueuedTurn();
2740
2833
  if (!turn)
2741
2834
  return undefined;
2742
- const usageMarkdown = ensureUsageMarkdown(turn);
2743
- if (!usageMarkdown)
2835
+ const pending = ensureLocalCommandMarkdown(turn, originalOutput);
2836
+ if (!pending)
2744
2837
  return undefined;
2745
- const markdown = await usageMarkdown;
2838
+ const markdown = await pending;
2746
2839
  if (markdown === null)
2747
2840
  return undefined;
2748
- if (turn.usageMarkdownDelivered) {
2841
+ if (turn.localCommandDelivered) {
2749
2842
  // Different SDK message shapes can mirror the same local-command
2750
2843
  // output. Suppress an exact mirror, but let a later, distinct frame
2751
2844
  // (for example an interruption diagnostic) follow the normal path.
2752
- return turn.usageOriginalOutput === originalOutput ? null : undefined;
2845
+ return turn.localCommandOriginalOutput === originalOutput ? null : undefined;
2753
2846
  }
2754
- turn.usageMarkdownDelivered = true;
2755
- turn.usageOriginalOutput = originalOutput;
2847
+ turn.localCommandDelivered = true;
2848
+ turn.localCommandOriginalOutput = originalOutput;
2756
2849
  return markdown;
2757
2850
  };
2758
2851
  /** Registers a live background task (see `liveBackgroundTasks`). A
@@ -2905,7 +2998,9 @@ export class ClaudeAcpAgent {
2905
2998
  // Captured before the settled flip below (isHeldOpen tests !settled).
2906
2999
  const wasHeld = isHeldOpen(turn);
2907
3000
  turn.settled = true;
2908
- turn.usageMarkdownAbort?.abort();
3001
+ turn.localCommandAbort?.abort();
3002
+ if (turn.localCommandDelivered)
3003
+ turn.localCommand?.afterDelivery?.();
2909
3004
  disarmForceCancel(session);
2910
3005
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
2911
3006
  session.activeTurn = null;
@@ -3365,17 +3460,17 @@ export class ClaudeAcpAgent {
3365
3460
  if (compaction.consumeDuplicateErrorOutput(message.content)) {
3366
3461
  break;
3367
3462
  }
3368
- const usageTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
3369
- const usageMarkdown = await takeUsageMarkdown(message.content);
3370
- if (usageTurn?.isUsageCommand && session.cancelled)
3463
+ const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
3464
+ const commandMarkdown = await takeLocalCommandMarkdown(message.content);
3465
+ if (commandTurn?.localCommand && session.cancelled)
3371
3466
  break;
3372
- if (usageMarkdown === null)
3467
+ if (commandMarkdown === null)
3373
3468
  break;
3374
3469
  await sendUpdate({
3375
3470
  sessionId: params.sessionId,
3376
3471
  update: {
3377
3472
  sessionUpdate: "agent_message_chunk",
3378
- content: { type: "text", text: usageMarkdown ?? message.content },
3473
+ content: { type: "text", text: commandMarkdown ?? message.content },
3379
3474
  },
3380
3475
  });
3381
3476
  break;
@@ -3534,11 +3629,12 @@ export class ClaudeAcpAgent {
3534
3629
  // list with this payload. Forward message.commands directly —
3535
3630
  // it's authoritative, and re-querying supportedCommands()
3536
3631
  // would just return the same list with an extra round-trip.
3632
+ session.skillPaths = new Map();
3537
3633
  await sendUpdate({
3538
3634
  sessionId: params.sessionId,
3539
3635
  update: {
3540
3636
  sessionUpdate: "available_commands_update",
3541
- availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
3637
+ availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, session.skillPaths),
3542
3638
  },
3543
3639
  });
3544
3640
  break;
@@ -4384,10 +4480,13 @@ export class ClaudeAcpAgent {
4384
4480
  (message.usage.output_tokens ?? 0) === 0 &&
4385
4481
  !session.activeTurn?.noticeTexts?.includes(normalizeNoticeText(message.result)));
4386
4482
  if (shouldForwardResult) {
4387
- const usageMarkdown = await takeUsageMarkdown(message.result);
4388
- if (usageMarkdown === null)
4483
+ const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
4484
+ const commandMarkdown = await takeLocalCommandMarkdown(message.result);
4485
+ if (commandTurn?.localCommand && session.cancelled)
4389
4486
  break;
4390
- for (const notification of toAcpNotifications(usageMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
4487
+ if (commandMarkdown === null)
4488
+ break;
4489
+ for (const notification of toAcpNotifications(commandMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
4391
4490
  await sendUpdate(notification);
4392
4491
  }
4393
4492
  }
@@ -4780,16 +4879,16 @@ export class ClaudeAcpAgent {
4780
4879
  // Depending on the Claude Code build, a local command can arrive
4781
4880
  // as the dedicated system message above or as a synthetic
4782
4881
  // assistant message. Replace only the output owned by the exact
4783
- // /usage turn; no content signatures or text parsing are involved.
4882
+ // /usage or /mcp turn; no content signatures or text parsing are involved.
4784
4883
  if (message.type === "assistant" &&
4785
4884
  message.parent_tool_use_id === null &&
4786
4885
  message.message.model === "<synthetic>") {
4787
- const usageMarkdown = await takeUsageMarkdown(assistantMessageText(message.message) ?? "");
4886
+ const commandMarkdown = await takeLocalCommandMarkdown(assistantMessageText(message.message) ?? "");
4788
4887
  if (session.cancelled)
4789
4888
  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) })) {
4889
+ if (commandMarkdown !== undefined) {
4890
+ if (commandMarkdown !== null) {
4891
+ for (const notification of toAcpNotifications(commandMarkdown, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4793
4892
  await sendUpdate(notification);
4794
4893
  }
4795
4894
  }
@@ -5089,7 +5188,7 @@ export class ClaudeAcpAgent {
5089
5188
  }
5090
5189
  session.cancelled = true;
5091
5190
  for (const turn of session.turnQueue ?? [])
5092
- turn.usageMarkdownAbort?.abort();
5191
+ turn.localCommandAbort?.abort();
5093
5192
  session.pendingExitPlanModeInterruption = undefined;
5094
5193
  session.pendingExitPlanContextReset = undefined;
5095
5194
  // The stream already ended (see closeQueryStream): every in-flight turn was
@@ -5531,13 +5630,15 @@ export class ClaudeAcpAgent {
5531
5630
  await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
5532
5631
  return { configOptions: session.configOptions };
5533
5632
  }
5534
- async replaySessionHistory(sessionId, resumedMessages) {
5633
+ async replaySessionHistory(sessionId, resumedMessages, pending) {
5535
5634
  const replayStartedAt = performance.now();
5536
5635
  const toolUseCache = {};
5537
5636
  const messages = resumedMessages ?? (await getSessionMessages(sessionId));
5538
5637
  const historyLoadedAt = performance.now();
5539
5638
  this.logger.log(`[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`);
5540
- const session = this.sessions[sessionId];
5639
+ // A pending replay has no session record yet. It uses the state that the record takes later.
5640
+ const replayState = () => pending?.state ?? this.sessions[sessionId];
5641
+ const session = replayState();
5541
5642
  // A replay rebuilds the client view, so its plan goes out again.
5542
5643
  if (session?.taskState)
5543
5644
  forgetPublishedTaskPlan(session.taskState);
@@ -5549,7 +5650,12 @@ export class ClaudeAcpAgent {
5549
5650
  sessionId,
5550
5651
  state: session.sessionFailureState,
5551
5652
  capabilities: this.clientCapabilities,
5552
- isCurrent: () => this.sessions[sessionId] === session,
5653
+ isCurrent: () => {
5654
+ const live = this.sessions[sessionId];
5655
+ if (!live)
5656
+ return pending !== undefined && !pending.stopped;
5657
+ return live.sessionFailureState === session.sessionFailureState;
5658
+ },
5553
5659
  sendUpdate: (notification) => this.client.sessionUpdate(notification),
5554
5660
  logger: this.logger,
5555
5661
  })
@@ -5704,7 +5810,7 @@ export class ClaudeAcpAgent {
5704
5810
  await replayAsyncTasks.taskNotification(notification);
5705
5811
  };
5706
5812
  const replayMessage = async (message) => {
5707
- if (isReplayHiddenMetaMessage(message)) {
5813
+ if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
5708
5814
  return;
5709
5815
  }
5710
5816
  if (message.type === "user" &&
@@ -5718,7 +5824,7 @@ export class ClaudeAcpAgent {
5718
5824
  // a client-supplied id without an extra getSessionMessages read. Not read
5719
5825
  // yet (see Session.messageIdToUuid).
5720
5826
  const replayMessageId = messageIdForGrouping(message);
5721
- const replaySession = this.sessions[sessionId];
5827
+ const replaySession = replayState();
5722
5828
  if (replaySession && replayMessageId && message.uuid) {
5723
5829
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
5724
5830
  }
@@ -5812,8 +5918,8 @@ export class ClaudeAcpAgent {
5812
5918
  registerHooks: false,
5813
5919
  replay: true,
5814
5920
  clientCapabilities: this.clientCapabilities,
5815
- cwd: this.sessions[sessionId]?.cwd,
5816
- taskState: this.sessions[sessionId]?.taskState,
5921
+ cwd: replayState()?.cwd,
5922
+ taskState: replayState()?.taskState,
5817
5923
  messageId: replayMessageId,
5818
5924
  parentToolUseId,
5819
5925
  })) {
@@ -5858,7 +5964,7 @@ export class ClaudeAcpAgent {
5858
5964
  };
5859
5965
  for (const message of messages)
5860
5966
  await replayMessage(message);
5861
- if (nativeReplayEnabled) {
5967
+ if (nativeReplayEnabled && !pending?.stopped) {
5862
5968
  // Claude history persists sidechain messages and Agent/Task tool uses,
5863
5969
  // but not task_started/task_updated lifecycle frames. Recover terminal
5864
5970
  // state from the launch tool_result. Missing results and malformed or
@@ -6292,7 +6398,7 @@ export class ClaudeAcpAgent {
6292
6398
  sessionId,
6293
6399
  update: {
6294
6400
  sessionUpdate: "available_commands_update",
6295
- availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
6401
+ availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, (session.skillPaths ??= new Map())),
6296
6402
  },
6297
6403
  });
6298
6404
  }
@@ -6613,6 +6719,8 @@ export class ClaudeAcpAgent {
6613
6719
  }
6614
6720
  async getOrCreateSession(params, resumedSession) {
6615
6721
  const existingSession = this.sessions[params.sessionId];
6722
+ // A recreated live session keeps its mode, not the mode of the transcript.
6723
+ const livePermissionMode = existingSession?.modes.currentModeId;
6616
6724
  if (existingSession) {
6617
6725
  const fingerprint = computeSessionFingerprint(params);
6618
6726
  if (fingerprint === existingSession.sessionFingerprint) {
@@ -6628,9 +6736,6 @@ export class ClaudeAcpAgent {
6628
6736
  // up the new values.
6629
6737
  await this.teardownSession(params.sessionId);
6630
6738
  }
6631
- const resumedModelHint = resumedSession
6632
- ? resumedSession.model
6633
- : await readResumedModel(params.sessionId, this.logger);
6634
6739
  const response = await this.createSession({
6635
6740
  cwd: params.cwd,
6636
6741
  mcpServers: params.mcpServers ?? [],
@@ -6638,7 +6743,8 @@ export class ClaudeAcpAgent {
6638
6743
  _meta: params._meta,
6639
6744
  }, {
6640
6745
  resume: params.sessionId,
6641
- resumedModelHint,
6746
+ permissionMode: livePermissionMode,
6747
+ ...(resumedSession ? { resumedModelHint: resumedSession.model } : {}),
6642
6748
  });
6643
6749
  return {
6644
6750
  sessionId: response.sessionId,
@@ -6667,6 +6773,12 @@ export class ClaudeAcpAgent {
6667
6773
  throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
6668
6774
  }
6669
6775
  }
6776
+ /** Whether the session forwards the text and the thinking of a subagent. */
6777
+ forwardsSubagentText(meta) {
6778
+ return (clientSupportsSubagents(this.clientCapabilities) ||
6779
+ supportsSubagentTranscript(this.clientCapabilities) ||
6780
+ meta?.claudeCode?.options?.forwardSubagentText === true);
6781
+ }
6670
6782
  async createSession(params, creationOpts = {}) {
6671
6783
  const createStartedAt = performance.now();
6672
6784
  // Validate `cwd` up front. The ACP spec requires an absolute path, and the
@@ -6704,10 +6816,15 @@ export class ClaudeAcpAgent {
6704
6816
  // sign-out respawn, provider rerouting), so fill the same fast local hint
6705
6817
  // here when the caller did not provide it. Never fall back to the slow
6706
6818
  // getContextUsage control request.
6819
+ // The same tail read gives the permission mode of the resumed session.
6707
6820
  let resumedModelHint = creationOpts.resumedModelHint;
6708
- if (creationOpts.resume !== undefined &&
6709
- !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint")) {
6710
- resumedModelHint = await readResumedModel(creationOpts.resume, this.logger);
6821
+ let resumedPermissionMode;
6822
+ if (creationOpts.resume !== undefined) {
6823
+ const withModel = !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint");
6824
+ const tail = await readResumedTail(creationOpts.resume, this.logger, withModel);
6825
+ if (withModel)
6826
+ resumedModelHint = tail.model;
6827
+ resumedPermissionMode = tail.permissionMode;
6711
6828
  timing.phase("resume-transcript");
6712
6829
  }
6713
6830
  const input = new Pushable();
@@ -6769,7 +6886,11 @@ export class ClaudeAcpAgent {
6769
6886
  const allowBypass = ALLOW_BYPASS &&
6770
6887
  settingsManager.getSettings().permissions?.disableBypassPermissionsMode !== "disable" &&
6771
6888
  sessionMeta?.claudeCode?.options?.allowDangerouslySkipPermissions !== false;
6772
- const initialPermissionMode = resolvePermissionMode(creationOpts.permissionMode ?? settingsManager.getSettings().permissions?.defaultMode, this.logger, allowBypass);
6889
+ const initialPermissionMode = resolveInitialPermissionMode({
6890
+ explicit: creationOpts.permissionMode,
6891
+ resumed: resumedPermissionMode,
6892
+ defaultMode: settingsManager.getSettings().permissions?.defaultMode,
6893
+ }, this.logger, allowBypass);
6773
6894
  const userProvidedOptions = sessionMeta?.claudeCode?.options
6774
6895
  ? { ...sessionMeta.claudeCode.options }
6775
6896
  : undefined;
@@ -6778,9 +6899,7 @@ export class ClaudeAcpAgent {
6778
6899
  // hidden state that the Client cannot inspect or change.
6779
6900
  if (userProvidedOptions)
6780
6901
  delete userProvidedOptions.agent;
6781
- const forwardSubagentText = clientSupportsSubagents(this.clientCapabilities) ||
6782
- supportsSubagentTranscript(this.clientCapabilities) ||
6783
- userProvidedOptions?.forwardSubagentText === true;
6902
+ const forwardSubagentText = this.forwardsSubagentText(params._meta);
6784
6903
  // Configure thinking behavior from environment variable
6785
6904
  const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
6786
6905
  // Parse model configuration from environment (e.g. Bedrock model overrides)
@@ -6806,7 +6925,7 @@ export class ClaudeAcpAgent {
6806
6925
  // Per-session task state. Created here (rather than in the session record
6807
6926
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
6808
6927
  // the same Map that the streaming message handler will read from.
6809
- const taskState = new Map();
6928
+ const taskState = creationOpts.replayState?.taskState ?? new Map();
6810
6929
  // Resolve every workspace root once. The native checkpoint report uses
6811
6930
  // this same set for lexical path validation, and the SDK receives it below.
6812
6931
  const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
@@ -7099,7 +7218,7 @@ export class ClaudeAcpAgent {
7099
7218
  const allowedModels = Array.isArray(settingsAvailableModels)
7100
7219
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, settingsManager.getManagedDeniedModels())
7101
7220
  : initializationResult.models;
7102
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, resumedModelHint);
7221
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, await resumedModelHint);
7103
7222
  timing.phase("models");
7104
7223
  // Resolve the current model's capabilities separately from the stable
7105
7224
  // permission-mode catalog advertised to ACP clients.
@@ -7248,8 +7367,8 @@ export class ClaudeAcpAgent {
7248
7367
  nativeSubagentParentByToolUseId: new Map(),
7249
7368
  emittedAssistantText: false,
7250
7369
  owedTrailingIdles: 0,
7251
- messageIdToUuid: new Map(),
7252
- sessionFailureState: createSessionFailureState(),
7370
+ messageIdToUuid: creationOpts.replayState?.messageIdToUuid ?? new Map(),
7371
+ sessionFailureState: creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(),
7253
7372
  claudeSubscriptionGuard,
7254
7373
  accountKind: fromAccountInfo(initializationResult.account)?.kind,
7255
7374
  fileChangeReporter,
@@ -7290,7 +7409,10 @@ export class ClaudeAcpAgent {
7290
7409
  this.closeQueryStream(session);
7291
7410
  delete this.sessions[sessionId];
7292
7411
  try {
7293
- await this.createSession(session.creationParams, { resume: sessionId });
7412
+ await this.createSession(session.creationParams, {
7413
+ resume: sessionId,
7414
+ permissionMode: session.modes.currentModeId,
7415
+ });
7294
7416
  }
7295
7417
  catch (error) {
7296
7418
  // One session that cannot come back must not abort the switch. The
@@ -7771,7 +7893,20 @@ function getAvailableSlashCommands(commands,
7771
7893
  // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
7772
7894
  // the CLI's own terminal, which ACP clients aren't) — filtered alongside
7773
7895
  // the static list. Raw CLI names, matched before the MCP rename.
7774
- terminalCommands) {
7896
+ terminalCommands,
7897
+ // The session cwd of an AIR client, or undefined for every other client.
7898
+ // AIR gets the kind and the SKILL.md path of each skill command.
7899
+ airSkillCwd,
7900
+ // The resolved SKILL.md paths of the session, keyed by the cwd and the name.
7901
+ skillPaths) {
7902
+ const skillPath = (name, cwd) => {
7903
+ const key = `${cwd}\0${name}`;
7904
+ if (skillPaths?.has(key))
7905
+ return skillPaths.get(key);
7906
+ const resolved = resolveSkillPath(name, cwd);
7907
+ skillPaths?.set(key, resolved);
7908
+ return resolved;
7909
+ };
7775
7910
  const UNSUPPORTED_COMMANDS = [
7776
7911
  "clear",
7777
7912
  "cost",
@@ -7782,8 +7917,10 @@ terminalCommands) {
7782
7917
  "release-notes",
7783
7918
  "todos",
7784
7919
  ];
7785
- return commands
7786
- .filter((command) => !terminalCommands?.includes(command.name))
7920
+ // The adapter replaces the terminal text of `/mcp` and runs its actions,
7921
+ // even when Claude Code tags the command terminal-bound.
7922
+ const advertised = commands
7923
+ .filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name))
7787
7924
  .map((command) => {
7788
7925
  const input = command.argumentHint
7789
7926
  ? {
@@ -7793,16 +7930,26 @@ terminalCommands) {
7793
7930
  }
7794
7931
  : null;
7795
7932
  let name = command.name;
7796
- if (command.name.endsWith(" (MCP)")) {
7933
+ const mcpPrompt = command.name.endsWith(" (MCP)");
7934
+ if (mcpPrompt) {
7797
7935
  name = `mcp:${name.replace(" (MCP)", "")}`;
7798
7936
  }
7937
+ const skillMdPath = airSkillCwd && !command.builtin && !mcpPrompt
7938
+ ? skillPath(command.name, airSkillCwd)
7939
+ : undefined;
7799
7940
  return {
7800
7941
  name,
7801
7942
  description: command.description || "",
7802
7943
  input,
7944
+ ...(skillMdPath
7945
+ ? {
7946
+ _meta: withAirMeta(withAirMeta(undefined, AIR_KIND_KEY, "skill"), AIR_SKILL_PATH_KEY, skillMdPath),
7947
+ }
7948
+ : {}),
7803
7949
  };
7804
7950
  })
7805
7951
  .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
7952
+ return [...advertised, MCP_AVAILABLE_COMMAND];
7806
7953
  }
7807
7954
  function formatUriAsLink(uri) {
7808
7955
  try {