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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acp-agent.js CHANGED
@@ -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,7 +1858,72 @@ 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
+ }
1902
+ /**
1903
+ * Serves an ACP v1 `session/prompt`, which answers when the turn ends. The
1904
+ * outcome of a turn is field for field a v1 prompt response.
1905
+ */
1815
1906
  async prompt(params) {
1907
+ let events;
1908
+ const outcome = new Promise((resolve, reject) => {
1909
+ events = {
1910
+ inserted() { },
1911
+ awaitingUser() { },
1912
+ resumed() { },
1913
+ ended: resolve,
1914
+ failed: reject,
1915
+ };
1916
+ });
1917
+ await this.startTurn(params, events);
1918
+ return outcome;
1919
+ }
1920
+ /**
1921
+ * Starts a turn for a prompt. Resolves once the prompt is queued for Claude
1922
+ * Code, and rejects only when the prompt is refused before that. From then
1923
+ * on, `events` reports how the turn goes (see {@link TurnEvents}), possibly
1924
+ * before this resolves.
1925
+ */
1926
+ async startTurn(params, events) {
1816
1927
  if (this.providerUpdate)
1817
1928
  await this.providerUpdate;
1818
1929
  let session = this.sessions[params.sessionId];
@@ -1854,45 +1965,41 @@ export class ClaudeAcpAgent {
1854
1965
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
1855
1966
  const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
1856
1967
  const fileChangeReport = session.fileChangeReporter?.request(params._meta);
1857
- const isUsageCommand = params.prompt.length === 1 &&
1858
- params.prompt[0]?.type === "text" &&
1859
- isUsageCommandText(params.prompt[0].text);
1860
- const usageMarkdownAbort = isUsageCommand ? new AbortController() : undefined;
1968
+ const localCommand = this.localCommandMarkdown(params);
1969
+ const localCommandAbort = localCommand ? new AbortController() : undefined;
1861
1970
  session.titles.onPrompt(params.prompt);
1862
- // Each prompt is a Turn whose deferred the persistent consumer settles once
1863
- // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
1864
- // turn, pushes the user message onto the streaming input, makes sure the
1865
- // consumer is running, and awaits the deferred.
1971
+ // Each prompt is a Turn that the persistent consumer settles once the
1972
+ // turn's outcome is known. `startTurn()` owns no loop: it enqueues the
1973
+ // turn, pushes the user message onto the streaming input, and makes sure
1974
+ // the consumer is running.
1975
+ let completeTurn;
1866
1976
  const turn = {
1867
1977
  promptUuid,
1978
+ events,
1868
1979
  isLocalOnlyCommand,
1869
- ...(isUsageCommand ? { isUsageCommand: true } : {}),
1870
- ...(usageMarkdownAbort ? { usageMarkdownAbort } : {}),
1980
+ ...(localCommand ? { localCommand } : {}),
1981
+ ...(localCommandAbort ? { localCommandAbort } : {}),
1871
1982
  ...(fileChangeReport ? { fileChangeReport } : {}),
1872
1983
  settled: false,
1873
- resolve: () => { },
1874
- reject: () => { },
1875
- };
1876
- let completeTurn;
1877
- turn.completion = new Promise((resolve) => {
1878
- completeTurn = resolve;
1879
- });
1880
- const response = new Promise((resolve, reject) => {
1881
- turn.resolve = (result) => {
1882
- resolve(result);
1984
+ completion: new Promise((resolve) => {
1985
+ completeTurn = resolve;
1986
+ }),
1987
+ resolve: (outcome) => {
1988
+ events.ended(outcome);
1883
1989
  completeTurn();
1884
- };
1885
- turn.reject = (error) => {
1886
- reject(error);
1990
+ },
1991
+ reject: (error, title) => {
1992
+ events.failed(error, title);
1887
1993
  completeTurn();
1888
- };
1889
- });
1994
+ },
1995
+ };
1890
1996
  session.turnQueue ??= [];
1891
1997
  session.turnQueue.push(turn);
1892
1998
  session.input.push(userMessage);
1893
1999
  this.ensureConsumer(session, params.sessionId);
1894
- await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid);
1895
- return response;
2000
+ // The prompt is queued, so its turn goes ahead even if the client misses
2001
+ // the goal it sets; the turn's events report the outcome.
2002
+ await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid).catch((error) => this.logger.error(`Session ${params.sessionId}: failed to publish the prompt's goal:`, error));
1896
2003
  }
