@agentclientprotocol/claude-agent-acp 0.89.2-preview.9 → 0.90.1-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +56 -8
  2. package/dist/acp-agent.d.ts +50 -1
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +493 -147
  5. package/dist/acp-subagents.d.ts +16 -6
  6. package/dist/acp-subagents.d.ts.map +1 -1
  7. package/dist/acp-subagents.js +8 -2
  8. package/dist/async-tasks.d.ts +4 -1
  9. package/dist/async-tasks.d.ts.map +1 -1
  10. package/dist/auth-status.d.ts +2 -2
  11. package/dist/auth-status.d.ts.map +1 -1
  12. package/dist/auth-status.js +18 -4
  13. package/dist/exit-plan.d.ts +17 -0
  14. package/dist/exit-plan.d.ts.map +1 -1
  15. package/dist/exit-plan.js +34 -0
  16. package/dist/native-subagents.d.ts +228 -11
  17. package/dist/native-subagents.d.ts.map +1 -1
  18. package/dist/native-subagents.js +489 -40
  19. package/dist/settings.d.ts +6 -0
  20. package/dist/settings.d.ts.map +1 -1
  21. package/dist/settings.js +13 -0
  22. package/dist/subagent-history.d.ts +44 -2
  23. package/dist/subagent-history.d.ts.map +1 -1
  24. package/dist/subagent-history.js +116 -10
  25. package/dist/subagent-replay.d.ts +108 -0
  26. package/dist/subagent-replay.d.ts.map +1 -0
  27. package/dist/subagent-replay.js +261 -0
  28. package/dist/tool-calls/background.d.ts +9 -0
  29. package/dist/tool-calls/background.d.ts.map +1 -1
  30. package/dist/tool-calls/background.js +25 -0
  31. package/dist/tool-calls/renderer.d.ts +30 -9
  32. package/dist/tool-calls/renderer.d.ts.map +1 -1
  33. package/dist/tool-calls/renderer.js +48 -22
  34. package/dist/tool-calls/reporters/bash.d.ts +7 -0
  35. package/dist/tool-calls/reporters/bash.d.ts.map +1 -1
  36. package/dist/tool-calls/reporters/bash.js +16 -1
  37. package/dist/tool-calls/reporters/search.d.ts.map +1 -1
  38. package/dist/tool-calls/reporters/search.js +20 -2
  39. package/dist/v2/agent.d.ts.map +1 -1
  40. package/dist/v2/agent.js +45 -12
  41. package/dist/v2/initialize.d.ts.map +1 -1
  42. package/dist/v2/initialize.js +10 -1
  43. package/dist/v2/session-update.d.ts +11 -0
  44. package/dist/v2/session-update.d.ts.map +1 -1
  45. package/dist/v2/session-update.js +67 -0
  46. package/dist/v2/session.d.ts +3 -12
  47. package/dist/v2/session.d.ts.map +1 -1
  48. package/dist/v2/session.js +5 -18
  49. package/dist/v2/tool-call-meta.d.ts +44 -0
  50. package/dist/v2/tool-call-meta.d.ts.map +1 -0
  51. package/dist/v2/tool-call-meta.js +72 -0
  52. package/package.json +9 -9
