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

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) => {
@@ -1903,8 +1988,8 @@ export class ClaudeAcpAgent {
1903
1988
  events.ended(outcome);
1904
1989
  completeTurn();
1905
1990
  },
1906
- reject: (error) => {
1907
- events.failed(error);
1991
+ reject: (error, title) => {
1992
+ events.failed(error, title);
1908
1993
  completeTurn();
1909
1994
  },
1910
1995
  };
@@ -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;
@@ -2929,7 +3024,7 @@ export class ClaudeAcpAgent {
2929
3024
  };
2930
3025
  /** Reject the active turn (auth required, error result, …) without tearing
2931
3026
  * down the consumer: the stream continues to idle and later turns proceed. */
2932
- const failActive = (error) => {
3027
+ const failActive = (error, title) => {
2933
3028
  disarmForceCancel(session);
2934
3029
  const turn = session.activeTurn;
2935
3030
  if (!turn || turn.settled) {
@@ -2946,7 +3041,7 @@ export class ClaudeAcpAgent {
2946
3041
  // start the next stretch clean, or its stale delivery record would
2947
3042
  // suppress the next turn's issue-#453 result-text fallback.
2948
3043
  session.emittedAssistantText = false;
2949
- turn.reject(error);
3044
+ turn.reject(error, title);
2950
3045
  };
2951
3046
  /** Complete a negotiated terminal failure on the prompt response itself,
2952
3047
  * which is the canonical AIR carrier. Legacy clients keep the historical
@@ -2970,7 +3065,7 @@ export class ClaudeAcpAgent {
2970
3065
  return;
2971
3066
  }
2972
3067
  if (!supportsAirSessionFailures(this.clientCapabilities)) {
2973
- failActive(error);
3068
+ failActive(error, title);
2974
3069
  return;
2975
3070
  }
2976
3071
  if (!session.activeTurn || session.activeTurn.settled) {
@@ -2980,7 +3075,7 @@ export class ClaudeAcpAgent {
2980
3075
  }
2981
3076
  const failure = await createSessionFailure(kind, { title });
2982
3077
  if (!failure) {
2983
- failActive(error);
3078
+ failActive(error, title);
2984
3079
  return;
2985
3080
  }
2986
3081
  sessionFailures.recordActive(failure);
@@ -3365,17 +3460,18 @@ 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 },
3474
+ messageId: messageIdForGrouping(message),
3379
3475
  },
3380
3476
  });
3381
3477
  break;
@@ -3534,11 +3630,12 @@ export class ClaudeAcpAgent {
3534
3630
  // list with this payload. Forward message.commands directly —
3535
3631
  // it's authoritative, and re-querying supportedCommands()
3536
3632
  // would just return the same list with an extra round-trip.
3633
+ session.skillPaths = new Map();
3537
3634
  await sendUpdate({
3538
3635
  sessionId: params.sessionId,
3539
3636
  update: {
3540
3637
  sessionUpdate: "available_commands_update",
3541
- availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
3638
+ availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, session.skillPaths),
3542
3639
  },
3543
3640
  });
3544
3641
  break;
@@ -4310,6 +4407,7 @@ export class ClaudeAcpAgent {
4310
4407
  update: {
4311
4408
  sessionUpdate: "agent_message_chunk",
4312
4409
  content: { type: "text", text: lastRefusalExplanation },
4410
+ messageId: messageIdForGrouping(message),
4313
4411
  },
4314
4412
  });
4315
4413
  }
@@ -4384,10 +4482,13 @@ export class ClaudeAcpAgent {
4384
4482
  (message.usage.output_tokens ?? 0) === 0 &&
4385
4483
  !session.activeTurn?.noticeTexts?.includes(normalizeNoticeText(message.result)));
4386
4484
  if (shouldForwardResult) {
4387
- const usageMarkdown = await takeUsageMarkdown(message.result);
4388
- if (usageMarkdown === null)
4485
+ const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
4486
+ const commandMarkdown = await takeLocalCommandMarkdown(message.result);
4487
+ if (commandTurn?.localCommand && session.cancelled)
4389
4488
  break;
4390
- for (const notification of toAcpNotifications(usageMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
4489
+ if (commandMarkdown === null)
4490
+ break;
4491
+ for (const notification of toAcpNotifications(commandMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4391
4492
  await sendUpdate(notification);
4392
4493
  }
4393
4494
  }
@@ -4780,16 +4881,16 @@ export class ClaudeAcpAgent {
4780
4881
  // Depending on the Claude Code build, a local command can arrive
4781
4882
  // as the dedicated system message above or as a synthetic
4782
4883
  // assistant message. Replace only the output owned by the exact
4783
- // /usage turn; no content signatures or text parsing are involved.
4884
+ // /usage or /mcp turn; no content signatures or text parsing are involved.
4784
4885
  if (message.type === "assistant" &&
4785
4886
  message.parent_tool_use_id === null &&
4786
4887
  message.message.model === "<synthetic>") {
4787
- const usageMarkdown = await takeUsageMarkdown(assistantMessageText(message.message) ?? "");
4888
+ const commandMarkdown = await takeLocalCommandMarkdown(assistantMessageText(message.message) ?? "");
4788
4889
  if (session.cancelled)
4789
4890
  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) })) {
4891
+ if (commandMarkdown !== undefined) {
4892
+ if (commandMarkdown !== null) {
4893
+ for (const notification of toAcpNotifications(commandMarkdown, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4793
4894
  await sendUpdate(notification);
4794
4895
  }
4795
4896
  }
@@ -5089,7 +5190,7 @@ export class ClaudeAcpAgent {
5089
5190
  }
5090
5191
  session.cancelled = true;
5091
5192
  for (const turn of session.turnQueue ?? [])
5092
- turn.usageMarkdownAbort?.abort();
5193
+ turn.localCommandAbort?.abort();
5093
5194
  session.pendingExitPlanModeInterruption = undefined;
5094
5195
  session.pendingExitPlanContextReset = undefined;
5095
5196
  // The stream already ended (see closeQueryStream): every in-flight turn was
@@ -5531,13 +5632,15 @@ export class ClaudeAcpAgent {
5531
5632
  await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
5532
5633
  return { configOptions: session.configOptions };
5533
5634
  }
5534
- async replaySessionHistory(sessionId, resumedMessages) {
5635
+ async replaySessionHistory(sessionId, resumedMessages, pending) {
5535
5636
  const replayStartedAt = performance.now();
5536
5637
  const toolUseCache = {};
5537
5638
  const messages = resumedMessages ?? (await getSessionMessages(sessionId));
5538
5639
  const historyLoadedAt = performance.now();
5539
5640
  this.logger.log(`[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`);
5540
- const session = this.sessions[sessionId];
5641
+ // A pending replay has no session record yet. It uses the state that the record takes later.
5642
+ const replayState = () => pending?.state ?? this.sessions[sessionId];
5643
+ const session = replayState();
5541
5644
  // A replay rebuilds the client view, so its plan goes out again.
5542
5645
  if (session?.taskState)
5543
5646
  forgetPublishedTaskPlan(session.taskState);
@@ -5549,7 +5652,12 @@ export class ClaudeAcpAgent {
5549
5652
  sessionId,
5550
5653
  state: session.sessionFailureState,
5551
5654
  capabilities: this.clientCapabilities,
5552
- isCurrent: () => this.sessions[sessionId] === session,
5655
+ isCurrent: () => {
5656
+ const live = this.sessions[sessionId];
5657
+ if (!live)
5658
+ return pending !== undefined && !pending.stopped;
5659
+ return live.sessionFailureState === session.sessionFailureState;
5660
+ },
5553
5661
  sendUpdate: (notification) => this.client.sessionUpdate(notification),
5554
5662
  logger: this.logger,
5555
5663
  })
@@ -5704,7 +5812,7 @@ export class ClaudeAcpAgent {
5704
5812
  await replayAsyncTasks.taskNotification(notification);
5705
5813
  };
5706
5814
  const replayMessage = async (message) => {
5707
- if (isReplayHiddenMetaMessage(message)) {
5815
+ if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
5708
5816
  return;
5709
5817
  }
5710
5818
  if (message.type === "user" &&
@@ -5718,7 +5826,7 @@ export class ClaudeAcpAgent {
5718
5826
  // a client-supplied id without an extra getSessionMessages read. Not read
5719
5827
  // yet (see Session.messageIdToUuid).
5720
5828
  const replayMessageId = messageIdForGrouping(message);
5721
- const replaySession = this.sessions[sessionId];
5829
+ const replaySession = replayState();
5722
5830
  if (replaySession && replayMessageId && message.uuid) {
5723
5831
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
5724
5832
  }
@@ -5812,8 +5920,8 @@ export class ClaudeAcpAgent {
5812
5920
  registerHooks: false,
5813
5921
  replay: true,
5814
5922
  clientCapabilities: this.clientCapabilities,
5815
- cwd: this.sessions[sessionId]?.cwd,
5816
- taskState: this.sessions[sessionId]?.taskState,
5923
+ cwd: replayState()?.cwd,
5924
+ taskState: replayState()?.taskState,
5817
5925
  messageId: replayMessageId,
5818
5926
  parentToolUseId,
5819
5927
  })) {
@@ -5858,7 +5966,7 @@ export class ClaudeAcpAgent {
5858
5966
  };
5859
5967
  for (const message of messages)
5860
5968
  await replayMessage(message);
5861
- if (nativeReplayEnabled) {
5969
+ if (nativeReplayEnabled && !pending?.stopped) {
5862
5970
  // Claude history persists sidechain messages and Agent/Task tool uses,
5863
5971
  // but not task_started/task_updated lifecycle frames. Recover terminal
5864
5972
  // state from the launch tool_result. Missing results and malformed or
@@ -6292,7 +6400,7 @@ export class ClaudeAcpAgent {
6292
6400
  sessionId,
6293
6401
  update: {
6294
6402
  sessionUpdate: "available_commands_update",
6295
- availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
6403
+ availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, (session.skillPaths ??= new Map())),
6296
6404
  },
6297
6405
  });
6298
6406
  }
@@ -6613,6 +6721,8 @@ export class ClaudeAcpAgent {
6613
6721
  }
6614
6722
  async getOrCreateSession(params, resumedSession) {
6615
6723
  const existingSession = this.sessions[params.sessionId];
6724
+ // A recreated live session keeps its mode, not the mode of the transcript.
6725
+ const livePermissionMode = existingSession?.modes.currentModeId;
6616
6726
  if (existingSession) {
6617
6727
  const fingerprint = computeSessionFingerprint(params);
6618
6728
  if (fingerprint === existingSession.sessionFingerprint) {
@@ -6628,9 +6738,6 @@ export class ClaudeAcpAgent {
6628
6738
  // up the new values.
6629
6739
  await this.teardownSession(params.sessionId);
6630
6740
  }
6631
- const resumedModelHint = resumedSession
6632
- ? resumedSession.model
6633
- : await readResumedModel(params.sessionId, this.logger);
6634
6741
  const response = await this.createSession({
6635
6742
  cwd: params.cwd,
6636
6743
  mcpServers: params.mcpServers ?? [],
@@ -6638,7 +6745,8 @@ export class ClaudeAcpAgent {
6638
6745
  _meta: params._meta,
6639
6746
  }, {
6640
6747
  resume: params.sessionId,
6641
- resumedModelHint,
6748
+ permissionMode: livePermissionMode,
6749
+ ...(resumedSession ? { resumedModelHint: resumedSession.model } : {}),
6642
6750
  });
6643
6751
  return {
6644
6752
  sessionId: response.sessionId,
@@ -6667,6 +6775,12 @@ export class ClaudeAcpAgent {
6667
6775
  throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
6668
6776
  }
6669
6777
  }
6778
+ /** Whether the session forwards the text and the thinking of a subagent. */
6779
+ forwardsSubagentText(meta) {
6780
+ return (clientSupportsSubagents(this.clientCapabilities) ||
6781
+ supportsSubagentTranscript(this.clientCapabilities) ||
6782
+ meta?.claudeCode?.options?.forwardSubagentText === true);
6783
+ }
6670
6784
  async createSession(params, creationOpts = {}) {
6671
6785
  const createStartedAt = performance.now();
6672
6786
  // Validate `cwd` up front. The ACP spec requires an absolute path, and the
@@ -6704,10 +6818,15 @@ export class ClaudeAcpAgent {
6704
6818
  // sign-out respawn, provider rerouting), so fill the same fast local hint
6705
6819
  // here when the caller did not provide it. Never fall back to the slow
6706
6820
  // getContextUsage control request.
6821
+ // The same tail read gives the permission mode of the resumed session.
6707
6822
  let resumedModelHint = creationOpts.resumedModelHint;
6708
- if (creationOpts.resume !== undefined &&
6709
- !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint")) {
6710
- resumedModelHint = await readResumedModel(creationOpts.resume, this.logger);
6823
+ let resumedPermissionMode;
6824
+ if (creationOpts.resume !== undefined) {
6825
+ const withModel = !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint");
6826
+ const tail = await readResumedTail(creationOpts.resume, this.logger, withModel);
6827
+ if (withModel)
6828
+ resumedModelHint = tail.model;
6829
+ resumedPermissionMode = tail.permissionMode;
6711
6830
  timing.phase("resume-transcript");
6712
6831
  }
6713
6832
  const input = new Pushable();
@@ -6769,7 +6888,11 @@ export class ClaudeAcpAgent {
6769
6888
  const allowBypass = ALLOW_BYPASS &&
6770
6889
  settingsManager.getSettings().permissions?.disableBypassPermissionsMode !== "disable" &&
6771
6890
  sessionMeta?.claudeCode?.options?.allowDangerouslySkipPermissions !== false;
6772
- const initialPermissionMode = resolvePermissionMode(creationOpts.permissionMode ?? settingsManager.getSettings().permissions?.defaultMode, this.logger, allowBypass);
6891
+ const initialPermissionMode = resolveInitialPermissionMode({
6892
+ explicit: creationOpts.permissionMode,
6893
+ resumed: resumedPermissionMode,
6894
+ defaultMode: settingsManager.getSettings().permissions?.defaultMode,
6895
+ }, this.logger, allowBypass);
6773
6896
  const userProvidedOptions = sessionMeta?.claudeCode?.options
6774
6897
  ? { ...sessionMeta.claudeCode.options }
6775
6898
  : undefined;
@@ -6778,9 +6901,7 @@ export class ClaudeAcpAgent {
6778
6901
  // hidden state that the Client cannot inspect or change.
6779
6902
  if (userProvidedOptions)
6780
6903
  delete userProvidedOptions.agent;
6781
- const forwardSubagentText = clientSupportsSubagents(this.clientCapabilities) ||
6782
- supportsSubagentTranscript(this.clientCapabilities) ||
6783
- userProvidedOptions?.forwardSubagentText === true;
6904
+ const forwardSubagentText = this.forwardsSubagentText(params._meta);
6784
6905
  // Configure thinking behavior from environment variable
6785
6906
  const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
6786
6907
  // Parse model configuration from environment (e.g. Bedrock model overrides)
@@ -6806,7 +6927,7 @@ export class ClaudeAcpAgent {
6806
6927
  // Per-session task state. Created here (rather than in the session record
6807
6928
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
6808
6929
  // the same Map that the streaming message handler will read from.
6809
- const taskState = new Map();
6930
+ const taskState = creationOpts.replayState?.taskState ?? new Map();
6810
6931
  // Resolve every workspace root once. The native checkpoint report uses
6811
6932
  // this same set for lexical path validation, and the SDK receives it below.
6812
6933
  const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
@@ -7099,7 +7220,7 @@ export class ClaudeAcpAgent {
7099
7220
  const allowedModels = Array.isArray(settingsAvailableModels)
7100
7221
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, settingsManager.getManagedDeniedModels())
7101
7222
  : initializationResult.models;
7102
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, resumedModelHint);
7223
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, await resumedModelHint);
7103
7224
  timing.phase("models");
7104
7225
  // Resolve the current model's capabilities separately from the stable
7105
7226
  // permission-mode catalog advertised to ACP clients.
@@ -7248,8 +7369,8 @@ export class ClaudeAcpAgent {
7248
7369
  nativeSubagentParentByToolUseId: new Map(),
7249
7370
  emittedAssistantText: false,
7250
7371
  owedTrailingIdles: 0,
7251
- messageIdToUuid: new Map(),
7252
- sessionFailureState: createSessionFailureState(),
7372
+ messageIdToUuid: creationOpts.replayState?.messageIdToUuid ?? new Map(),
7373
+ sessionFailureState: creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(),
7253
7374
  claudeSubscriptionGuard,
7254
7375
  accountKind: fromAccountInfo(initializationResult.account)?.kind,
7255
7376
  fileChangeReporter,
@@ -7290,7 +7411,10 @@ export class ClaudeAcpAgent {
7290
7411
  this.closeQueryStream(session);
7291
7412
  delete this.sessions[sessionId];
7292
7413
  try {
7293
- await this.createSession(session.creationParams, { resume: sessionId });
7414
+ await this.createSession(session.creationParams, {
7415
+ resume: sessionId,
7416
+ permissionMode: session.modes.currentModeId,
7417
+ });
7294
7418
  }
7295
7419
  catch (error) {
7296
7420
  // One session that cannot come back must not abort the switch. The
@@ -7771,7 +7895,20 @@ function getAvailableSlashCommands(commands,
7771
7895
  // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
7772
7896
  // the CLI's own terminal, which ACP clients aren't) — filtered alongside
7773
7897
  // the static list. Raw CLI names, matched before the MCP rename.
7774
- terminalCommands) {
7898
+ terminalCommands,
7899
+ // The session cwd of an AIR client, or undefined for every other client.
7900
+ // AIR gets the kind and the SKILL.md path of each skill command.
7901
+ airSkillCwd,
7902
+ // The resolved SKILL.md paths of the session, keyed by the cwd and the name.
7903
+ skillPaths) {
7904
+ const skillPath = (name, cwd) => {
7905
+ const key = `${cwd}\0${name}`;
7906
+ if (skillPaths?.has(key))
7907
+ return skillPaths.get(key);
7908
+ const resolved = resolveSkillPath(name, cwd);
7909
+ skillPaths?.set(key, resolved);
7910
+ return resolved;
7911
+ };
7775
7912
  const UNSUPPORTED_COMMANDS = [
7776
7913
  "clear",
7777
7914
  "cost",
@@ -7782,8 +7919,10 @@ terminalCommands) {
7782
7919
  "release-notes",
7783
7920
  "todos",
7784
7921
  ];
7785
- return commands
7786
- .filter((command) => !terminalCommands?.includes(command.name))
7922
+ // The adapter replaces the terminal text of `/mcp` and runs its actions,
7923
+ // even when Claude Code tags the command terminal-bound.
7924
+ const advertised = commands
7925
+ .filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name))
7787
7926
  .map((command) => {
7788
7927
  const input = command.argumentHint
7789
7928
  ? {
@@ -7793,16 +7932,26 @@ terminalCommands) {
7793
7932
  }
7794
7933
  : null;
7795
7934
  let name = command.name;
7796
- if (command.name.endsWith(" (MCP)")) {
7935
+ const mcpPrompt = command.name.endsWith(" (MCP)");
7936
+ if (mcpPrompt) {
7797
7937
  name = `mcp:${name.replace(" (MCP)", "")}`;
7798
7938
  }
7939
+ const skillMdPath = airSkillCwd && !command.builtin && !mcpPrompt
7940
+ ? skillPath(command.name, airSkillCwd)
7941
+ : undefined;
7799
7942
  return {
7800
7943
  name,
7801
7944
  description: command.description || "",
7802
7945
  input,
7946
+ ...(skillMdPath
7947
+ ? {
7948
+ _meta: withAirMeta(withAirMeta(undefined, AIR_KIND_KEY, "skill"), AIR_SKILL_PATH_KEY, skillMdPath),
7949
+ }
7950
+ : {}),
7803
7951
  };
7804
7952
  })
7805
7953
  .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
7954
+ return [...advertised, MCP_AVAILABLE_COMMAND];
7806
7955
  }
7807
7956
  function formatUriAsLink(uri) {
7808
7957
  try {