1897
2004
  /** `--hide-claude-auth` applies only to the CLI's own login. A provider
1898
2005
  * override (`providers/set` or gateway `authenticate`) routes traffic away
@@ -1993,7 +2100,10 @@ export class ClaudeAcpAgent {
1993
2100
  try {
1994
2101
  // `resume` names the Claude session, which shares the ACP session id, so
1995
2102
  // the new query continues the same conversation under the same id.
1996
- await this.createSession(creationParams, { resume: sessionId });
2103
+ await this.createSession(creationParams, {
2104
+ resume: sessionId,
2105
+ permissionMode: session.modes.currentModeId,
2106
+ });
1997
2107
  }
1998
2108
  catch (error) {
1999
2109
  if (error instanceof RequestError && error.code === RequestError.resourceNotFound().code) {
@@ -2003,7 +2113,10 @@ export class ClaudeAcpAgent {
2003
2113
  // better answer than a session the client can never use again.
2004
2114
  this.logger.log(`session ${sessionId} was never persisted; starting a fresh query under the same id`);
2005
2115
  try {
2006
- await this.createSession(creationParams, { reuseSessionId: sessionId });
2116
+ await this.createSession(creationParams, {
2117
+ reuseSessionId: sessionId,
2118
+ permissionMode: session.modes.currentModeId,
2119
+ });
2007
2120
  return;
2008
2121
  }
2009
2122
  catch (fallbackError) {
@@ -2465,11 +2578,11 @@ export class ClaudeAcpAgent {
2465
2578
  };
2466
2579
  };
2467
2580
  const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
2468
- const ensureUsageMarkdown = (turn) => {
2469
- if (!turn.isUsageCommand || !turn.usageMarkdownAbort)
2581
+ const ensureLocalCommandMarkdown = (turn, originalOutput = "") => {
2582
+ if (!turn.localCommand || !turn.localCommandAbort)
2470
2583
  return undefined;
2471
- turn.usageMarkdown ??= structuredUsageMarkdown(session.query, turn.usageMarkdownAbort.signal, this.logger);
2472
- return turn.usageMarkdown;
2584
+ turn.localCommandMarkdown ??= turn.localCommand.produce(session.query, turn.localCommandAbort.signal, originalOutput);
2585
+ return turn.localCommandMarkdown;
2473
2586
  };
2474
2587
  const resetTurnScratch = () => {
2475
2588
  lastAssistantTotalUsage = null;
@@ -2512,7 +2625,8 @@ export class ClaudeAcpAgent {
2512
2625
  session.activeTurn = turn;
2513
2626
  session.cancelled = false;
2514
2627
  compaction.resume();
2515
- ensureUsageMarkdown(turn);
2628
+ if (turn.localCommand?.startsAtActivation)
2629
+ ensureLocalCommandMarkdown(turn);
2516
2630
  session.pendingOrphanResults = 0;
2517
2631
  session.orphanCommands?.clear();
2518
2632
  // Two-phase sweep of registry entries the level signal ended (see
@@ -2540,6 +2654,22 @@ export class ClaudeAcpAgent {
2540
2654
  }
2541
2655
  }
2542
2656
  resetTurnScratch();
2657
+ // Without a command_lifecycle "started" frame before it (see that
2658
+ // handler), activation is the first sign that Claude Code took the
2659
+ // prompt in: its echo, or for an echo-less command, its result.
2660
+ reportInserted(turn);
2661
+ // A request can already be open: Claude Code asks for a queued prompt
2662
+ // before the consumer reaches its echo.
2663
+ this.syncAwaitingUser(session);
2664
+ };
2665
+ /** Report that Claude Code took the turn's prompt in, once. A turn can be
2666
+ * settled by now: an echo hand-off awaits settling the previous turn, and
2667
+ * a cancel in that window settles the queued one. */
2668
+ const reportInserted = (turn) => {
2669
+ if (turn.insertedReported || turn.settled)
2670
+ return;
2671
+ turn.insertedReported = true;
2672
+ turn.events.inserted(turn.promptUuid);
2543
2673
  };
2544
2674
  /** Ensure there is an active turn before a user-turn result that carries no
2545
2675
  * echo to activate it, by promoting the queue head. Most turns are
@@ -2699,27 +2829,28 @@ export class ClaudeAcpAgent {
2699
2829
  * the autonomous stretch-close guard. */
2700
2830
  const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