package/dist/acp-agent.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { agent as acpAgent, CreateElicitationResponse, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
- import { getSessionMessages, getSubagentMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
2
+ import { filterEscalatingDefaultMode, getSessionMessages, listSessions, query, resolveSettings, } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
4
4
  import { sanitizeTitle, SessionTitles } from "./session-titles.js";
5
- import { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, asSdkSessionNotification, clientSupportsSubagents, } from "./acp-subagents.js";
6
- import { isNativeSubagentControlTool, isNativeSubagentControlUpdate, NativeSubagentRuntime, nativeSubagentState, resumedNativeSubagentId, sendMessageResumePrompt, } from "./native-subagents.js";
5
+ import { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, asSdkSessionNotification, clientSupportsSubagents, subagentForm, } from "./acp-subagents.js";
6
+ import { isNativeSubagentControlTool, isNativeSubagentControlUpdate, NativeSubagentRuntime, nativeSubagentState, resumedNativeSubagentId, sendMessageResume, sendMessageResumePrompt, } from "./native-subagents.js";
7
7
  import { AIR_ASYNC_TASKS_CAPABILITY, AIR_CUSTOM_INSTRUCTIONS_CAPABILITY, airCustomInstructions, 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 * as sessionIndex from "./session-index/connection.js";
9
9
  import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, backgroundedBashToolCallIds, clientSupportsAsyncTasks, } from "./async-tasks.js";
@@ -16,7 +16,8 @@ import { promisify } from "node:util";
16
16
  import packageJson from "../package.json" with { type: "json" };
17
17
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
18
18
  import { forkSession } from "./fork-session.js";
19
- import { subagentHistory } from "./subagent-history.js";
19
+ import { readSubagentMessages, subagentHistory, subagentLaunchIds, subagentLineage, } from "./subagent-history.js";
20
+ import { RfdSubagentReplay, replaySubagentTerminalState, } from "./subagent-replay.js";
20
21
  import { readResumedSession, readResumedTail, } from "./resumed-session.js";
21
22
  import { SessionTiming } from "./session-timing.js";
22
23
  import { ALLOW_BYPASS } from "./permissions/modes.js";
@@ -32,14 +33,14 @@ import { ContextCompactionLifecycle, clientSupportsCompactionUpdates, contextCom
32
33
  import { clientSupportsNotices, MAX_NOTICE_TITLE_LENGTH, normalizeNoticeText, noticeOrTranscriptUpdate, sentenceCase, splitNoticeText, } from "./session-notices.js";
33
34
  import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, hasHookCallback, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, changedTaskPlanEntries, forgetPublishedTaskPlan, unregisterHookCallback, } from "./tools.js";
34
35
  import { previewPatchContent } from "./diff.js";
35
- import { backgroundedBashToolCall } from "./tool-calls/background.js";
36
+ import { backgroundedBashToolCall, withRunningTerminal } from "./tool-calls/background.js";
36
37
  import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
37
38
  import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
38
39
  import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
39
40
  import { AcpToolCallRenderer, isFinalToolCallStatus, } from "./tool-calls/renderer.js";
40
41
  import { resolveSkillPath } from "./tool-calls/reporters/interaction.js";
41
42
  import { messageLoopYielder, nodeToWebReadable, nodeToWebWritable, Pushable, raceTimeoutAndAbort, unreachable, } from "./utils.js";
42
- import { acceptedPlanToolResult, ExitPlanCoordinator, isExitPlanInterruptionResult, observeExitPlanToolResults, } from "./exit-plan.js";
43
+ import { acceptedPlanToolResult, approvedPlan, ExitPlanCoordinator, isExitPlanInterruptionResult, observeExitPlanToolResults, planModeUpdate, } from "./exit-plan.js";
43
44
  import { DEFAULT_AGENT_ID } from "./session-config-ids.js";
44
45
  import { parseToolResultMeta } from "./tool-result-meta.js";
45
46
  import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
@@ -536,40 +537,6 @@ function parentToolUseIdOf(message) {
536
537
  return null;
537
538
  return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null;
538
539
  }
539
- /** The ids of the Agent and Task tool uses in the content of a message. */
540
- function subagentLaunchIds(content) {
541
- if (!Array.isArray(content))
542
- return [];
543
- return content.flatMap((block) => typeof block === "object" &&
544
- block !== null &&
545
- block.type === "tool_use" &&
546
- typeof block.id === "string" &&
547
- isNativeSubagentControlTool(block.name)
548
- ? [block.id]
549
- : []);
550
- }
551
- function replaySubagentTerminalState(block) {
552
- if (block.is_error !== true)
553
- return "completed";
554
- const text = replayContentText(block.content).toLowerCase();
555
- return /\b(?:cancelled|canceled|interrupted|stopped|killed)\b/.test(text)
556
- ? "cancelled"
557
- : "failed";
558
- }
559
- function replayContentText(value, seen = new Set()) {
560
- if (typeof value === "string")
561
- return value;
562
- if (typeof value !== "object" || value === null || seen.has(value))
563
- return "";
564
- seen.add(value);
565
- if (Array.isArray(value))
566
- return value.map((item) => replayContentText(item, seen)).join(" ");
567
- const record = value;
568
- return [record.text, record.content, record.message]
569
- .map((item) => replayContentText(item, seen))
570
- .filter(Boolean)
571
- .join(" ");
572
- }
573
540
  function stripSubagentTextAndThinking(content) {
574
541
  if (!Array.isArray(content))
575
542
  return content;
@@ -1172,6 +1139,8 @@ export class ClaudeAcpAgent {
1172
1139
  /** Last auth identity reported to the client, connection-scoped like
1173
1140
  * `authenticate`/`logout`. Undefined means "not determined yet". */
1174
1141
  currentAuthStatus;
1142
+ authStatusSettings;
1143
+ authStatusHelper;
1175
1144
  /** In-flight `claude auth status --json` probe, shared by every caller so
1176
1145
  * a concurrent `initialize` and start-of-prompt read never spawn two CLI
1177
1146
  * processes. */
@@ -1232,6 +1201,7 @@ export class ClaudeAcpAgent {
1232
1201
  },
1233
1202
  applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
1234
1203
  sessionUpdate: (notification) => this.client.sessionUpdate(notification),
1204
+ supportsPlanOperations: () => this.clientCapabilities?.plan != null,
1235
1205
  ensureConsumer: (session, id) => this.ensureConsumer(session, id),
1236
1206
  logError: (message, error) => this.logger.error(message, error),
1237
1207
  destroyReplacement: (id, session) => {
@@ -1378,6 +1348,8 @@ export class ClaudeAcpAgent {
1378
1348
  terminalAuthMethods.push(consoleLoginMethod);
1379
1349
  }
1380
1350
  }
1351
+ // The subagents RFD has only the client capability. AIR's draft also had
1352
+ // this agent capability, which AIR gets (`docs/air-extensions.md`).
1381
1353
  const sessionCapabilities = {
1382
1354
  additionalDirectories: {},
1383
1355
  close: {},
@@ -1385,7 +1357,7 @@ export class ClaudeAcpAgent {
1385
1357
  fork: {},
1386
1358
  list: {},
1387
1359
  resume: {},
1388
- subagents: {},
1360
+ ...(this.toolCallCapabilities.air.client ? { subagents: {} } : {}),
1389
1361
  };
1390
1362
  return {
1391
1363
  protocolVersion: 1,
@@ -1506,14 +1478,30 @@ export class ClaudeAcpAgent {
1506
1478
  configOptions: response.configOptions,
1507
1479
  };
1508
1480
  }
1481
+ /**
1482
+ * Copies the session's transcript into a new session, then starts that
1483
+ * session, so the client can prompt it as soon as the response arrives, as
1484
+ * the schema requires of a fork. It starts like a resumed session: from the
1485
+ * copied transcript, with the model and the permission mode it records. A
1486
+ * later load or resume with the same settings reuses it.
1487
+ */
1509
1488
  async unstable_forkSession(params) {
1510
1489
  if (this.providerUpdate)
1511
1490
  await this.providerUpdate;
1512
- return forkSession(params, {
1491
+ const { sessionId } = await forkSession(params, {
1513
1492
  liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
1514
1493
  logger: this.logger,
1515
1494
  messageIdForGrouping,
1516
1495
  });
1496
+ const { modes, configOptions } = await this.getOrCreateSession({
1497
+ sessionId,
1498
+ cwd: params.cwd,
1499
+ mcpServers: params.mcpServers,
1500
+ additionalDirectories: params.additionalDirectories,
1501
+ _meta: params._meta,
1502
+ });
1503
+ this.afterSetupResponse(sessionId, params.mcpServers ?? []);
1504
+ return { sessionId, modes, configOptions };
1517
1505
  }
1518
1506
  async resumeSession(params) {
1519
1507
  if (this.providerUpdate)
@@ -1682,7 +1670,22 @@ export class ClaudeAcpAgent {
1682
1670
  * under an apiKeyHelper) means "nothing to add", not "logged out" — keep
1683
1671
  * what the CLI probe already established instead of overwriting it.
1684
1672
  */
1685
- publishSessionAccountIdentity(account) {
1673
+ useAuthStatusSettings(manager, settingsOverride, launchEnv) {
1674
+ const helper = manager.getApiKeyHelper(settingsOverride);
1675
+ const changed = manager.getCwd() !== (this.authStatusSettings?.manager.getCwd() ?? process.cwd()) ||
1676
+ helper !== this.authStatusHelper ||
1677
+ JSON.stringify(launchEnv) !== JSON.stringify(this.authStatusSettings?.env);
1678
+ this.authStatusSettings = { manager, override: settingsOverride, env: launchEnv };
1679
+ this.authStatusHelper = helper;
1680
+ if (changed) {
1681
+ // The epoch bump discards any probe that read the old context, and the
1682
+ // session account may carry no identity signal (an apiKeyHelper), so
1683
+ // only a probe in the new context can classify it.
1684
+ this.authEpoch += 1;
1685
+ void this.probeCliAuthStatus({ fresh: true });
1686
+ }
1687
+ }
1688
+ publishSessionAccountIdentity(account, apiKeyHelper) {
1686
1689
  if (this.providerConfig) {
1687
1690
  if (!this.loggedOverriddenAccount) {
1688
1691
  this.loggedOverriddenAccount = true;
@@ -1693,7 +1696,7 @@ export class ClaudeAcpAgent {
1693
1696
  if (this.gatewayAuthRequest) {
1694
1697
  return;
1695
1698
  }
1696
- const fromSession = fromAccountInfo(account);
1699
+ const fromSession = fromAccountInfo(account, apiKeyHelper);
1697
1700
  if (fromSession) {
1698
1701
  this.setAuthStatus(fromSession);
1699
1702
  }
@@ -1726,6 +1729,8 @@ export class ClaudeAcpAgent {
1726
1729
  // auth-affecting event describes a world that no longer exists. Remember
1727
1730
  // which one this read belongs to and drop the answer if it moved on.
1728
1731
  const epoch = this.authEpoch;
1732
+ const context = this.authStatusSettings;
1733
+ const apiKeyHelper = context?.manager.getApiKeyHelper(context.override);
1729
1734
  // ACP gateway auth bypasses the CLI credential store entirely, so the
1730
1735
  // probe would report an identity that is not the one being used. A mere
1731
1736
  // client provider override (`providers/set`) does NOT skip the probe: it
@@ -1737,8 +1742,13 @@ export class ClaudeAcpAgent {
1737
1742
  let stdout;
1738
1743
  try {
1739
1744
  const cliPath = await claudeCliPath();
1740
- ({ stdout } = await execFileAsync(cliPath, ["auth", "status", "--json"], {
1745
+ const args = ["auth", "status", "--json"];
1746
+ if (context?.override)
1747
+ args.push("--settings", JSON.stringify(context.override));
1748
+ ({ stdout } = await execFileAsync(cliPath, args, {
1741
1749
  timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
1750
+ cwd: context?.manager.getCwd() ?? process.cwd(),
1751
+ env: context?.env ? { ...process.env, ...context.env } : process.env,
1742
1752
  }));
1743
1753
  }
1744
1754
  catch (error) {
@@ -1761,7 +1771,16 @@ export class ClaudeAcpAgent {
1761
1771
  }
1762
1772
  stdout = failed.stdout;
1763
1773
  }
1764
- const status = fromCliStatus(stdout);
1774
+ let status = fromCliStatus(stdout, apiKeyHelper);
1775
+ if (!context && status?.kind === "api_key" && status.detail === "API key helper") {
1776
+ try {
1777
+ const resolved = await resolveSettings({ cwd: process.cwd() });
1778
+ status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper);
1779
+ }
1780
+ catch {
1781
+ this.logger.error("Could not resolve auth source settings");
1782
+ }
1783
+ }
1765
1784
  if (!status) {
1766
1785
  this.logger.error("claude auth status returned unparseable output");
1767
1786
  return undefined;
@@ -2027,6 +2046,7 @@ export class ClaudeAcpAgent {
2027
2046
  // that finds the new credential must not be the one the refusal skipped.
2028
2047
  // The guard itself consumes the result only at the next turn boundary (see
2029
2048
  // `markSessionsWhoseAccountKindChanged`).
2049
+ this.useAuthStatusSettings(session.settingsManager, session.effortSettingsOverride, session.creationParams?._meta?.claudeCode?.options?.env);
2030
2050
  void this.probeCliAuthStatus();
2031
2051
  const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
2032
2052
  if (signOutRespawn)
@@ -2245,7 +2265,7 @@ export class ClaudeAcpAgent {
2245
2265
  logger: this.logger,
2246
2266
  // The guard reads the account anyway; reuse that read to keep the
2247
2267
  // reported identity current, refusal or not.
2248
- onAccount: (account) => this.publishSessionAccountIdentity(account),
2268
+ onAccount: (account) => this.publishSessionAccountIdentity(account, session.settingsManager.getApiKeyHelper(session.effortSettingsOverride)),
2249
2269
  });
2250
2270
  }
2251
2271
  async goal(params) {
@@ -2500,6 +2520,10 @@ export class ClaudeAcpAgent {
2500
2520
  streamedBlocksByParent.set(key, (blocks = []));
2501
2521
  return blocks;
2502
2522
  };
2523
+ // The streams, keyed like streamedBlocksByParent, that dropped a delta
2524
+ // after a cancel since their record last reset, so the record no longer
2525
+ // matches what the client got (see the stream_event case).
2526
+ const droppedStreams = new Set();
2503
2527
  // A client gets the consolidated subagent text when it negotiated the
2504
2528
  // transcript extension or the `forwardSubagentText` session option. AIR
2505
2529
  // also gets it with native subagent sessions, and AIR gets no streamed
@@ -2528,7 +2552,7 @@ export class ClaudeAcpAgent {
2528
2552
  * recognizable by the `parentToolUseId` meta that toAcpNotifications
2529
2553
  * stamps from `parent_tool_use_id`, and never reach the top-level feed
2530
2554
  * as the turn's answer. */
2531
- const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(clientSupportsSubagents(this.clientCapabilities), params.sessionId, session, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.logger));
2555
+ const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(subagentForm(this.clientCapabilities), params.sessionId, session, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.logger, (agentId) => subagentLineage(params.sessionId, agentId, this.logger)));
2532
2556
  // Adapter-composed advisories (hook feedback, model fallbacks) are live
2533
2557
  // events, not something the model said. Clients on the notice contract get
2534
2558
  // them as `notice` updates; the rest keep the bold-label transcript line.
@@ -2563,6 +2587,19 @@ export class ClaudeAcpAgent {
2563
2587
  const eagerOwnerSessionId = toolCallId
2564
2588
  ? session.eagerToolCallSessions?.get(toolCallId)
2565
2589
  : undefined;
2590
+ // Before routing, which can buffer or drop the update: a cancel still has
2591
+ // to know the parent of a call that is open here. A later update can be
2592
+ // the first to name it, for a call that a permission request emitted.
2593
+ if (toolCallId &&
2594
+ update.sessionUpdate === "tool_call_update" &&
2595
+ isFinalToolCallStatus(update.status)) {
2596
+ session.toolCallParents?.delete(toolCallId);
2597
+ }
2598
+ else if (toolCallId &&
2599
+ claudeMeta?.parentToolUseId &&
2600
+ session.emittedToolCalls.has(toolCallId)) {
2601
+ recordToolCallParent(session, toolCallId, claudeMeta.parentToolUseId);
2602
+ }
2566
2603
  const routedNotification = await subagents.route(notification, sendUpdate, eagerOwnerSessionId);
2567
2604
  if (!routedNotification) {
2568
2605
  // Native Agent/Task control calls are intentionally not transcript
@@ -3011,6 +3048,29 @@ export class ClaudeAcpAgent {
3011
3048
  }
3012
3049
  this.syncAwaitingUser(params.sessionId, session);
3013
3050
  };
3051
+ /** Ends the display terminal of a command that went to the background, once
3052
+ * its task ends (see `Session.backgroundTerminals`). Claude Code reports no
3053
+ * exit code for it, and `completed` does not mean 0: Claude Code accepts
3054
+ * some non-zero codes as a success, such as grep's 1. */
3055
+ const endBackgroundTerminal = async (taskId) => {
3056
+ const terminal = session.backgroundTerminals?.get(taskId);
3057
+ if (!terminal)
3058
+ return;
3059
+ session.backgroundTerminals.delete(taskId);
3060
+ await sendUpdate({
3061
+ sessionId: params.sessionId,
3062
+ update: {
3063
+ sessionUpdate: "tool_call_update",
3064
+ toolCallId: terminal.toolCallId,
3065
+ _meta: {
3066
+ ...(terminal.parentToolUseId
3067
+ ? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
3068
+ : {}),
3069
+ terminal_exit: { terminal_id: terminal.toolCallId, exit_code: null, signal: null },
3070
+ },
3071
+ },
3072
+ });
3073
+ };
3014
3074
  /** Settles a task. Its request may still be open, and an agent without a
3015
3075
  * record no longer blocks the foreground work, so that is checked again. */
3016
3076
  const settleLiveTask = (taskId) => {
@@ -3087,6 +3147,92 @@ export class ClaudeAcpAgent {
3087
3147
  await settleActive(outcome);
3088
3148
  }
3089
3149
  };
3150
+ /** Sends `report`, which ends the tool call `toolCallId` without its result
3151
+ * (see `AcpToolCallRenderer.unfinished`), and forgets the call. Forgetting
3152
+ * comes first, so that a late hook or a second close cannot overwrite the
3153
+ * report. */
3154
+ const closeToolCall = async (toolCallId, report) => {
3155
+ unregisterHookCallback(toolCallId);
3156
+ session.emittedToolCalls.delete(toolCallId);
3157
+ delete session.toolUseCache[toolCallId];
3158
+ session.toolCallFields?.delete(toolCallId);
3159
+ session.dispatchedToolCalls?.delete(toolCallId);
3160
+ session.toolCallParents?.delete(toolCallId);
3161
+ await sendUpdate({ sessionId: params.sessionId, update: report });
3162
+ };
3163
+ /** Whether the subagent that the Agent call `agentToolUseId` started runs in
3164
+ * the background and has not ended. */
3165
+ const runsInBackground = (agentToolUseId) => [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent &&
3166
+ task.backgrounded === true &&
3167
+ !task.endedPerLevel &&
3168
+ task.parentToolUseId === agentToolUseId);
3169
+ /** Closes the tool calls that the work a cancel stopped left open, as that
3170
+ * work ends. Claude Code's interrupt ends each running tool of the main
3171
+ * conversation with an error result, which `sendInterruptedToolResults`
3172
+ * delivers. What is still open here has no result to deliver:
3173
+ * - a tool of a subagent: Claude Code writes its interrupted result only to
3174
+ * the subagent's own transcript, not to the stream;
3175
+ * - a tool call that Claude abandoned before it ran;
3176
+ * - every tool call, when the stream gives no results at all, as when the
3177
+ * force-cancel backstop ends a wedged turn.
3178
+ * A foreground subagent ends with the work that waits on it. A background
3179
+ * subagent keeps its tool calls open until the SDK reports its end, which
3180
+ * Claude Code 2.1.293 does for the ones it stops, before the turn's result
3181
+ * (see `NativeSubagentRuntime.parentCancelled`).
3182
+ * Runs once per turn: a second call, from the other settling path, waits
3183
+ * for the first (see `Turn.closingCancelledToolCalls`). */
3184
+ const closeCancelledToolCalls = (turn) => (turn.closingCancelledToolCalls ??= (async () => {
3185
+ const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
3186
+ for (const toolCallId of [...session.emittedToolCalls]) {
3187
+ const parentToolUseId = session.toolCallParents?.get(toolCallId);
3188
+ if (parentToolUseId !== undefined && runsInBackground(parentToolUseId))
3189
+ continue;
3190
+ const report = renderer.unfinished({
3191
+ toolCallId,
3192
+ ran: session.dispatchedToolCalls?.has(toolCallId) === true,
3193
+ cancelled: true,
3194
+ });
3195
+ // Named as the call's other updates are: the subagent runtime routes the
3196
+ // report with them, to the child session, or to none that has ended.
3197
+ if (parentToolUseId !== undefined)
3198
+ stampParentToolUseId(report, parentToolUseId);
3199
+ await closeToolCall(toolCallId, report);
3200
+ }
3201
+ })());
3202
+ session.closeCancelledToolCalls = closeCancelledToolCalls;
3203
+ /** Delivers the results that the work a cancel stopped sends for tool calls
3204
+ * that the client holds open. Claude Code's interrupt ends each running tool
3205
+ * of the main conversation with an error result, before the result that
3206
+ * ends the turn. The tool call gets that result like any other, so its
3207
+ * final status and `nonExecutionKind` reach the client. Nothing else of the
3208
+ * message is applied: the other effects of a tool result (async tasks, plan
3209
+ * state, the ExitPlanMode flow) belong to the stopped work, which stays
3210
+ * dropped. */
3211
+ const sendInterruptedToolResults = async (message) => {
3212
+ const content = message.message.content;
3213
+ if (!Array.isArray(content))
3214
+ return;
3215
+ const results = content.filter((block) => block.type === "tool_result" && session.emittedToolCalls.has(block.tool_use_id));
3216
+ if (results.length === 0)
3217
+ return;
3218
+ const resultCount = content.filter((block) => block.type === "tool_result").length;
3219
+ for (const notification of toAcpNotifications(results, "user", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
3220
+ clientCapabilities: this.clientCapabilities,
3221
+ toolCallCapabilities: this.toolCallCapabilities,
3222
+ parentToolUseId: message.parent_tool_use_id,
3223
+ cwd: session.cwd,
3224
+ taskState: session.taskState,
3225
+ emittedToolCalls: session.emittedToolCalls,
3226
+ toolCallFields: toolCallFieldsOf(session),
3227
+ messageId: messageIdForGrouping(message),
3228
+ // `tool_use_result` describes the one result of its message.
3229
+ toolUseResult: results.length === resultCount ? message.tool_use_result : undefined,
3230
+ toolResultMeta: message.tool_result_meta,
3231
+ cancelled: true,
3232
+ })) {
3233
+ await sendUpdate(notification);
3234
+ }
3235
+ };
3090
3236
  /** At the actual turn boundary, preview its checkpoint, settle the active
3091
3237
  * turn exactly once, disarm the force-cancel backstop, and drop it from
3092
3238
  * the queue. Cancellation and provider failures skip checkpoint I/O. */
@@ -3112,16 +3258,7 @@ export class ClaudeAcpAgent {
3112
3258
  * ended meanwhile.
3113
3259
  */
3114
3260
  const closeUnfinished = async (toolCallId, ran) => {
3115
- // A late hook must not overwrite the report we are about to send.
3116
- unregisterHookCallback(toolCallId);
3117
- session.emittedToolCalls.delete(toolCallId);
3118
- delete session.toolUseCache[toolCallId];
3119
- session.toolCallFields?.delete(toolCallId);
3120
- session.dispatchedToolCalls?.delete(toolCallId);
3121
- await sendUpdate({
3122
- sessionId: params.sessionId,
3123
- update: renderer.unfinished({ toolCallId, ran }),
3124
- });
3261
+ await closeToolCall(toolCallId, renderer.unfinished({ toolCallId, ran }));
3125
3262
  // Cancellation can arrive while we await the client update.
3126
3263
  if (turn.settled || session.activeTurn !== turn)
3127
3264
  return false;
@@ -3154,6 +3291,15 @@ export class ClaudeAcpAgent {
3154
3291
  return;
3155
3292
  }
3156
3293
  }
3294
+ // The client is told that the work a cancel stopped ended (the prompt
3295
+ // response on v1, `idle` on v2) only after every tool call that work
3296
+ // opened has ended.
3297
+ if (result.stopReason === "cancelled" && session.cancelled) {
3298
+ await closeCancelledToolCalls(turn);
3299
+ // `cancelTurns` can settle the turn while the reports are in flight.
3300
+ if (turn.settled || session.activeTurn !== turn)
3301
+ return;
3302
+ }
3157
3303
  turn.settling = true;
3158
3304
  turn.settlingOutcome = result;
3159
3305
  if (reportReason === "notReported") {
@@ -4064,9 +4210,14 @@ export class ClaudeAcpAgent {
4064
4210
  this.logger.log(`Ignoring task_notification for task ${message.task_id} from earlier run ${message.run_id}`);
4065
4211
  break;
4066
4212
  }
4213
+ // In the RFD form, a resumed child runs under the call that
4214
+ // resumed it, and the end of an earlier delegation ends
4215
+ // nothing: the child and its live task belong to the new run.
4216
+ if (subagents.isStaleEnd(message.task_id, message.tool_use_id))
4217
+ break;
4067
4218
  // The task settled — no further tool calls can originate
4068
4219
  // from it, so its registry entry can be dropped.
4069
- await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id);
4220
+ await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id, { reason: message.reason });
4070
4221
  await asyncTasks.taskNotification({
4071
4222
  task_id: message.task_id,
4072
4223
  status: message.status,
@@ -4078,7 +4229,9 @@ export class ClaudeAcpAgent {
4078
4229
  subagents.discardPending(message.tool_use_id);
4079
4230
  // The subagent streams no more text under this parent.
4080
4231
  streamedBlocksByParent.delete(message.tool_use_id);
4232
+ droppedStreams.delete(message.tool_use_id);
4081
4233
  }
4234
+ await endBackgroundTerminal(message.task_id);
4082
4235
  settleLiveTask(message.task_id);
4083
4236
  break;
4084
4237
  case "task_updated":
@@ -4103,10 +4256,13 @@ export class ClaudeAcpAgent {
4103
4256
  if (message.patch.status === "completed" ||
4104
4257
  message.patch.status === "failed" ||
4105
4258
  message.patch.status === "killed") {
4106
- await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
4259
+ await subagents.finishTask(message.task_id, message.patch.status, sendUpdate, undefined, { error: message.patch.status === "failed" ? message.patch.error : undefined });
4107
4260
  const parentToolUseId = session.liveBackgroundTasks.get(message.task_id)?.parentToolUseId;
4108
- if (parentToolUseId)
4261
+ if (parentToolUseId) {
4109
4262
  streamedBlocksByParent.delete(parentToolUseId);
4263
+ droppedStreams.delete(parentToolUseId);
4264
+ }
4265
+ await endBackgroundTerminal(message.task_id);
4110
4266
  settleLiveTask(message.task_id);
4111
4267
  }
4112
4268
  else if (message.patch.status === "running" ||
@@ -4930,43 +5086,14 @@ export class ClaudeAcpAgent {
4930
5086
  // reset, and the synthetic-auth/system/local-command paths
4931
5087
  // `break` early too. Block indices restart at 0 each message, so
4932
5088
  // leftover entries would otherwise collide with this message's
4933
- // blocks and re-emit (or truncate) already-streamed text. Gated on
4934
- // `parent_tool_use_id === null` so a subagent stream can't clear
4935
- // the top-level record. Fires once, before any of this message's
4936
- // blocks, so it doesn't disturb the mid-message turn-activation
4937
- // path the way resetting on turn activation would.
5089
+ // blocks and re-emit (or truncate) already-streamed text. Keyed by
5090
+ // the stream, so a subagent's message can't clear the top-level
5091
+ // record. Fires once, before any of this message's blocks, so it
5092
+ // doesn't disturb the mid-message turn-activation path the way
5093
+ // resetting on turn activation would. The record's drop latch
5094
+ // (see below) resets with it.
4938
5095
  streamedBlocksOf(message.parent_tool_use_id).length = 0;
4939
- }
4940
- // Accumulate the text/thinking actually streamed live, so the
4941
- // `assistant` case below can diff its assembled blocks against what
4942
- // already reached the client as chunks and forward only the
4943
- // remainder. Each stream (top level or one subagent) keeps its own
4944
- // record, so a subagent cannot attribute content to another stream.
4945
- // Contiguous deltas of the same block (same index and type) extend
4946
- // the current entry; anything else opens a new one.
4947
- if (message.event.type === "content_block_delta") {
4948
- const delta = message.event.delta;
4949
- const chunk = delta.type === "text_delta"
4950
- ? { type: "text", text: delta.text }
4951
- : delta.type === "thinking_delta"
4952
- ? { type: "thinking", text: delta.thinking }
4953
- : undefined;
4954
- // Skip empty deltas (some gateways emit empty thinking chunks —
4955
- // #793): appending "" is a no-op, but pushing a "" entry would
4956
- // create a block the consolidated handler's `text.length > 0`
4957
- // guard can never consume, stalling the diff cursor and
4958
- // re-emitting the next block as a duplicate.
4959
- if (chunk?.text) {
4960
- const index = message.event.index;
4961
- const streamedBlocks = streamedBlocksOf(message.parent_tool_use_id);
4962
- const last = streamedBlocks[streamedBlocks.length - 1];
4963
- if (last && last.index === index && last.type === chunk.type) {
4964
- last.text += chunk.text;
4965
- }
4966
- else {
4967
- streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
4968
- }
4969
- }
5096
+ droppedStreams.delete(message.parent_tool_use_id ?? "");
4970
5097
  }
4971
5098
  if (message.parent_tool_use_id === null &&
4972
5099
  (message.event.type === "message_start" || message.event.type === "message_delta")) {
@@ -5020,7 +5147,71 @@ export class ClaudeAcpAgent {
5020
5147
  });
5021
5148
  }
5022
5149
  }
5023
- for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, this.client, this.logger, {
5150
+ // After a cancel, output streams to the client until the client is
5151
+ // told that the stopped work ended (see workEndReported), so what
5152
+ // the interrupt flushes before then still goes out, as both
5153
+ // protocol versions allow. After that, while the window after the
5154
+ // cancel is open, streamed text, thinking and tool calls are
5155
+ // dropped like the consolidated messages they belong to (see the
5156
+ // `cancelled` check in the user/assistant case). Otherwise the
5157
+ // answer to a prompt the cancel abandoned, which Claude Code had
5158
+ // already taken off its queue, would stream to the client after the
5159
+ // cancel was reported.
5160
+ //
5161
+ // Where the consolidated message delivers the text (the same
5162
+ // condition as in the user/assistant case), a stream that loses a
5163
+ // delta keeps dropping until the record below resets: at the next
5164
+ // message, or when a consolidated message consumes the record. If
5165
+ // the next prompt's echo ends the window mid-block, the record then
5166
+ // stays empty and the consolidated message delivers the block's
5167
+ // whole text once. A record holding only the block's tail is not a
5168
+ // prefix of the text, so the text would be sent again (see
5169
+ // unstreamedRemainder).
5170
+ const streamKey = message.parent_tool_use_id ?? "";
5171
+ if (session.cancelled && this.workEndReported(params.sessionId, session)) {
5172
+ if (message.parent_tool_use_id === null || forwardsSubagentText()) {
5173
+ droppedStreams.add(streamKey);
5174
+ }
5175
+ break;
5176
+ }
5177
+ if (droppedStreams.has(streamKey)) {
5178
+ break;
5179
+ }
5180
+ // Accumulate the text/thinking actually streamed live, so the
5181
+ // `assistant` case below can diff its assembled blocks against what
5182
+ // already reached the client as chunks and forward only the
5183
+ // remainder. Each stream (top level or one subagent) keeps its own
5184
+ // record, so a subagent cannot attribute content to another stream.
5185
+ // Contiguous deltas of the same block (same index and type) extend
5186
+ // the current entry; anything else opens a new one.
5187
+ if (message.event.type === "content_block_delta") {
5188
+ const delta = message.event.delta;
5189
+ const chunk = delta.type === "text_delta"
5190
+ ? { type: "text", text: delta.text }
5191
+ : delta.type === "thinking_delta"
5192
+ ? { type: "thinking", text: delta.thinking }
5193
+ : undefined;
5194
+ // Skip empty deltas (some gateways emit empty thinking chunks —
5195
+ // #793): appending "" is a no-op, but pushing a "" entry would
5196
+ // create a block the consolidated handler's `text.length > 0`
5197
+ // guard can never consume, stalling the diff cursor and
5198
+ // re-emitting the next block as a duplicate.
5199
+ if (chunk?.text) {
5200
+ const index = message.event.index;
5201
+ const streamedBlocks = streamedBlocksOf(message.parent_tool_use_id);
5202
+ const last = streamedBlocks[streamedBlocks.length - 1];
5203
+ if (last && last.index === index && last.type === chunk.type) {
5204
+ last.text += chunk.text;
5205
+ }
5206
+ else {
5207
+ streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
5208
+ }
5209
+ }
5210
+ }
5211
+ // The stream sees a tool_use first, so the tool's PostToolUse hook
5212
+ // is registered here, and publishes through this client: a
5213
+ // subagent's hook update must reach the child session too.
5214
+ for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
5024
5215
  clientCapabilities: this.clientCapabilities,
5025
5216
  toolCallCapabilities: this.toolCallCapabilities,
5026
5217
  cwd: session.cwd,
@@ -5153,6 +5344,13 @@ export class ClaudeAcpAgent {
5153
5344
  }
5154
5345
  }
5155
5346
  if (session.cancelled) {
5347
+ // The work the cancel stopped ends its tools with error results.
5348
+ // Until the client is told that the work ended, they close the
5349
+ // tool calls it opened. Everything else that work sends is
5350
+ // dropped, and so is everything after that report.
5351
+ if (message.type === "user" && !this.workEndReported(params.sessionId, session)) {
5352
+ await sendInterruptedToolResults(message);
5353
+ }
5156
5354
  break;
5157
5355
  }
5158
5356
  // Synthetic assistant frames carry the CLI's local-command output.
@@ -5284,6 +5482,7 @@ export class ClaudeAcpAgent {
5284
5482
  const streamed = streamedBlocksOf(message.parent_tool_use_id);
5285
5483
  content = unstreamedRemainder(message.message.content, streamed);
5286
5484
  streamed.length = 0;
5485
+ droppedStreams.delete(message.parent_tool_use_id ?? "");
5287
5486
  }
5288
5487
  else if (message.type === "assistant") {
5289
5488
  // Nested text/thinking stays internal for a client that does
@@ -5296,19 +5495,41 @@ export class ClaudeAcpAgent {
5296
5495
  }
5297
5496
  const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
5298
5497
  let backgroundedToolCalls = new Set();
5498
+ let runningTerminal;
5299
5499
  if (message.type === "user") {
5300
5500
  rememberResolvedToolNames(session, content);
5301
5501
  const backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
5302
5502
  if (backgroundBashTask)
5303
5503
  await asyncTasks.taskBackgrounded(backgroundBashTask);
5504
+ // The command's terminal runs until its task ends, unless the
5505
+ // task has ended already (a fast command can end before the tool
5506
+ // result comes).
5507
+ const terminalId = backgroundBashTask?.toolCallId;
5508
+ if (terminalId &&
5509
+ this.toolCallCapabilities.terminalOutput &&
5510
+ !this.toolCallCapabilities.air.client &&
5511
+ session.liveBackgroundTasks.has(backgroundBashTask.taskId)) {
5512
+ runningTerminal = terminalId;
5513
+ (session.backgroundTerminals ??= new Map()).set(backgroundBashTask.taskId, {
5514
+ toolCallId: terminalId,
5515
+ ...(message.parent_tool_use_id
5516
+ ? { parentToolUseId: message.parent_tool_use_id }
5517
+ : {}),
5518
+ });
5519
+ }
5304
5520
  await asyncTasks.toolResults(content);
5305
5521
  backgroundedToolCalls = backgroundedBashToolCallIds(content, session.toolUseCache, asyncTasks, backgroundBashTask);
5306
5522
  const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
5307
5523
  if (resumedAgentId) {
5308
- resumeLiveTask(resumedAgentId);
5309
- await subagents.taskResumed(resumedAgentId, sendUpdate, sendMessageResumePrompt(session.toolUseCache, resumedAgentId, Array.isArray(content)
5524
+ const resume = sendMessageResume(session.toolUseCache, resumedAgentId, Array.isArray(content)
5310
5525
  ? content.flatMap((block) => block.type === "tool_result" ? [block.tool_use_id] : [])
5311
- : []));
5526
+ : []);
5527
+ // In the RFD form the result can arrive after the run it
5528
+ // started ended, and then resumes nothing.
5529
+ if (!subagents.isEndedDelegation(resumedAgentId, resume?.toolUseId)) {
5530
+ resumeLiveTask(resumedAgentId);
5531
+ await subagents.sendMessageResumed(resumedAgentId, sendUpdate, resume?.text, resume?.toolUseId);
5532
+ }
5312
5533
  }
5313
5534
  }
5314
5535
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
@@ -5331,7 +5552,7 @@ export class ClaudeAcpAgent {
5331
5552
  // filtered out of `content` above; blocks that do pass through
5332
5553
  // (e.g. a subagent image) carry the stamped parentToolUseId
5333
5554
  // meta and are excluded there.
5334
- await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled));
5555
+ await sendUpdate(withRunningTerminal(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled), runningTerminal));
5335
5556
  }
5336
5557
  break;
5337
5558
  }
@@ -5355,10 +5576,14 @@ export class ClaudeAcpAgent {
5355
5576
  if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
5356
5577
  break;
5357
5578
  }
5358
- const subagentParentToolUseId = message.parent_tool_use_id
5359
- ? [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id)
5360
- ? message.parent_tool_use_id
5361
- : undefined
5579
+ // The beat is in the subagent whose Agent call is
5580
+ // `parent_tool_use_id`, unless it reports on that Agent call
5581
+ // itself: the Agent call's own heartbeat falls back to its id, and
5582
+ // a tool call is never its own parent.
5583
+ const subagentParentToolUseId = message.parent_tool_use_id &&
5584
+ message.parent_tool_use_id !== toolCallId &&
5585
+ [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id)
5586
+ ? message.parent_tool_use_id
5362
5587
  : undefined;
5363
5588
  // `tool_name` names the tool that reports the beat. When the beat
5364
5589
  // falls back to the parent call, AIR gets the name of that call,
@@ -5581,17 +5806,29 @@ export class ClaudeAcpAgent {
5581
5806
  // their compaction is running. Close it before ANY cancelled prompt
5582
5807
  // resolves, not just the active-turn idle/backstop paths.
5583
5808
  await session.contextCompaction?.interrupt();
5809
+ // A cancel drops the state of each tool call (the session that a
5810
+ // permission request emitted it in, the fields the client holds, the hook
5811
+ // callbacks), even when publishing fails. AIR's draft ends every child
5812
+ // here, so a later update of an ended child's tool call is then dropped
5813
+ // instead of reaching that child's session. The RFD form keeps the state:
5814
+ // a background subagent can keep running after a cancel
5815
+ // (agentclientprotocol/agent-client-protocol#2308), and its tool call that
5816
+ // a permission request emitted on the root before the child was announced
5817
+ // must stay on the root, which the RFD requires. Each tool call drops its
5818
+ // own state when it ends.
5584
5819
  try {
5585
- await session.nativeSubagentRuntime?.finishAll("cancelled", session.nativeSubagentDeliver ??
5820
+ await session.nativeSubagentRuntime?.parentCancelled(session.nativeSubagentDeliver ??
5586
5821
  (async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification))));
5587
5822
  }
5588
5823
  catch (error) {
5589
5824
  this.logger.error(`Session ${params.sessionId}: failed to publish cancelled subagent state`, error);
5590
5825
  }
5591
5826
  finally {
5592
- session.eagerToolCallSessions?.clear();
5593
- session.toolCallFields?.clear();
5594
- clearHookCallbacks(params.sessionId);
5827
+ if (session.nativeSubagentRuntime?.form !== "rfd") {
5828
+ session.eagerToolCallSessions?.clear();
5829
+ session.toolCallFields?.clear();
5830
+ clearHookCallbacks(params.sessionId);
5831
+ }
5595
5832
  }
5596
5833
  // The user messages this cancel abandons while Claude Code may still have
5597
5834
  // them queued: they are dropped from its queue before the interrupt.
@@ -5705,7 +5942,8 @@ export class ClaudeAcpAgent {
5705
5942
  // permission request) is left to that cycle's interrupted result, like any
5706
5943
  // live turn, or to the idle when no result comes. The captured outcome
5707
5944
  // preserves usage and metadata (issue #844), while reporter state makes a
5708
- // late checkpoint response harmless.
5945
+ // late checkpoint response harmless. The turn's open tool calls close
5946
+ // before its response, as in the consumer's settle.
5709
5947
  // Since CLI 2.1.270, Claude Code reports `running` for as long as
5710
5948
  // background agents live, so the session state cannot tell a live
5711
5949
  // followup from a hold. With request markers, a followup is live when one
@@ -5718,7 +5956,10 @@ export class ClaudeAcpAgent {
5718
5956
  if (active &&
5719
5957
  (active.settling || (isHeldOpen(active) && !followupLive)) &&
5720
5958
  (active.deferredSettle ?? active.settlingOutcome)) {
5959
+ const closingToolCalls = session.closeCancelledToolCalls?.(active);
5721
5960
  session.fileChangeReporter?.finish(active.fileChangeReport, "cancelled");
5961
+ // Settled before the reports are awaited, so that the consumer, which
5962
+ // keeps running meanwhile, cannot settle the turn a second time.
5722
5963
  active.settled = true;
5723
5964
  // Mirror settleActive's invariants (it is consumer-scoped and
5724
5965
  // unreachable from here): disarm the backstop — none should be
@@ -5750,7 +5991,15 @@ export class ClaudeAcpAgent {
5750
5991
  if (session.lastSessionState !== "idle") {
5751
5992
  session.owedTrailingIdles++;
5752
5993
  }
5753
- active.resolve(cancelledOutcome(session, active));
5994
+ try {
5995
+ await closingToolCalls;
5996
+ }
5997
+ catch (error) {
5998
+ this.logger.error(`Session ${params.sessionId}: failed to publish cancelled tool state`, error);
5999
+ }
6000
+ finally {
6001
+ active.resolve(cancelledOutcome(session, active));
6002
+ }
5754
6003
  }
5755
6004
  }
5756
6005
  // A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
@@ -6053,7 +6302,12 @@ export class ClaudeAcpAgent {
6053
6302
  // A replay rebuilds the client view, so its plan goes out again.
6054
6303
  if (session?.taskState)
6055
6304
  forgetPublishedTaskPlan(session.taskState);
6056
- const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
6305
+ // A subagent's text replays onto the root only for a client that gets it
6306
+ // there live: one without native subagent sessions that asked for the
6307
+ // flattened transcript. With native sessions the text goes to the child
6308
+ // session, which AIR's replay restores and the RFD form leaves out for now.
6309
+ const forwardSubagentText = !clientSupportsSubagents(this.clientCapabilities) &&
6310
+ (session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities));
6057
6311
  const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
6058
6312
  const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
6059
6313
  const sessionFailures = session && supportsTypedFailures
@@ -6072,7 +6326,11 @@ export class ClaudeAcpAgent {
6072
6326
  })
6073
6327
  : undefined;
6074
6328
  let replayTurnId;
6075
- const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
6329
+ // AIR's draft replays each child under a replay id. A client on the RFD
6330
+ // form gets each child under the session id it had live (see
6331
+ // `RfdSubagentReplay`).
6332
+ const form = subagentForm(this.clientCapabilities);
6333
+ const nativeReplayEnabled = form === "air";
6076
6334
  const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
6077
6335
  const replayTerminalStates = new Map();
6078
6336
  const replayChildren = new Map();
@@ -6175,8 +6433,17 @@ export class ClaudeAcpAgent {
6175
6433
  // Only a history with an Agent or Task launch has subagent transcripts to read.
6176
6434
  const subagents = nativeReplayEnabled && replayChildren.size > 0
6177
6435
  ? await subagentHistory(sessionId)
6178
- : { ids: new Map() };
6436
+ : { agents: new Map() };
6179
6437
  const replayedSubagents = new Set();
6438
+ const rfdHistory = form === "rfd" &&
6439
+ messages.some((message) => subagentLaunchIds(message.message?.content)
6440
+ .length > 0)
6441
+ ? await subagentHistory(sessionId)
6442
+ : { agents: new Map() };
6443
+ const rfdReplay = form === "rfd"
6444
+ ? new RfdSubagentReplay(rfdHistory, (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)))
6445
+ : undefined;
6446
+ rfdReplay?.scan(messages, sessionId);
6180
6447
  // History persists no task_started frame, so a background task gets its
6181
6448
  // spawn from the notification of its stop, with the tool call that
6182
6449
  // started it. A task still running at the end of the history gets no
@@ -6195,6 +6462,8 @@ export class ClaudeAcpAgent {
6195
6462
  });
6196
6463
  // The replay counterpart of the live `task_notification` frame.
6197
6464
  const restoreTaskNotification = async (notification) => {
6465
+ if (rfdReplay?.taskNotification(notification.task_id, notification.status))
6466
+ return;
6198
6467
  const toolUseId = notification.tool_use_id;
6199
6468
  if (!toolUseId)
6200
6469
  return;
@@ -6221,7 +6490,8 @@ export class ClaudeAcpAgent {
6221
6490
  });
6222
6491
  await replayAsyncTasks.taskNotification(notification);
6223
6492
  };
6224
- const replayMessage = async (message) => {
6493
+ /** Replays `message`, from the transcript of `child` when it is set (the RFD form). */
6494
+ const replayMessage = async (message, child) => {
6225
6495
  // A long history is sent in order, but with event-loop turns in between,
6226
6496
  // so other sessions and incoming requests are not starved meanwhile.
6227
6497
  const pause = messageLoopYielder.maybeYield();
@@ -6230,6 +6500,10 @@ export class ClaudeAcpAgent {
6230
6500
  if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
6231
6501
  return;
6232
6502
  }
6503
+ // In the RFD form a child's history comes from its own transcript, so a
6504
+ // subagent message that the session transcript stores inline is skipped.
6505
+ if (rfdReplay && !child && parentToolUseIdOf(message))
6506
+ return;
6233
6507
  if (message.type === "user" &&
6234
6508
  message.parent_tool_use_id === null &&
6235
6509
  typeof message.uuid === "string" &&
@@ -6269,11 +6543,14 @@ export class ClaudeAcpAgent {
6269
6543
  // @ts-expect-error - untyped in SDK but we handle all of these
6270
6544
  let content = message.message.content;
6271
6545
  const parentToolUseId = parentToolUseIdOf(message);
6272
- const replayTargetSessionId = nativeReplayEnabled && parentToolUseId
6273
- ? await announceReplayChild(parentToolUseId)
6274
- : sessionId;
6546
+ const replayTargetSessionId = child
6547
+ ? child.sessionId
6548
+ : nativeReplayEnabled && parentToolUseId
6549
+ ? await announceReplayChild(parentToolUseId)
6550
+ : sessionId;
6275
6551
  if (message.type === "assistant" &&
6276
6552
  parentToolUseId &&
6553
+ !child &&
6277
6554
  !nativeReplayEnabled &&
6278
6555
  !forwardSubagentText) {
6279
6556
  content = stripSubagentTextAndThinking(content);
@@ -6287,6 +6564,9 @@ export class ClaudeAcpAgent {
6287
6564
  }
6288
6565
  // Live, the prompt loop skips this record and the SDK frame reports the stop.
6289
6566
  content = isTaskNotificationRecord(message) ? null : stripLocalCommandMetadata(content);
6567
+ if (content !== null && child && rfdReplay) {
6568
+ content = await rfdReplay.childContent(child, message, content);
6569
+ }
6290
6570
  if (content === null)
6291
6571
  return;
6292
6572
  }
@@ -6358,32 +6638,37 @@ export class ClaudeAcpAgent {
6358
6638
  if (nativeReplayEnabled && message.type === "assistant") {
6359
6639
  for (const toolUseId of subagentLaunchIds(content)) {
6360
6640
  await announceReplayChild(toolUseId);
6361
- const agentId = subagents.ids.get(toolUseId);
6641
+ const agentId = subagents.agents.get(toolUseId)?.agentId;
6362
6642
  if (!agentId || replayedSubagents.has(agentId))
6363
6643
  continue;
6364
6644
  replayedSubagents.add(agentId);
6365
- let childMessages = [];
6366
- try {
6367
- // With the project directory, the SDK skips its search of every project.
6368
- // A session opened from another directory falls back to that search.
6369
- if (subagents.dir) {
6370
- childMessages = await getSubagentMessages(sessionId, agentId, { dir: subagents.dir });
6371
- }
6372
- if (childMessages.length === 0) {
6373
- childMessages = await getSubagentMessages(sessionId, agentId);
6374
- }
6375
- }
6376
- catch (error) {
6377
- this.logger.error(`Failed to read the history of subagent ${agentId}:`, error);
6378
- }
6645
+ const childMessages = await readSubagentMessages(sessionId, agentId, subagents, this.logger);
6379
6646
  registerLaunches(childMessages);
6380
6647
  for (const childMessage of childMessages)
6381
6648
  await replayMessage(childMessage);
6382
6649
  }
6383
6650
  }
6651
+ // The RFD form announces a child right after its launch, then sends its
6652
+ // own transcript to it.
6653
+ if (rfdReplay && message.type === "assistant") {
6654
+ for (const toolUseId of subagentLaunchIds(content)) {
6655
+ const launched = rfdReplay.childOfLaunch(toolUseId);
6656
+ if (!launched || launched.announced)
6657
+ continue;
6658
+ await rfdReplay.announce(launched);
6659
+ const childMessages = await readSubagentMessages(sessionId, launched.sessionId, rfdHistory, this.logger);
6660
+ rfdReplay.scan(childMessages, launched.sessionId);
6661
+ for (const childMessage of childMessages)
6662
+ await replayMessage(childMessage, launched);
6663
+ await rfdReplay.childReplayed(launched, childMessages);
6664
+ }
6665
+ }
6384
6666
  };
6385
6667
  for (const message of messages)
6386
6668
  await replayMessage(message);
6669
+ if (rfdReplay && !pending?.stopped) {
6670
+ await rfdReplay.finish(clientSupportsNotices(this.clientCapabilities), sessionId);
6671
+ }
6387
6672
  if (nativeReplayEnabled && !pending?.stopped) {
6388
6673
  // Claude history persists sidechain messages and Agent/Task tool uses,
6389
6674
  // but not task_started/task_updated lifecycle frames. Recover terminal
@@ -6418,6 +6703,16 @@ export class ClaudeAcpAgent {
6418
6703
  const response = await this.client.writeTextFile(params);
6419
6704
  return response;
6420
6705
  }
6706
+ /** Runs `request`, a request that `requestSessionId` sends for the session
6707
+ * `ownerSessionId`. When that is a subagent's child session, the child
6708
+ * reports that it awaits the user while the request is open (the RFD form,
6709
+ * see `NativeSubagentRuntime.awaitingUser`). */
6710
+ withChildAwaitingUser(ownerSessionId, requestSessionId, request) {
6711
+ const runtime = this.sessions[ownerSessionId]?.nativeSubagentRuntime;
6712
+ return runtime && requestSessionId !== ownerSessionId
6713
+ ? runtime.awaitingUser(requestSessionId, request)
6714
+ : request();
6715
+ }
6421
6716
  /** Mark a client request of the subagent `agentID` (absent for the main
6422
6717
  * conversation) as awaiting user input for exactly the lifetime of its
6423
6718
  * promise (see `Session.openUserInputs`). Steering consults the open
@@ -6512,6 +6807,13 @@ export class ClaudeAcpAgent {
6512
6807
  return;
6513
6808
  this.startForeground(sessionId, AUTONOMOUS_CYCLE);
6514
6809
  }
6810
+ /** Whether the client has been told that the session's work ended: on v2 by
6811
+ * an idle state, so no foreground work is open, including a cycle that
6812
+ * Claude Code started on its own; on v1 by the response to the turn's
6813
+ * prompt, so no turn is active. */
6814
+ workEndReported(sessionId, session) {
6815
+ return this.foregroundEvents ? !this.foregroundWork.has(sessionId) : !session.activeTurn;
6816
+ }
6515
6817
  /** End the session's model cycle that Claude Code started on its own, if one
6516
6818
  * runs. */
6517
6819
  endAutonomousCycle(sessionId, end) {
@@ -6541,7 +6843,7 @@ export class ClaudeAcpAgent {
6541
6843
  // The request goes to a subagent's child session for a tool call of that
6542
6844
  // subagent, but it blocks the turn of the owner session.
6543
6845
  try {
6544
- return await this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID);
6846
+ return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, () => this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID));
6545
6847
  }
6546
6848
  catch (error) {
6547
6849
  if (signal.aborted) {
@@ -6581,6 +6883,8 @@ export class ClaudeAcpAgent {
6581
6883
  return;
6582
6884
  }
6583
6885
  session.emittedToolCalls.add(toolCallId);
6886
+ if (parentToolUseId)
6887
+ recordToolCallParent(session, toolCallId, parentToolUseId);
6584
6888
  // SandboxNetworkAccess is a permission-only callback, not an executing
6585
6889
  // tool: the SDK does not send a tool_result for its synthetic id.
6586
6890
  if (!parentToolUseId &&
@@ -6605,6 +6909,7 @@ export class ClaudeAcpAgent {
6605
6909
  session.emittedToolCalls.delete(toolCallId);
6606
6910
  session.eagerToolCallSessions?.delete(toolCallId);
6607
6911
  session.toolCallFields?.delete(toolCallId);
6912
+ session.toolCallParents?.delete(toolCallId);
6608
6913
  throw error;
6609
6914
  }
6610
6915
  }
@@ -6879,7 +7184,7 @@ export class ClaudeAcpAgent {
6879
7184
  const createRequest = askUserQuestionsToCreateRequest(questions, requestSessionId, toolUseID, this.toolCallCapabilities.air.client);
6880
7185
  let response;
6881
7186
  try {
6882
- response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal), agentID);
7187
+ response = await this.withChildAwaitingUser(sessionId, requestSessionId, () => this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal), agentID));
6883
7188
  }
6884
7189
  catch (error) {
6885
7190
  // A cancellation we requested (signal aborted) settles as an aborted tool
@@ -7750,6 +8055,8 @@ export class ClaudeAcpAgent {
7750
8055
  prompt: input,
7751
8056
  options,
7752
8057
  });
8058
+ this.useAuthStatusSettings(settingsManager, configuredSettingsObject, userProvidedOptions?.env);
8059
+ const authSettingsEpoch = this.authEpoch;
7753
8060
  timing.phase("prepare-query");
7754
8061
  // `query()` spawns the CLI at once. Any throw between here and the
7755
8062
  // registration in `this.sessions` would leave that child process running
@@ -7772,7 +8079,9 @@ export class ClaudeAcpAgent {
7772
8079
  // Publish the identity BEFORE the guard can refuse this session. A
7773
8080
  // refusal is exactly when the client most needs to know which account it
7774
8081
  // was refused for.
7775
- this.publishSessionAccountIdentity(initializationResult.account);
8082
+ if (authSettingsEpoch === this.authEpoch) {
8083
+ this.publishSessionAccountIdentity(initializationResult.account, settingsManager.getApiKeyHelper(configuredSettingsObject));
8084
+ }
7776
8085
  // Shared with the per-turn guard, so "warn once" spans the whole session.
7777
8086
  const claudeSubscriptionGuard = {};
7778
8087
  if (this.claudeSubscriptionGuardActive()) {
@@ -7974,7 +8283,7 @@ export class ClaudeAcpAgent {
7974
8283
  messageIdToUuid: creationOpts.replayState?.messageIdToUuid ?? new Map(),
7975
8284
  sessionFailureState: creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(),
7976
8285
  claudeSubscriptionGuard,
7977
- accountKind: fromAccountInfo(initializationResult.account)?.kind,
8286
+ accountKind: fromAccountInfo(initializationResult.account, settingsManager.getApiKeyHelper(configuredSettingsObject))?.kind,
7978
8287
  fileChangeReporter,
7979
8288
  };
7980
8289
  timing.phase("register");
@@ -8126,27 +8435,34 @@ function cancelledOutcome(session, turn) {
8126
8435
  }
8127
8436
  /** Whether the model request that `status` (a `status: requesting` frame)
8128
8437
  * starts may belong to the work a cancel stopped. It may while:
8129
- * - a turn is unsettled: the stopped turn, whose result is still to come, or
8130
- * a queued prompt, which the cancel abandons (one that arrived while the
8131
- * cancel awaited is recorded only when the cancel sweeps the queue) or
8132
- * whose echo ends the cancel right after its request anyway;
8438
+ * - a turn in {@link Session.cancelledPrompts} is unsettled: the stopped
8439
+ * turn, whose result is still to come;
8133
8440
  * - the orphan accounting still expects the result of an abandoned prompt
8134
8441
  * that Claude Code may still run, which it can fold into its own
8135
8442
  * follow-up after that follow-up's first request;
8136
8443
  * - the request serves one of {@link Session.cancelledPrompts}, as an
8137
8444
  * abandoned prompt's request does even when the interrupt receipt left it
8138
- * out of the orphan accounting. */
8445
+ * out of the orphan accounting;
8446
+ * - the request serves an unsettled prompt. One that arrived while the cancel
8447
+ * awaited is recorded only when the cancel sweeps the queue, and its echo
8448
+ * ends the cancel right after its request when the cancel does not.
8449
+ * A prompt that is merely queued does not make another request belong to
8450
+ * the stopped work: a follow-up that Claude Code runs before a prompt sent
8451
+ * after the cancel names no prompt. */
8139
8452
  function servesCancelledWork(session, status) {
8140
8453
  const cancelled = session.cancelledPrompts;
8141
8454
  if (!cancelled)
8142
8455
  return false;
8143
- if ((session.turnQueue ?? []).some((turn) => !turn.settled))
8456
+ const unsettled = (session.turnQueue ?? []).filter((turn) => !turn.settled);
8457
+ if (unsettled.some((turn) => cancelled.has(turn.promptUuid)))
8144
8458
  return true;
8145
8459
  if ((session.pendingOrphanResults ?? 0) > 0 || (session.orphanCommands?.size ?? 0) > 0) {
8146
8460
  return true;
8147
8461
  }
8148
8462
  const served = status.user_message_uuids;
8149
- return (Array.isArray(served) && served.some((uuid) => typeof uuid === "string" && cancelled.has(uuid)));
8463
+ return (Array.isArray(served) &&
8464
+ served.some((uuid) => typeof uuid === "string" &&
8465
+ (cancelled.has(uuid) || unsettled.some((turn) => turn.promptUuid === uuid))));
8150
8466
  }
8151
8467
  /** `_meta.quota` for a prompt response: what the turn spent, shaped like
8152
8468
  * codex-acp's (snake_case container keys, camelCase counters) so a client
@@ -8872,6 +9188,18 @@ function isTaskTool(toolName) {
8872
9188
  function shouldEmitToolCall(toolName) {
8873
9189
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
8874
9190
  }
9191
+ const MAX_TOOL_CALL_PARENTS = 1000;
9192
+ /** Records the Agent/Task call that started the subagent making `toolCallId`. */
9193
+ function recordToolCallParent(session, toolCallId, parentToolUseId) {
9194
+ const parents = (session.toolCallParents ??= new Map());
9195
+ parents.delete(toolCallId);
9196
+ parents.set(toolCallId, parentToolUseId);
9197
+ if (parents.size > MAX_TOOL_CALL_PARENTS) {
9198
+ const oldest = parents.keys().next().value;
9199
+ if (oldest !== undefined)
9200
+ parents.delete(oldest);
9201
+ }
9202
+ }
8875
9203
  const MAX_DISPATCHED_TOOL_CALLS = 1000;
8876
9204
  /** Records a tool call that Claude Code sent to the tool runner. */
8877
9205
  function recordDispatchedToolCall(session, toolCallId) {
@@ -8984,6 +9312,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
8984
9312
  let update = null;
8985
9313
  // The tool call that this chunk finishes, if it is a tool result.
8986
9314
  let finishedToolCallId;
9315
+ // The plan that this chunk approves, if it is an ExitPlanMode result.
9316
+ let approvedPlanUpdate;
8987
9317
  switch (chunk.type) {
8988
9318
  case "text":
8989
9319
  case "text_delta": {
@@ -9141,7 +9471,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9141
9471
  update: {
9142
9472
  toolCallId: chunk.tool_use_id,
9143
9473
  sessionUpdate: "tool_call_update",
9144
- status: renderer.resultStatus("is_error" in chunk && chunk.is_error === true, nonExecution),
9474
+ status: renderer.resultStatus("is_error" in chunk && chunk.is_error === true, nonExecution, { cancelled: options?.cancelled }),
9145
9475
  rawOutput: chunk.content,
9146
9476
  },
9147
9477
  });
@@ -9170,7 +9500,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9170
9500
  },
9171
9501
  toolCallId: chunk.tool_use_id,
9172
9502
  sessionUpdate: "tool_call_update",
9173
- status: renderer.resultStatus("is_error" in chunk && chunk.is_error === true, nonExecution),
9503
+ status: renderer.resultStatus("is_error" in chunk && chunk.is_error === true, nonExecution, { cancelled: options?.cancelled }),
9174
9504
  rawOutput: chunk.content,
9175
9505
  },
9176
9506
  });
@@ -9222,6 +9552,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9222
9552
  .result(toolUse, chunk, {
9223
9553
  structured: toolUseResult,
9224
9554
  nonExecution,
9555
+ cancelled: options?.cancelled,
9225
9556
  })
9226
9557
  .reverse();
9227
9558
  for (const outputUpdate of rest.reverse()) {
@@ -9230,6 +9561,19 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9230
9561
  }
9231
9562
  output.push({ sessionId, update: outputUpdate });
9232
9563
  }
9564
+ // An approved plan of the main conversation becomes the session's
9565
+ // markdown plan, after the tool call's result. An approval that
9566
+ // clears the context ends the tool with an error result instead, and
9567
+ // the restart into the fresh context publishes that plan (see
9568
+ // ExitPlanCoordinator.restart).
9569
+ if (toolUse.name === "ExitPlanMode" &&
9570
+ options?.clientCapabilities?.plan &&
9571
+ !options.parentToolUseId &&
9572
+ !("is_error" in chunk && chunk.is_error)) {
9573
+ const plan = approvedPlan(toolUse.input, toolUseResult);
9574
+ if (plan)
9575
+ approvedPlanUpdate = planModeUpdate(plan);
9576
+ }
9233
9577
  update = finalUpdate;
9234
9578
  }
9235
9579
  // The tool_use is fully resolved now — drop it so a long session doesn't
@@ -9269,6 +9613,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9269
9613
  output.push({ sessionId, update });
9270
9614
  }
9271
9615
  }
9616
+ if (approvedPlanUpdate)
9617
+ output.push({ sessionId, update: approvedPlanUpdate });
9272
9618
  if (finishedToolCallId !== undefined) {
9273
9619
  // The PostToolUse hook can still send the final diff, so the fields
9274
9620
  // stay tracked until its callback leaves the registry.