2701
2831
  /** Claim the structured replacement for the turn currently producing a
2702
- * local-command output. Undefined means this is not a structured usage
2703
- * turn (or its request failed), null means another SDK message shape
2704
- * already delivered it, and string is the one replacement to publish. */
2705
- const takeUsageMarkdown = async (originalOutput) => {
2832
+ * local-command output. Undefined means this is not a structured
2833
+ * local-command turn (or its request failed), null means another SDK
2834
+ * message shape already delivered it, and string is the one replacement
2835
+ * to publish. */
2836
+ const takeLocalCommandMarkdown = async (originalOutput) => {
2706
2837
  const turn = session.activeTurn ?? firstUnsettledQueuedTurn();
2707
2838
  if (!turn)
2708
2839
  return undefined;
2709
- const usageMarkdown = ensureUsageMarkdown(turn);
2710
- if (!usageMarkdown)
2840
+ const pending = ensureLocalCommandMarkdown(turn, originalOutput);
2841
+ if (!pending)
2711
2842
  return undefined;
2712
- const markdown = await usageMarkdown;
2843
+ const markdown = await pending;
2713
2844
  if (markdown === null)
2714
2845
  return undefined;
2715
- if (turn.usageMarkdownDelivered) {
2846
+ if (turn.localCommandDelivered) {
2716
2847
  // Different SDK message shapes can mirror the same local-command
2717
2848
  // output. Suppress an exact mirror, but let a later, distinct frame
2718
2849
  // (for example an interruption diagnostic) follow the normal path.
2719
- return turn.usageOriginalOutput === originalOutput ? null : undefined;
2850
+ return turn.localCommandOriginalOutput === originalOutput ? null : undefined;
2720
2851
  }
2721
- turn.usageMarkdownDelivered = true;
2722
- turn.usageOriginalOutput = originalOutput;
2852
+ turn.localCommandDelivered = true;
2853
+ turn.localCommandOriginalOutput = originalOutput;
2723
2854
  return markdown;
2724
2855
  };
2725
2856
  /** Registers a live background task (see `liveBackgroundTasks`). A
@@ -2872,7 +3003,9 @@ export class ClaudeAcpAgent {
2872
3003
  // Captured before the settled flip below (isHeldOpen tests !settled).
2873
3004
  const wasHeld = isHeldOpen(turn);
2874
3005
  turn.settled = true;
2875
- turn.usageMarkdownAbort?.abort();
3006
+ turn.localCommandAbort?.abort();
3007
+ if (turn.localCommandDelivered)
3008
+ turn.localCommand?.afterDelivery?.();
2876
3009
  disarmForceCancel(session);
2877
3010
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
2878
3011
  session.activeTurn = null;
@@ -2896,7 +3029,7 @@ export class ClaudeAcpAgent {
2896
3029
  };
2897
3030
  /** Reject the active turn (auth required, error result, …) without tearing
2898
3031
  * down the consumer: the stream continues to idle and later turns proceed. */
2899
- const failActive = (error) => {
3032
+ const failActive = (error, title) => {
2900
3033
  disarmForceCancel(session);
2901
3034
  const turn = session.activeTurn;
2902
3035
  if (!turn || turn.settled) {
@@ -2913,7 +3046,7 @@ export class ClaudeAcpAgent {
2913
3046
  // start the next stretch clean, or its stale delivery record would
2914
3047
  // suppress the next turn's issue-#453 result-text fallback.
2915
3048
  session.emittedAssistantText = false;
2916
- turn.reject(error);
3049
+ turn.reject(error, title);
2917
3050
  };
2918
3051
  /** Complete a negotiated terminal failure on the prompt response itself,
2919
3052
  * which is the canonical AIR carrier. Legacy clients keep the historical
@@ -2937,7 +3070,7 @@ export class ClaudeAcpAgent {
2937
3070
  return;
2938
3071
  }
2939
3072
  if (!supportsAirSessionFailures(this.clientCapabilities)) {
2940
- failActive(error);
3073
+ failActive(error, title);
2941
3074
  return;
2942
3075
  }
2943
3076
  if (!session.activeTurn || session.activeTurn.settled) {
@@ -2947,7 +3080,7 @@ export class ClaudeAcpAgent {
2947
3080
  }
2948
3081
  const failure = await createSessionFailure(kind, { title });
2949
3082
  if (!failure) {
2950
- failActive(error);
3083
+ failActive(error, title);
2951
3084
  return;
2952
3085
  }
2953
3086
  sessionFailures.recordActive(failure);
@@ -3051,7 +3184,7 @@ export class ClaudeAcpAgent {
3051
3184
  }
3052
3185
  }
3053
3186
  await compaction.interrupt();
3054
- await settleActive(turnOutcome(session, "cancelled"));
3187
+ await settleActive(cancelledOutcome(session, session.activeTurn));
3055
3188
  // The cancelled turn's result may never come (that's why the
3056
3189
  // backstop fired) — close its delivery stretch here so partial
3057
3190
  // streamed text can't suppress the next turn's issue-#453 fallback.
@@ -3107,7 +3240,7 @@ export class ClaudeAcpAgent {
3107
3240
  const inFlight = session.activeTurn;
3108
3241
  await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "at end of stream");
3109
3242
  await settleActive(session.cancelled
3110
- ? turnOutcome(session, "cancelled")
3243
+ ? cancelledOutcome(session, inFlight)
3111
3244
  : (inFlight?.deferredSettle ?? turnOutcome(session, stopReason)));
3112
3245
  // Queued turns the SDK never started never ran, so reject them rather
3113
3246
  // than reporting a success (end_turn) — or a misleading "cancelled" —
@@ -3142,10 +3275,10 @@ export class ClaudeAcpAgent {
3142
3275
  // prompt() stamps a uuid on every message. The frame is @internal and
3143
3276
  // absent from the SDKMessage union, so handle it BEFORE the exhaustive
3144
3277
  // switch: it must not reach `unreachable`'s error log, and a `case`
3145
- // for it wouldn't typecheck. It feeds only the orphan accounting (see
3146
- // Session.orphanCommands); turn settlement stays driven by
3147
- // echoes/results/idle. (Raw-mode emission above still forwards these
3148
- // frames.)
3278
+ // for it wouldn't typecheck. It feeds the orphan accounting (see
3279
+ // Session.orphanCommands) and reports a turn inserted at "started";
3280
+ // turn activation and settlement stay driven by echoes/results/idle.
3281
+ // (Raw-mode emission above still forwards these frames.)
3149
3282
  if (message.type === "command_lifecycle") {
3150
3283
  const frame = message;
3151
3284
  switch (frame.state) {
@@ -3156,6 +3289,17 @@ export class ClaudeAcpAgent {
3156
3289
  if (queued) {
3157
3290
  queued.commandStarted = true;
3158
3291
  compaction.resume();
3292
+ // Claude Code took the prompt in: it drained into a turn. A
3293
+ // fresh turn reports that before anything it produces, its echo
3294
+ // and first stream events included, so this is the earliest
3295
+ // insertion point. (A prompt folded into a running turn reports
3296
+ // it after its echo, which already activated it.) While another
3297
+ // turn is still active (held for background work, steered, or
3298
+ // cancelled and awaiting its trailing idle), insertion waits
3299
+ // for this turn's echo, whose hand-off ends that turn first.
3300
+ const active = session.activeTurn;
3301
+ if (!active || active.settled || active === queued)
3302
+ reportInserted(queued);
3159
3303
  }
3160
3304
  // ...and promote an already-orphaned command: once dispatched,
3161
3305
  // a bare `cancelled` no longer means "dropped without running".
@@ -3332,17 +3476,18 @@ export class ClaudeAcpAgent {
3332
3476
  if (compaction.consumeDuplicateErrorOutput(message.content)) {
3333
3477
  break;
3334
3478
  }
3335
- const usageTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
3336
- const usageMarkdown = await takeUsageMarkdown(message.content);
3337
- if (usageTurn?.isUsageCommand && session.cancelled)
3479
+ const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
3480
+ const commandMarkdown = await takeLocalCommandMarkdown(message.content);
3481
+ if (commandTurn?.localCommand && session.cancelled)
3338
3482
  break;
3339
- if (usageMarkdown === null)
3483
+ if (commandMarkdown === null)
3340
3484
  break;
3341
3485
  await sendUpdate({
3342
3486
  sessionId: params.sessionId,
3343
3487
  update: {
3344
3488
  sessionUpdate: "agent_message_chunk",
3345
- content: { type: "text", text: usageMarkdown ?? message.content },
3489
+ content: { type: "text", text: commandMarkdown ?? message.content },
3490
+ messageId: messageIdForGrouping(message),
3346
3491
  },
3347
3492
  });
3348
3493
  break;
@@ -3399,7 +3544,13 @@ export class ClaudeAcpAgent {
3399
3544
  // the interrupted turn's tokens entirely (issue #844). Zero
3400
3545
  // when the cancel pre-empted the result (wedge/force-cancel).
3401
3546
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
3402
- await settleActive(turnOutcome(session, "cancelled"));
3547
+ // A held turn's result passed long ago; this idle is the
3548
+ // trailer of the followup cycle the cancel interrupted,
3549
+ // whose result (an autonomous one) recorded it as owed.
3550
+ if (isHeldOpen(session.activeTurn) && session.owedTrailingIdles > 0) {
3551
+ session.owedTrailingIdles--;
3552
+ }
3553
+ await settleActive(cancelledOutcome(session, session.activeTurn));
3403
3554
  // An interrupt can pre-empt the turn's result entirely
3404
3555
  // (nothing ran the result-case `finally`), so close the
3405
3556
  // delivery stretch here: idle is the SDK's authoritative
@@ -3501,11 +3652,12 @@ export class ClaudeAcpAgent {
3501
3652
  // list with this payload. Forward message.commands directly —
3502
3653
  // it's authoritative, and re-querying supportedCommands()
3503
3654
  // would just return the same list with an extra round-trip.
3655
+ session.skillPaths = new Map();
3504
3656
  await sendUpdate({
3505
3657
  sessionId: params.sessionId,
3506
3658
  update: {
3507
3659
  sessionUpdate: "available_commands_update",
3508
- availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
3660
+ availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, session.skillPaths),
3509
3661
  },
3510
3662
  });
3511
3663
  break;
@@ -4277,6 +4429,7 @@ export class ClaudeAcpAgent {
4277
4429
  update: {
4278
4430
  sessionUpdate: "agent_message_chunk",
4279
4431
  content: { type: "text", text: lastRefusalExplanation },
4432
+ messageId: messageIdForGrouping(message),
4280
4433
  },
4281
4434
  });
4282
4435
  }
@@ -4351,10 +4504,13 @@ export class ClaudeAcpAgent {
4351
4504
  (message.usage.output_tokens ?? 0) === 0 &&
4352
4505
  !session.activeTurn?.noticeTexts?.includes(normalizeNoticeText(message.result)));
4353
4506
  if (shouldForwardResult) {
4354
- const usageMarkdown = await takeUsageMarkdown(message.result);
4355
- if (usageMarkdown === null)
4507
+ const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
4508
+ const commandMarkdown = await takeLocalCommandMarkdown(message.result);
4509
+ if (commandTurn?.localCommand && session.cancelled)
4510
+ break;
4511
+ if (commandMarkdown === null)
4356
4512
  break;
4357
- for (const notification of toAcpNotifications(usageMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
4513
+ for (const notification of toAcpNotifications(commandMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4358
4514
  await sendUpdate(notification);
4359
4515
  }
4360
4516
  }
@@ -4636,7 +4792,7 @@ export class ClaudeAcpAgent {
4636
4792
  session.owedTrailingIdles++;
4637
4793
  // Before activateTurn resets the accumulator, so the
4638
4794
  // usage still belongs to the cancelled turn.
4639
- await settleActive(turnOutcome(session, "cancelled"));
4795
+ await settleActive(cancelledOutcome(session, session.activeTurn));
4640
4796
  }
4641
4797
  else if (isHeldOpen(session.activeTurn)) {
4642
4798
  // A turn held open for its background subagents (see
@@ -4747,16 +4903,16 @@ export class ClaudeAcpAgent {
4747
4903
  // Depending on the Claude Code build, a local command can arrive
4748
4904
  // as the dedicated system message above or as a synthetic
4749
4905
  // assistant message. Replace only the output owned by the exact
4750
- // /usage turn; no content signatures or text parsing are involved.
4906
+ // /usage or /mcp turn; no content signatures or text parsing are involved.
4751
4907
  if (message.type === "assistant" &&
4752
4908
  message.parent_tool_use_id === null &&
4753
4909
  message.message.model === "<synthetic>") {
4754
- const usageMarkdown = await takeUsageMarkdown(assistantMessageText(message.message) ?? "");
4910
+ const commandMarkdown = await takeLocalCommandMarkdown(assistantMessageText(message.message) ?? "");
4755
4911
  if (session.cancelled)
4756
4912
  break;
4757
- if (usageMarkdown !== undefined) {
4758
- if (usageMarkdown !== null) {
4759
- for (const notification of toAcpNotifications(usageMarkdown, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4913
+ if (commandMarkdown !== undefined) {
4914
+ if (commandMarkdown !== null) {
4915
+ for (const notification of toAcpNotifications(commandMarkdown, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4760
4916
  await sendUpdate(notification);
4761
4917
  }
4762
4918
  }
@@ -5040,6 +5196,36 @@ export class ClaudeAcpAgent {
5040
5196
  session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
5041
5197
  }
5042
5198
  }
5199
+ /** Ask Claude Code to drop user messages it still has queued, so that a
5200
+ * prompt a cancel settled is not run after all. Claude Code keeps queued
5201
+ * messages through an interrupt and runs them next (the receipt's
5202
+ * `still_queued`), unless they are cancelled first with
5203
+ * `cancel_async_message`: a no-op for a message it already dequeued, which
5204
+ * then still runs, as an orphan (see `orphanCommands`).
5205
+ *
5206
+ * Not awaited: the SDK writes control requests in call order, so the drops
5207
+ * reach Claude Code before a request sent after them, and a wedged CLI must
5208
+ * not hold up the cancel. The orphan accounting needs no update here: a
5209
+ * dropped message gets a `cancelled` lifecycle frame and is missing from the
5210
+ * interrupt receipt's `still_queued`, which both already account for. The
5211
+ * SDK implements the request as `Query.cancelAsyncMessage` without typing
5212
+ * it, so it is looked up, and a CLI that lacks it rejects the request. */
5213
+ dropQueuedMessages(sessionId, query, uuids) {
5214
+ const cancelAsyncMessage = query
5215
+ .cancelAsyncMessage;
5216
+ if (typeof cancelAsyncMessage !== "function")
5217
+ return;
5218
+ const logFailure = (uuid, error) => this.logger.error(`Session ${sessionId}: could not drop the queued message ${uuid} from Claude Code:`, error);
5219
+ for (const uuid of uuids) {
5220
+ // Called synchronously: deferring it would let the interrupt go first.
5221
+ try {
5222
+ Promise.resolve(cancelAsyncMessage.call(query, uuid)).catch((error) => logFailure(uuid, error));
5223
+ }
5224
+ catch (error) {
5225
+ logFailure(uuid, error);
5226
+ }
5227
+ }
5228
+ }
5043
5229
  async cancel(params) {
5044
5230
  await this.cancelTurns(params, { awaitInterrupt: true });
5045
5231
  }
@@ -5056,7 +5242,7 @@ export class ClaudeAcpAgent {
5056
5242
  }
5057
5243
  session.cancelled = true;
5058
5244
  for (const turn of session.turnQueue ?? [])
5059
- turn.usageMarkdownAbort?.abort();
5245
+ turn.localCommandAbort?.abort();
5060
5246
  session.pendingExitPlanModeInterruption = undefined;
5061
5247
  session.pendingExitPlanContextReset = undefined;
5062
5248
  // The stream already ended (see closeQueryStream): every in-flight turn was
@@ -5085,12 +5271,16 @@ export class ClaudeAcpAgent {
5085
5271
  session.toolCallFields?.clear();
5086
5272
  clearHookCallbacks(params.sessionId);
5087
5273
  }
5274
+ // The user messages this cancel abandons while Claude Code may still have
5275
+ // them queued: they are dropped from its queue before the interrupt.
5276
+ const abandoned = [];
5088
5277
  // A priority steer may still be queued in the SDK when cancellation
5089
5278
  // settles its owning turn. Its later echo matches no live turn, and its
5090
5279
  // result must be skipped rather than promoted onto the next prompt.
5091
5280
  if (isSteering(session.activeTurn)) {
5092
5281
  for (const uuid of session.activeTurn.steeredEchoes) {
5093
5282
  this.trackOrphanCommand(session, uuid, "pending");
5283
+ abandoned.push(uuid);
5094
5284
  }
5095
5285
  }
5096
5286
  // Capture the orphan-accounting lane before anything can await: the
@@ -5114,11 +5304,13 @@ export class ClaudeAcpAgent {
5114
5304
  // accumulator (the active turn's tally) is not its spend.
5115
5305
  turn.resolve({ stopReason: "cancelled" });
5116
5306
  orphanedTurns.push(turn);
5307
+ abandoned.push(turn.promptUuid);
5117
5308
  }
5118
5309
  }
5119
- // Each removed queued turn's user message was already pushed to the SDK,
5120
- // which processes input FIFO and will still emit a result for it with no
5121
- // user echo to match (0.3.246+ CLIs do stamp results with the
5310
+ // Each removed queued turn's user message was already pushed to the SDK.
5311
+ // It is dropped from Claude Code's queue below (see dropQueuedMessages),
5312
+ // but one Claude Code already dequeued still runs, FIFO, and emits a
5313
+ // result with no user echo to match (0.3.246+ CLIs do stamp results with the
5122
5314
  // triggering send's user_message_uuid, which ensureActiveTurn uses as
5123
5315
  // an exact join when present). Track those so the consumer skips them
5124
5316
  // (see ensureActiveTurn) rather than misattributing them to the head.
@@ -5181,15 +5373,23 @@ export class ClaudeAcpAgent {
5181
5373
  }
5182
5374
  // A deferred active turn (see Turn.deferredSettle) already has its result
5183
5375
  // and is only held open for subagents. A settling turn likewise has its
5184
- // result but is awaiting the bounded checkpoint preview. Settle either one
5185
- // "cancelled" NOW: the consumer cannot process the interrupt's trailing
5186
- // idle while blocked in that preview, and a held turn may already be idle.
5187
- // The captured outcome preserves usage and metadata (issue #844), while
5188
- // reporter state makes a late checkpoint response harmless.
5376
+ // result but is awaiting the bounded checkpoint preview. Settle it
5377
+ // "cancelled" NOW where the consumer cannot: it cannot process the
5378
+ // interrupt's trailing idle while blocked in that preview, and a held turn
5379
+ // whose session sits idle gets no trailing idle (the interrupt emits
5380
+ // nothing; with no state events consumed yet, the same is assumed).
5381
+ // A held turn whose followup cycle is live (running, or blocked on a
5382
+ // permission request) is left to that idle, like any live turn: the
5383
+ // interrupt still flushes the cycle's output, which must reach the client
5384
+ // before the turn ends cancelled. The captured outcome preserves usage and
5385
+ // metadata (issue #844), while reporter state makes a late checkpoint
5386
+ // response harmless.
5189
5387
  {
5190
5388
  const active = session.activeTurn;
5191
- const pendingOutcome = active?.deferredSettle ?? active?.settlingOutcome;
5192
- if (active && pendingOutcome && (isHeldOpen(active) || active.settling)) {
5389
+ const followupLive = session.lastSessionState === "running" || session.lastSessionState === "requires_action";
5390
+ if (active &&
5391
+ (active.settling || (isHeldOpen(active) && !followupLive)) &&
5392
+ (active.deferredSettle ?? active.settlingOutcome)) {
5193
5393
  session.fileChangeReporter?.finish(active.fileChangeReport, "cancelled");
5194
5394
  active.settled = true;
5195
5395
  // Mirror settleActive's invariants (it is consumer-scoped and
@@ -5203,17 +5403,16 @@ export class ClaudeAcpAgent {
5203
5403
  // text since the last boundary was its followups', and left latched
5204
5404
  // it would suppress a following replayed turn's issue-#453 fallback.
5205
5405
  session.emittedAssistantText = false;
5206
- // When the interrupt below pre-empts a live cycle — running, or
5207
- // blocked on a permission request (requires_action, the #866 shape
5208
- // users cancel out of) — it produces a trailer idle with no counted
5209
- // result; with the hold's own trailer typically already absorbed,
5210
- // that idle would be un-owed and could lag past the next prompt's
5211
- // echo — read as the fresh turn ending without a result (issue #825
5212
- // false-fail). Pre-count it unless the session sits idle: there the
5213
- // interrupt emits nothing, and a debt that never drains would mask
5214
- // one future #825 detection. (lastSessionState is last-CONSUMED, so
5215
- // both stale reads exist and both are accepted one-cycle windows: a
5216
- // running transition still in the backlog reads as stale idle and
5406
+ // When the interrupt below pre-empts a live cycle (a settling turn's,
5407
+ // or one no state event reported), it produces a trailer idle with no
5408
+ // counted result; that idle would be un-owed and could lag past the
5409
+ // next prompt's echo — read as the fresh turn ending without a result
5410
+ // (issue #825 false-fail).
5411
+ // Pre-count it unless the session sits idle: there the interrupt
5412
+ // emits nothing, and a debt that never drains would mask one future
5413
+ // #825 detection. (lastSessionState is last-CONSUMED, so both stale
5414
+ // reads exist and both are accepted one-cycle windows: a running
5415
+ // transition still in the backlog reads as stale idle and
5217
5416
  // under-counts — that false-fail additionally needs the trailer to
5218
5417
  // lag past the next echo — while a cycle already completed into the
5219
5418
  // backlog reads as stale non-idle and over-counts, masking one
@@ -5223,14 +5422,7 @@ export class ClaudeAcpAgent {
5223
5422
  if (session.lastSessionState !== "idle") {
5224
5423
  session.owedTrailingIdles++;
5225
5424
  }
5226
- // Carries the held outcome's `_meta` too: a deferred outcome's only
5227
- // metadata is its quota breakdown, the counterpart of the usage taken
5228
- // from it here.
5229
- active.resolve({
5230
- stopReason: "cancelled",
5231
- usage: pendingOutcome.usage,
5232
- ...(pendingOutcome._meta ? { _meta: pendingOutcome._meta } : {}),
5233
- });
5425
+ active.resolve(cancelledOutcome(session, active));
5234
5426
  }
5235
5427
  }
5236
5428
  // A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
@@ -5263,6 +5455,9 @@ export class ClaudeAcpAgent {
5263
5455
  cancelController.abort();
5264
5456
  }, this.forceCancelGraceMs);
5265
5457
  }
5458
+ // Before the interrupt: once it stops the running turn, Claude Code starts
5459
+ // the next queued message right away.
5460
+ this.dropQueuedMessages(params.sessionId, session.query, abandoned);
5266
5461
  const interrupt = session.query.interrupt();
5267
5462
  if (!options.awaitInterrupt) {
5268
5463
  // The caller closes the query next, which rejects the pending reply.
@@ -5498,13 +5693,15 @@ export class ClaudeAcpAgent {
5498
5693
  await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
5499
5694
  return { configOptions: session.configOptions };
5500
5695
  }
5501
- async replaySessionHistory(sessionId, resumedMessages) {
5696
+ async replaySessionHistory(sessionId, resumedMessages, pending) {
5502
5697
  const replayStartedAt = performance.now();
5503
5698
  const toolUseCache = {};
5504
5699
  const messages = resumedMessages ?? (await getSessionMessages(sessionId));
5505
5700
  const historyLoadedAt = performance.now();
5506
5701
  this.logger.log(`[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`);
5507
- const session = this.sessions[sessionId];
5702
+ // A pending replay has no session record yet. It uses the state that the record takes later.
5703
+ const replayState = () => pending?.state ?? this.sessions[sessionId];
5704
+ const session = replayState();
5508
5705
  // A replay rebuilds the client view, so its plan goes out again.
5509
5706
  if (session?.taskState)
5510
5707
  forgetPublishedTaskPlan(session.taskState);
@@ -5516,7 +5713,12 @@ export class ClaudeAcpAgent {
5516
5713
  sessionId,
5517
5714
  state: session.sessionFailureState,
5518
5715
  capabilities: this.clientCapabilities,
5519
- isCurrent: () => this.sessions[sessionId] === session,
5716
+ isCurrent: () => {
5717
+ const live = this.sessions[sessionId];
5718
+ if (!live)
5719
+ return pending !== undefined && !pending.stopped;
5720
+ return live.sessionFailureState === session.sessionFailureState;
5721
+ },
5520
5722
  sendUpdate: (notification) => this.client.sessionUpdate(notification),
5521
5723
  logger: this.logger,
5522
5724
  })
@@ -5671,7 +5873,7 @@ export class ClaudeAcpAgent {
5671
5873
  await replayAsyncTasks.taskNotification(notification);
5672
5874
  };
5673
5875
  const replayMessage = async (message) => {
5674
- if (isReplayHiddenMetaMessage(message)) {
5876
+ if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
5675
5877
  return;
5676
5878
  }
5677
5879
  if (message.type === "user" &&
@@ -5685,7 +5887,7 @@ export class ClaudeAcpAgent {
5685
5887
  // a client-supplied id without an extra getSessionMessages read. Not read
5686
5888
  // yet (see Session.messageIdToUuid).
5687
5889
  const replayMessageId = messageIdForGrouping(message);
5688
- const replaySession = this.sessions[sessionId];
5890
+ const replaySession = replayState();
5689
5891
  if (replaySession && replayMessageId && message.uuid) {
5690
5892
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
5691
5893
  }
@@ -5779,8 +5981,8 @@ export class ClaudeAcpAgent {
5779
5981
  registerHooks: false,
5780
5982
  replay: true,
5781
5983
  clientCapabilities: this.clientCapabilities,
5782
- cwd: this.sessions[sessionId]?.cwd,
5783
- taskState: this.sessions[sessionId]?.taskState,
5984
+ cwd: replayState()?.cwd,
5985
+ taskState: replayState()?.taskState,
5784
5986
  messageId: replayMessageId,
5785
5987
  parentToolUseId,
5786
5988
  })) {
@@ -5825,7 +6027,7 @@ export class ClaudeAcpAgent {
5825
6027
  };
5826
6028
  for (const message of messages)
5827
6029
  await replayMessage(message);
5828
- if (nativeReplayEnabled) {
6030
+ if (nativeReplayEnabled && !pending?.stopped) {
5829
6031
  // Claude history persists sidechain messages and Agent/Task tool uses,
5830
6032
  // but not task_started/task_updated lifecycle frames. Recover terminal
5831
6033
  // state from the launch tool_result. Missing results and malformed or
@@ -5862,18 +6064,40 @@ export class ClaudeAcpAgent {
5862
6064
  /** Mark a client request as blocking on user input for exactly the lifetime
5863
6065
  * of its promise. Steering consults this session-local count synchronously,
5864
6066
  * so a message arriving while any permission/elicitation card is open uses
5865
- * non-interrupting SDK delivery. */
6067
+ * non-interrupting SDK delivery. The active turn reports that it awaits the
6068
+ * user while any such request is open (see `syncAwaitingUser`). */
5866
6069
  async withPendingUserInput(sessionId, request) {
5867
6070
  const session = this.sessions[sessionId];
5868
6071
  if (!session)
5869
6072
  return request();
5870
6073
  session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
6074
+ this.syncAwaitingUser(session);
5871
6075
  try {
5872
6076
  return await request();
5873
6077
  }
5874
6078
  finally {
5875
6079
  session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
5876
- }
6080
+ this.syncAwaitingUser(session);
6081
+ }
6082
+ }
6083
+ /** Report whether the session's active turn awaits the user: exactly while
6084
+ * a permission request or question is open in the session. A request does
6085
+ * not say which prompt it is for, and Claude Code can ask for a queued
6086
+ * prompt before the consumer activates it, so a request is not tied to the
6087
+ * turn that was active when it opened. Called when the count changes and
6088
+ * when a turn activates. */
6089
+ syncAwaitingUser(session) {
6090
+ const turn = session.activeTurn;
6091
+ if (!turn || turn.settled || !turn.insertedReported)
6092
+ return;
6093
+ const waiting = (session.pendingUserInputCount ?? 0) > 0;
6094
+ if (waiting === (turn.awaitingUser ?? false))
6095
+ return;
6096
+ turn.awaitingUser = waiting;
6097
+ if (waiting)
6098
+ turn.events.awaitingUser();
6099
+ else
6100
+ turn.events.resumed();
5877
6101
  }
5878
6102
  /** Forward a permission request to the client, wiring the tool call's
5879
6103
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
@@ -6237,7 +6461,7 @@ export class ClaudeAcpAgent {
6237
6461
  sessionId,
6238
6462
  update: {
6239
6463
  sessionUpdate: "available_commands_update",
6240
- availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
6464
+ availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands, this.toolCallCapabilities.air.client ? session.cwd : undefined, (session.skillPaths ??= new Map())),
6241
6465
  },
6242
6466
  });
6243
6467
  }
@@ -6558,6 +6782,8 @@ export class ClaudeAcpAgent {
6558
6782
  }
6559
6783
  async getOrCreateSession(params, resumedSession) {
6560
6784
  const existingSession = this.sessions[params.sessionId];
6785
+ // A recreated live session keeps its mode, not the mode of the transcript.
6786
+ const livePermissionMode = existingSession?.modes.currentModeId;
6561
6787
  if (existingSession) {
6562
6788
  const fingerprint = computeSessionFingerprint(params);
6563
6789
  if (fingerprint === existingSession.sessionFingerprint) {
@@ -6573,9 +6799,6 @@ export class ClaudeAcpAgent {
6573
6799
  // up the new values.
6574
6800
  await this.teardownSession(params.sessionId);
6575
6801
  }
6576
- const resumedModelHint = resumedSession
6577
- ? resumedSession.model
6578
- : await readResumedModel(params.sessionId, this.logger);
6579
6802
  const response = await this.createSession({
6580
6803
  cwd: params.cwd,
6581
6804
  mcpServers: params.mcpServers ?? [],
@@ -6583,7 +6806,8 @@ export class ClaudeAcpAgent {
6583
6806
  _meta: params._meta,
6584
6807
  }, {
6585
6808
  resume: params.sessionId,
6586
- resumedModelHint,
6809
+ permissionMode: livePermissionMode,
6810
+ ...(resumedSession ? { resumedModelHint: resumedSession.model } : {}),
6587
6811
  });
6588
6812
  return {
6589
6813
  sessionId: response.sessionId,
@@ -6612,6 +6836,12 @@ export class ClaudeAcpAgent {
6612
6836
  throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
6613
6837
  }
6614
6838
  }
6839
+ /** Whether the session forwards the text and the thinking of a subagent. */
6840
+ forwardsSubagentText(meta) {
6841
+ return (clientSupportsSubagents(this.clientCapabilities) ||
6842
+ supportsSubagentTranscript(this.clientCapabilities) ||
6843
+ meta?.claudeCode?.options?.forwardSubagentText === true);
6844
+ }
6615
6845
  async createSession(params, creationOpts = {}) {
6616
6846
  const createStartedAt = performance.now();
6617
6847
  // Validate `cwd` up front. The ACP spec requires an absolute path, and the
@@ -6649,10 +6879,15 @@ export class ClaudeAcpAgent {
6649
6879
  // sign-out respawn, provider rerouting), so fill the same fast local hint
6650
6880
  // here when the caller did not provide it. Never fall back to the slow
6651
6881
  // getContextUsage control request.
6882
+ // The same tail read gives the permission mode of the resumed session.
6652
6883
  let resumedModelHint = creationOpts.resumedModelHint;
6653
- if (creationOpts.resume !== undefined &&
6654
- !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint")) {
6655
- resumedModelHint = await readResumedModel(creationOpts.resume, this.logger);
6884
+ let resumedPermissionMode;
6885
+ if (creationOpts.resume !== undefined) {
6886
+ const withModel = !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint");
6887
+ const tail = await readResumedTail(creationOpts.resume, this.logger, withModel);
6888
+ if (withModel)
6889
+ resumedModelHint = tail.model;
6890
+ resumedPermissionMode = tail.permissionMode;
6656
6891
  timing.phase("resume-transcript");
6657
6892
  }
6658
6893
  const input = new Pushable();
@@ -6714,7 +6949,11 @@ export class ClaudeAcpAgent {
6714
6949
  const allowBypass = ALLOW_BYPASS &&
6715
6950
  settingsManager.getSettings().permissions?.disableBypassPermissionsMode !== "disable" &&
6716
6951
  sessionMeta?.claudeCode?.options?.allowDangerouslySkipPermissions !== false;
6717
- const initialPermissionMode = resolvePermissionMode(creationOpts.permissionMode ?? settingsManager.getSettings().permissions?.defaultMode, this.logger, allowBypass);
6952
+ const initialPermissionMode = resolveInitialPermissionMode({
6953
+ explicit: creationOpts.permissionMode,
6954
+ resumed: resumedPermissionMode,
6955
+ defaultMode: settingsManager.getSettings().permissions?.defaultMode,
6956
+ }, this.logger, allowBypass);
6718
6957
  const userProvidedOptions = sessionMeta?.claudeCode?.options
6719
6958
  ? { ...sessionMeta.claudeCode.options }
6720
6959
  : undefined;
@@ -6723,9 +6962,7 @@ export class ClaudeAcpAgent {
6723
6962
  // hidden state that the Client cannot inspect or change.
6724
6963
  if (userProvidedOptions)
6725
6964
  delete userProvidedOptions.agent;
6726
- const forwardSubagentText = clientSupportsSubagents(this.clientCapabilities) ||
6727
- supportsSubagentTranscript(this.clientCapabilities) ||
6728
- userProvidedOptions?.forwardSubagentText === true;
6965
+ const forwardSubagentText = this.forwardsSubagentText(params._meta);
6729
6966
  // Configure thinking behavior from environment variable
6730
6967
  const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
6731
6968
  // Parse model configuration from environment (e.g. Bedrock model overrides)
@@ -6751,7 +6988,7 @@ export class ClaudeAcpAgent {
6751
6988
  // Per-session task state. Created here (rather than in the session record
6752
6989
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
6753
6990
  // the same Map that the streaming message handler will read from.
6754
- const taskState = new Map();
6991
+ const taskState = creationOpts.replayState?.taskState ?? new Map();
6755
6992
  // Resolve every workspace root once. The native checkpoint report uses
6756
6993
  // this same set for lexical path validation, and the SDK receives it below.
6757
6994
  const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
@@ -7044,7 +7281,7 @@ export class ClaudeAcpAgent {
7044
7281
  const allowedModels = Array.isArray(settingsAvailableModels)
7045
7282
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, settingsManager.getManagedDeniedModels())
7046
7283
  : initializationResult.models;
7047
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, resumedModelHint);
7284
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, await resumedModelHint);
7048
7285
  timing.phase("models");
7049
7286
  // Resolve the current model's capabilities separately from the stable
7050
7287
  // permission-mode catalog advertised to ACP clients.
@@ -7193,8 +7430,8 @@ export class ClaudeAcpAgent {
7193
7430
  nativeSubagentParentByToolUseId: new Map(),
7194
7431
  emittedAssistantText: false,
7195
7432
  owedTrailingIdles: 0,
7196
- messageIdToUuid: new Map(),
7197
- sessionFailureState: createSessionFailureState(),
7433
+ messageIdToUuid: creationOpts.replayState?.messageIdToUuid ?? new Map(),
7434
+ sessionFailureState: creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(),
7198
7435
  claudeSubscriptionGuard,
7199
7436
  accountKind: fromAccountInfo(initializationResult.account)?.kind,
7200
7437
  fileChangeReporter,
@@ -7235,7 +7472,10 @@ export class ClaudeAcpAgent {
7235
7472
  this.closeQueryStream(session);
7236
7473
  delete this.sessions[sessionId];
7237
7474
  try {
7238
- await this.createSession(session.creationParams, { resume: sessionId });
7475
+ await this.createSession(session.creationParams, {
7476
+ resume: sessionId,
7477
+ permissionMode: session.modes.currentModeId,
7478
+ });
7239
7479
  }
7240
7480
  catch (error) {
7241
7481
  // One session that cannot come back must not abort the switch. The
@@ -7329,6 +7569,20 @@ function turnOutcome(session, stopReason, extraMeta) {
7329
7569
  _meta: { ...turnQuotaMeta(session), ...extraMeta },
7330
7570
  };
7331
7571
  }
7572
+ /** The outcome of `turn` when a cancel ends it. A turn that already has its
7573
+ * result, because it is held open for its background subagents (see
7574
+ * Turn.deferredSettle) or is settling (see Turn.settlingOutcome), keeps the
7575
+ * usage and metadata that result recorded (issue #844). */
7576
+ function cancelledOutcome(session, turn) {
7577
+ const recorded = turn?.deferredSettle ?? turn?.settlingOutcome;
7578
+ if (!recorded)
7579
+ return turnOutcome(session, "cancelled");
7580
+ return {
7581
+ stopReason: "cancelled",
7582
+ usage: recorded.usage,
7583
+ ...(recorded._meta ? { _meta: recorded._meta } : {}),
7584
+ };
7585
+ }
7332
7586
  /** `_meta.quota` for a prompt response: what the turn spent, shaped like
7333
7587
  * codex-acp's (snake_case container keys, camelCase counters) so a client
7334
7588
  * reads one shape from either agent.
@@ -7716,7 +7970,20 @@ function getAvailableSlashCommands(commands,
7716
7970
  // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
7717
7971
  // the CLI's own terminal, which ACP clients aren't) — filtered alongside
7718
7972
  // the static list. Raw CLI names, matched before the MCP rename.
7719
- terminalCommands) {
7973
+ terminalCommands,
7974
+ // The session cwd of an AIR client, or undefined for every other client.
7975
+ // AIR gets the kind and the SKILL.md path of each skill command.
7976
+ airSkillCwd,
7977
+ // The resolved SKILL.md paths of the session, keyed by the cwd and the name.
7978
+ skillPaths) {
7979
+ const skillPath = (name, cwd) => {
7980
+ const key = `${cwd}\0${name}`;
7981
+ if (skillPaths?.has(key))
7982
+ return skillPaths.get(key);
7983
+ const resolved = resolveSkillPath(name, cwd);
7984
+ skillPaths?.set(key, resolved);
7985
+ return resolved;
7986
+ };
7720
7987
  const UNSUPPORTED_COMMANDS = [
7721
7988
  "clear",
7722
7989
  "cost",
@@ -7727,8 +7994,10 @@ terminalCommands) {
7727
7994
  "release-notes",
7728
7995
  "todos",
7729
7996
  ];
7730
- return commands
7731
- .filter((command) => !terminalCommands?.includes(command.name))
7997
+ // The adapter replaces the terminal text of `/mcp` and runs its actions,
7998
+ // even when Claude Code tags the command terminal-bound.
7999
+ const advertised = commands
8000
+ .filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name))
7732
8001
  .map((command) => {
7733
8002
  const input = command.argumentHint
7734
8003
  ? {
@@ -7738,16 +8007,26 @@ terminalCommands) {
7738
8007
  }
7739
8008
  : null;
7740
8009
  let name = command.name;
7741
- if (command.name.endsWith(" (MCP)")) {
8010
+ const mcpPrompt = command.name.endsWith(" (MCP)");
8011
+ if (mcpPrompt) {
7742
8012
  name = `mcp:${name.replace(" (MCP)", "")}`;
7743
8013
  }
8014
+ const skillMdPath = airSkillCwd && !command.builtin && !mcpPrompt
8015
+ ? skillPath(command.name, airSkillCwd)
8016
+ : undefined;
7744
8017
  return {
7745
8018
  name,
7746
8019
  description: command.description || "",
7747
8020
  input,
8021
+ ...(skillMdPath
8022
+ ? {
8023
+ _meta: withAirMeta(withAirMeta(undefined, AIR_KIND_KEY, "skill"), AIR_SKILL_PATH_KEY, skillMdPath),
8024
+ }
8025
+ : {}),
7748
8026
  };
7749
8027
  })
7750
8028
  .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
8029
+ return [...advertised, MCP_AVAILABLE_COMMAND];
7751
8030
  }
7752
8031
  function formatUriAsLink(uri) {
7753
8032
  try {