@agentclientprotocol/claude-agent-acp 0.89.2-preview.8 → 0.90.0

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 +51 -8
  2. package/dist/acp-agent.d.ts +52 -3
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +507 -139
  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 +190 -11
  17. package/dist/native-subagents.d.ts.map +1 -1
  18. package/dist/native-subagents.js +419 -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 +27 -2
  23. package/dist/subagent-history.d.ts.map +1 -1
  24. package/dist/subagent-history.js +38 -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 } 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";
@@ -548,28 +549,6 @@ function subagentLaunchIds(content) {
548
549
  ? [block.id]
549
550
  : []);
550
551
  }
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
552
  function stripSubagentTextAndThinking(content) {
574
553
  if (!Array.isArray(content))
575
554
  return content;
@@ -1172,6 +1151,8 @@ export class ClaudeAcpAgent {
1172
1151
  /** Last auth identity reported to the client, connection-scoped like
1173
1152
  * `authenticate`/`logout`. Undefined means "not determined yet". */
1174
1153
  currentAuthStatus;
1154
+ authStatusSettings;
1155
+ authStatusHelper;
1175
1156
  /** In-flight `claude auth status --json` probe, shared by every caller so
1176
1157
  * a concurrent `initialize` and start-of-prompt read never spawn two CLI
1177
1158
  * processes. */
@@ -1232,6 +1213,7 @@ export class ClaudeAcpAgent {
1232
1213
  },
1233
1214
  applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
1234
1215
  sessionUpdate: (notification) => this.client.sessionUpdate(notification),
1216
+ supportsPlanOperations: () => this.clientCapabilities?.plan != null,
1235
1217
  ensureConsumer: (session, id) => this.ensureConsumer(session, id),
1236
1218
  logError: (message, error) => this.logger.error(message, error),
1237
1219
  destroyReplacement: (id, session) => {
@@ -1378,6 +1360,8 @@ export class ClaudeAcpAgent {
1378
1360
  terminalAuthMethods.push(consoleLoginMethod);
1379
1361
  }
1380
1362
  }
1363
+ // The subagents RFD has only the client capability. AIR's draft also had
1364
+ // this agent capability, which AIR gets (`docs/air-extensions.md`).
1381
1365
  const sessionCapabilities = {
1382
1366
  additionalDirectories: {},
1383
1367
  close: {},
@@ -1385,7 +1369,7 @@ export class ClaudeAcpAgent {
1385
1369
  fork: {},
1386
1370
  list: {},
1387
1371
  resume: {},
1388
- subagents: {},
1372
+ ...(this.toolCallCapabilities.air.client ? { subagents: {} } : {}),
1389
1373
  };
1390
1374
  return {
1391
1375
  protocolVersion: 1,
@@ -1506,14 +1490,30 @@ export class ClaudeAcpAgent {
1506
1490
  configOptions: response.configOptions,
1507
1491
  };
1508
1492
  }
1493
+ /**
1494
+ * Copies the session's transcript into a new session, then starts that
1495
+ * session, so the client can prompt it as soon as the response arrives, as
1496
+ * the schema requires of a fork. It starts like a resumed session: from the
1497
+ * copied transcript, with the model and the permission mode it records. A
1498
+ * later load or resume with the same settings reuses it.
1499
+ */
1509
1500
  async unstable_forkSession(params) {
1510
1501
  if (this.providerUpdate)
1511
1502
  await this.providerUpdate;
1512
- return forkSession(params, {
1503
+ const { sessionId } = await forkSession(params, {
1513
1504
  liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
1514
1505
  logger: this.logger,
1515
1506
  messageIdForGrouping,
1516
1507
  });
1508
+ const { modes, configOptions } = await this.getOrCreateSession({
1509
+ sessionId,
1510
+ cwd: params.cwd,
1511
+ mcpServers: params.mcpServers,
1512
+ additionalDirectories: params.additionalDirectories,
1513
+ _meta: params._meta,
1514
+ });
1515
+ this.afterSetupResponse(sessionId, params.mcpServers ?? []);
1516
+ return { sessionId, modes, configOptions };
1517
1517
  }
1518
1518
  async resumeSession(params) {
1519
1519
  if (this.providerUpdate)
@@ -1682,7 +1682,22 @@ export class ClaudeAcpAgent {
1682
1682
  * under an apiKeyHelper) means "nothing to add", not "logged out" — keep
1683
1683
  * what the CLI probe already established instead of overwriting it.
1684
1684
  */
1685
- publishSessionAccountIdentity(account) {
1685
+ useAuthStatusSettings(manager, settingsOverride, launchEnv) {
1686
+ const helper = manager.getApiKeyHelper(settingsOverride);
1687
+ const changed = manager.getCwd() !== (this.authStatusSettings?.manager.getCwd() ?? process.cwd()) ||
1688
+ helper !== this.authStatusHelper ||
1689
+ JSON.stringify(launchEnv) !== JSON.stringify(this.authStatusSettings?.env);
1690
+ this.authStatusSettings = { manager, override: settingsOverride, env: launchEnv };
1691
+ this.authStatusHelper = helper;
1692
+ if (changed) {
1693
+ // The epoch bump discards any probe that read the old context, and the
1694
+ // session account may carry no identity signal (an apiKeyHelper), so
1695
+ // only a probe in the new context can classify it.
1696
+ this.authEpoch += 1;
1697
+ void this.probeCliAuthStatus({ fresh: true });
1698
+ }
1699
+ }
1700
+ publishSessionAccountIdentity(account, apiKeyHelper) {
1686
1701
  if (this.providerConfig) {
1687
1702
  if (!this.loggedOverriddenAccount) {
1688
1703
  this.loggedOverriddenAccount = true;
@@ -1693,7 +1708,7 @@ export class ClaudeAcpAgent {
1693
1708
  if (this.gatewayAuthRequest) {
1694
1709
  return;
1695
1710
  }
1696
- const fromSession = fromAccountInfo(account);
1711
+ const fromSession = fromAccountInfo(account, apiKeyHelper);
1697
1712
  if (fromSession) {
1698
1713
  this.setAuthStatus(fromSession);
1699
1714
  }
@@ -1726,6 +1741,8 @@ export class ClaudeAcpAgent {
1726
1741
  // auth-affecting event describes a world that no longer exists. Remember
1727
1742
  // which one this read belongs to and drop the answer if it moved on.
1728
1743
  const epoch = this.authEpoch;
1744
+ const context = this.authStatusSettings;
1745
+ const apiKeyHelper = context?.manager.getApiKeyHelper(context.override);
1729
1746
  // ACP gateway auth bypasses the CLI credential store entirely, so the
1730
1747
  // probe would report an identity that is not the one being used. A mere
1731
1748
  // client provider override (`providers/set`) does NOT skip the probe: it
@@ -1737,8 +1754,13 @@ export class ClaudeAcpAgent {
1737
1754
  let stdout;
1738
1755
  try {
1739
1756
  const cliPath = await claudeCliPath();
1740
- ({ stdout } = await execFileAsync(cliPath, ["auth", "status", "--json"], {
1757
+ const args = ["auth", "status", "--json"];
1758
+ if (context?.override)
1759
+ args.push("--settings", JSON.stringify(context.override));
1760
+ ({ stdout } = await execFileAsync(cliPath, args, {
1741
1761
  timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
1762
+ cwd: context?.manager.getCwd() ?? process.cwd(),
1763
+ env: context?.env ? { ...process.env, ...context.env } : process.env,
1742
1764
  }));
1743
1765
  }
1744
1766
  catch (error) {
@@ -1761,7 +1783,16 @@ export class ClaudeAcpAgent {
1761
1783
  }
1762
1784
  stdout = failed.stdout;
1763
1785
  }
1764
- const status = fromCliStatus(stdout);
1786
+ let status = fromCliStatus(stdout, apiKeyHelper);
1787
+ if (!context && status?.kind === "api_key" && status.detail === "API key helper") {
1788
+ try {
1789
+ const resolved = await resolveSettings({ cwd: process.cwd() });
1790
+ status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper);
1791
+ }
1792
+ catch {
1793
+ this.logger.error("Could not resolve auth source settings");
1794
+ }
1795
+ }
1765
1796
  if (!status) {
1766
1797
  this.logger.error("claude auth status returned unparseable output");
1767
1798
  return undefined;
@@ -2027,6 +2058,7 @@ export class ClaudeAcpAgent {
2027
2058
  // that finds the new credential must not be the one the refusal skipped.
2028
2059
  // The guard itself consumes the result only at the next turn boundary (see
2029
2060
  // `markSessionsWhoseAccountKindChanged`).
2061
+ this.useAuthStatusSettings(session.settingsManager, session.effortSettingsOverride, session.creationParams?._meta?.claudeCode?.options?.env);
2030
2062
  void this.probeCliAuthStatus();
2031
2063
  const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
2032
2064
  if (signOutRespawn)
@@ -2245,7 +2277,7 @@ export class ClaudeAcpAgent {
2245
2277
  logger: this.logger,
2246
2278
  // The guard reads the account anyway; reuse that read to keep the
2247
2279
  // reported identity current, refusal or not.
2248
- onAccount: (account) => this.publishSessionAccountIdentity(account),
2280
+ onAccount: (account) => this.publishSessionAccountIdentity(account, session.settingsManager.getApiKeyHelper(session.effortSettingsOverride)),
2249
2281
  });
2250
2282
  }
2251
2283
  async goal(params) {
@@ -2500,6 +2532,10 @@ export class ClaudeAcpAgent {
2500
2532
  streamedBlocksByParent.set(key, (blocks = []));
2501
2533
  return blocks;
2502
2534
  };
2535
+ // The streams, keyed like streamedBlocksByParent, that dropped a delta
2536
+ // after a cancel since their record last reset, so the record no longer
2537
+ // matches what the client got (see the stream_event case).
2538
+ const droppedStreams = new Set();
2503
2539
  // A client gets the consolidated subagent text when it negotiated the
2504
2540
  // transcript extension or the `forwardSubagentText` session option. AIR
2505
2541
  // also gets it with native subagent sessions, and AIR gets no streamed
@@ -2528,7 +2564,7 @@ export class ClaudeAcpAgent {
2528
2564
  * recognizable by the `parentToolUseId` meta that toAcpNotifications
2529
2565
  * stamps from `parent_tool_use_id`, and never reach the top-level feed
2530
2566
  * 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));
2567
+ const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(subagentForm(this.clientCapabilities), params.sessionId, session, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.logger));
2532
2568
  // Adapter-composed advisories (hook feedback, model fallbacks) are live
2533
2569
  // events, not something the model said. Clients on the notice contract get
2534
2570
  // them as `notice` updates; the rest keep the bold-label transcript line.
@@ -2563,6 +2599,19 @@ export class ClaudeAcpAgent {
2563
2599
  const eagerOwnerSessionId = toolCallId
2564
2600
  ? session.eagerToolCallSessions?.get(toolCallId)
2565
2601
  : undefined;
2602
+ // Before routing, which can buffer or drop the update: a cancel still has
2603
+ // to know the parent of a call that is open here. A later update can be
2604
+ // the first to name it, for a call that a permission request emitted.
2605
+ if (toolCallId &&
2606
+ update.sessionUpdate === "tool_call_update" &&
2607
+ isFinalToolCallStatus(update.status)) {
2608
+ session.toolCallParents?.delete(toolCallId);
2609
+ }
2610
+ else if (toolCallId &&
2611
+ claudeMeta?.parentToolUseId &&
2612
+ session.emittedToolCalls.has(toolCallId)) {
2613
+ recordToolCallParent(session, toolCallId, claudeMeta.parentToolUseId);
2614
+ }
2566
2615
  const routedNotification = await subagents.route(notification, sendUpdate, eagerOwnerSessionId);
2567
2616
  if (!routedNotification) {
2568
2617
  // Native Agent/Task control calls are intentionally not transcript
@@ -3011,7 +3060,35 @@ export class ClaudeAcpAgent {
3011
3060
  }
3012
3061
  this.syncAwaitingUser(params.sessionId, session);
3013
3062
  };
3014
- const settleLiveTask = (taskId) => settleLiveBackgroundTask(session, taskId);
3063
+ /** Ends the display terminal of a command that went to the background, once
3064
+ * its task ends (see `Session.backgroundTerminals`). Claude Code reports no
3065
+ * exit code for it, and `completed` does not mean 0: Claude Code accepts
3066
+ * some non-zero codes as a success, such as grep's 1. */
3067
+ const endBackgroundTerminal = async (taskId) => {
3068
+ const terminal = session.backgroundTerminals?.get(taskId);
3069
+ if (!terminal)
3070
+ return;
3071
+ session.backgroundTerminals.delete(taskId);
3072
+ await sendUpdate({
3073
+ sessionId: params.sessionId,
3074
+ update: {
3075
+ sessionUpdate: "tool_call_update",
3076
+ toolCallId: terminal.toolCallId,
3077
+ _meta: {
3078
+ ...(terminal.parentToolUseId
3079
+ ? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
3080
+ : {}),
3081
+ terminal_exit: { terminal_id: terminal.toolCallId, exit_code: null, signal: null },
3082
+ },
3083
+ },
3084
+ });
3085
+ };
3086
+ /** Settles a task. Its request may still be open, and an agent without a
3087
+ * record no longer blocks the foreground work, so that is checked again. */
3088
+ const settleLiveTask = (taskId) => {
3089
+ settleLiveBackgroundTask(session, taskId);
3090
+ this.syncAwaitingUser(params.sessionId, session);
3091
+ };
3015
3092
  /** Registers a settled subagent again when the SDK resumes it without a
3016
3093
  * new `task_started`. The active turn is the one that resumed it, so it
3017
3094
  * holds until the subagent settles again. A repeated resume signal finds
@@ -3082,6 +3159,92 @@ export class ClaudeAcpAgent {
3082
3159
  await settleActive(outcome);
3083
3160
  }
3084
3161
  };
3162
+ /** Sends `report`, which ends the tool call `toolCallId` without its result
3163
+ * (see `AcpToolCallRenderer.unfinished`), and forgets the call. Forgetting
3164
+ * comes first, so that a late hook or a second close cannot overwrite the
3165
+ * report. */
3166
+ const closeToolCall = async (toolCallId, report) => {
3167
+ unregisterHookCallback(toolCallId);
3168
+ session.emittedToolCalls.delete(toolCallId);
3169
+ delete session.toolUseCache[toolCallId];
3170
+ session.toolCallFields?.delete(toolCallId);
3171
+ session.dispatchedToolCalls?.delete(toolCallId);
3172
+ session.toolCallParents?.delete(toolCallId);
3173
+ await sendUpdate({ sessionId: params.sessionId, update: report });
3174
+ };
3175
+ /** Whether the subagent that the Agent call `agentToolUseId` started runs in
3176
+ * the background and has not ended. */
3177
+ const runsInBackground = (agentToolUseId) => [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent &&
3178
+ task.backgrounded === true &&
3179
+ !task.endedPerLevel &&
3180
+ task.parentToolUseId === agentToolUseId);
3181
+ /** Closes the tool calls that the work a cancel stopped left open, as that
3182
+ * work ends. Claude Code's interrupt ends each running tool of the main
3183
+ * conversation with an error result, which `sendInterruptedToolResults`
3184
+ * delivers. What is still open here has no result to deliver:
3185
+ * - a tool of a subagent: Claude Code writes its interrupted result only to
3186
+ * the subagent's own transcript, not to the stream;
3187
+ * - a tool call that Claude abandoned before it ran;
3188
+ * - every tool call, when the stream gives no results at all, as when the
3189
+ * force-cancel backstop ends a wedged turn.
3190
+ * A foreground subagent ends with the work that waits on it. A background
3191
+ * subagent keeps its tool calls open until the SDK reports its end, which
3192
+ * Claude Code 2.1.293 does for the ones it stops, before the turn's result
3193
+ * (see `NativeSubagentRuntime.parentCancelled`).
3194
+ * Runs once per turn: a second call, from the other settling path, waits
3195
+ * for the first (see `Turn.closingCancelledToolCalls`). */
3196
+ const closeCancelledToolCalls = (turn) => (turn.closingCancelledToolCalls ??= (async () => {
3197
+ const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
3198
+ for (const toolCallId of [...session.emittedToolCalls]) {
3199
+ const parentToolUseId = session.toolCallParents?.get(toolCallId);
3200
+ if (parentToolUseId !== undefined && runsInBackground(parentToolUseId))
3201
+ continue;
3202
+ const report = renderer.unfinished({
3203
+ toolCallId,
3204
+ ran: session.dispatchedToolCalls?.has(toolCallId) === true,
3205
+ cancelled: true,
3206
+ });
3207
+ // Named as the call's other updates are: the subagent runtime routes the
3208
+ // report with them, to the child session, or to none that has ended.
3209
+ if (parentToolUseId !== undefined)
3210
+ stampParentToolUseId(report, parentToolUseId);
3211
+ await closeToolCall(toolCallId, report);
3212
+ }
3213
+ })());
3214
+ session.closeCancelledToolCalls = closeCancelledToolCalls;
3215
+ /** Delivers the results that the work a cancel stopped sends for tool calls
3216
+ * that the client holds open. Claude Code's interrupt ends each running tool
3217
+ * of the main conversation with an error result, before the result that
3218
+ * ends the turn. The tool call gets that result like any other, so its
3219
+ * final status and `nonExecutionKind` reach the client. Nothing else of the
3220
+ * message is applied: the other effects of a tool result (async tasks, plan
3221
+ * state, the ExitPlanMode flow) belong to the stopped work, which stays
3222
+ * dropped. */
3223
+ const sendInterruptedToolResults = async (message) => {
3224
+ const content = message.message.content;
3225
+ if (!Array.isArray(content))
3226
+ return;
3227
+ const results = content.filter((block) => block.type === "tool_result" && session.emittedToolCalls.has(block.tool_use_id));
3228
+ if (results.length === 0)
3229
+ return;
3230
+ const resultCount = content.filter((block) => block.type === "tool_result").length;
3231
+ for (const notification of toAcpNotifications(results, "user", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
3232
+ clientCapabilities: this.clientCapabilities,
3233
+ toolCallCapabilities: this.toolCallCapabilities,
3234
+ parentToolUseId: message.parent_tool_use_id,
3235
+ cwd: session.cwd,
3236
+ taskState: session.taskState,
3237
+ emittedToolCalls: session.emittedToolCalls,
3238
+ toolCallFields: toolCallFieldsOf(session),
3239
+ messageId: messageIdForGrouping(message),
3240
+ // `tool_use_result` describes the one result of its message.
3241
+ toolUseResult: results.length === resultCount ? message.tool_use_result : undefined,
3242
+ toolResultMeta: message.tool_result_meta,
3243
+ cancelled: true,
3244
+ })) {
3245
+ await sendUpdate(notification);
3246
+ }
3247
+ };
3085
3248
  /** At the actual turn boundary, preview its checkpoint, settle the active
3086
3249
  * turn exactly once, disarm the force-cancel backstop, and drop it from
3087
3250
  * the queue. Cancellation and provider failures skip checkpoint I/O. */
@@ -3107,16 +3270,7 @@ export class ClaudeAcpAgent {
3107
3270
  * ended meanwhile.
3108
3271
  */
3109
3272
  const closeUnfinished = async (toolCallId, ran) => {
3110
- // A late hook must not overwrite the report we are about to send.
3111
- unregisterHookCallback(toolCallId);
3112
- session.emittedToolCalls.delete(toolCallId);
3113
- delete session.toolUseCache[toolCallId];
3114
- session.toolCallFields?.delete(toolCallId);
3115
- session.dispatchedToolCalls?.delete(toolCallId);
3116
- await sendUpdate({
3117
- sessionId: params.sessionId,
3118
- update: renderer.unfinished({ toolCallId, ran }),
3119
- });
3273
+ await closeToolCall(toolCallId, renderer.unfinished({ toolCallId, ran }));
3120
3274
  // Cancellation can arrive while we await the client update.
3121
3275
  if (turn.settled || session.activeTurn !== turn)
3122
3276
  return false;
@@ -3149,6 +3303,15 @@ export class ClaudeAcpAgent {
3149
3303
  return;
3150
3304
  }
3151
3305
  }
3306
+ // The client is told that the work a cancel stopped ended (the prompt
3307
+ // response on v1, `idle` on v2) only after every tool call that work
3308
+ // opened has ended.
3309
+ if (result.stopReason === "cancelled" && session.cancelled) {
3310
+ await closeCancelledToolCalls(turn);
3311
+ // `cancelTurns` can settle the turn while the reports are in flight.
3312
+ if (turn.settled || session.activeTurn !== turn)
3313
+ return;
3314
+ }
3152
3315
  turn.settling = true;
3153
3316
  turn.settlingOutcome = result;
3154
3317
  if (reportReason === "notReported") {
@@ -4059,9 +4222,14 @@ export class ClaudeAcpAgent {
4059
4222
  this.logger.log(`Ignoring task_notification for task ${message.task_id} from earlier run ${message.run_id}`);
4060
4223
  break;
4061
4224
  }
4225
+ // In the RFD form, a resumed child runs under the call that
4226
+ // resumed it, and the end of an earlier delegation ends
4227
+ // nothing: the child and its live task belong to the new run.
4228
+ if (subagents.isStaleEnd(message.task_id, message.tool_use_id))
4229
+ break;
4062
4230
  // The task settled — no further tool calls can originate
4063
4231
  // from it, so its registry entry can be dropped.
4064
- await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id);
4232
+ await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id, { reason: message.reason });
4065
4233
  await asyncTasks.taskNotification({
4066
4234
  task_id: message.task_id,
4067
4235
  status: message.status,
@@ -4073,7 +4241,9 @@ export class ClaudeAcpAgent {
4073
4241
  subagents.discardPending(message.tool_use_id);
4074
4242
  // The subagent streams no more text under this parent.
4075
4243
  streamedBlocksByParent.delete(message.tool_use_id);
4244
+ droppedStreams.delete(message.tool_use_id);
4076
4245
  }
4246
+ await endBackgroundTerminal(message.task_id);
4077
4247
  settleLiveTask(message.task_id);
4078
4248
  break;
4079
4249
  case "task_updated":
@@ -4098,10 +4268,13 @@ export class ClaudeAcpAgent {
4098
4268
  if (message.patch.status === "completed" ||
4099
4269
  message.patch.status === "failed" ||
4100
4270
  message.patch.status === "killed") {
4101
- await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
4271
+ await subagents.finishTask(message.task_id, message.patch.status, sendUpdate, undefined, { error: message.patch.status === "failed" ? message.patch.error : undefined });
4102
4272
  const parentToolUseId = session.liveBackgroundTasks.get(message.task_id)?.parentToolUseId;
4103
- if (parentToolUseId)
4273
+ if (parentToolUseId) {
4104
4274
  streamedBlocksByParent.delete(parentToolUseId);
4275
+ droppedStreams.delete(parentToolUseId);
4276
+ }
4277
+ await endBackgroundTerminal(message.task_id);
4105
4278
  settleLiveTask(message.task_id);
4106
4279
  }
4107
4280
  else if (message.patch.status === "running" ||
@@ -4925,43 +5098,14 @@ export class ClaudeAcpAgent {
4925
5098
  // reset, and the synthetic-auth/system/local-command paths
4926
5099
  // `break` early too. Block indices restart at 0 each message, so
4927
5100
  // leftover entries would otherwise collide with this message's
4928
- // blocks and re-emit (or truncate) already-streamed text. Gated on
4929
- // `parent_tool_use_id === null` so a subagent stream can't clear
4930
- // the top-level record. Fires once, before any of this message's
4931
- // blocks, so it doesn't disturb the mid-message turn-activation
4932
- // path the way resetting on turn activation would.
5101
+ // blocks and re-emit (or truncate) already-streamed text. Keyed by
5102
+ // the stream, so a subagent's message can't clear the top-level
5103
+ // record. Fires once, before any of this message's blocks, so it
5104
+ // doesn't disturb the mid-message turn-activation path the way
5105
+ // resetting on turn activation would. The record's drop latch
5106
+ // (see below) resets with it.
4933
5107
  streamedBlocksOf(message.parent_tool_use_id).length = 0;
4934
- }
4935
- // Accumulate the text/thinking actually streamed live, so the
4936
- // `assistant` case below can diff its assembled blocks against what
4937
- // already reached the client as chunks and forward only the
4938
- // remainder. Each stream (top level or one subagent) keeps its own
4939
- // record, so a subagent cannot attribute content to another stream.
4940
- // Contiguous deltas of the same block (same index and type) extend
4941
- // the current entry; anything else opens a new one.
4942
- if (message.event.type === "content_block_delta") {
4943
- const delta = message.event.delta;
4944
- const chunk = delta.type === "text_delta"
4945
- ? { type: "text", text: delta.text }
4946
- : delta.type === "thinking_delta"
4947
- ? { type: "thinking", text: delta.thinking }
4948
- : undefined;
4949
- // Skip empty deltas (some gateways emit empty thinking chunks —
4950
- // #793): appending "" is a no-op, but pushing a "" entry would
4951
- // create a block the consolidated handler's `text.length > 0`
4952
- // guard can never consume, stalling the diff cursor and
4953
- // re-emitting the next block as a duplicate.
4954
- if (chunk?.text) {
4955
- const index = message.event.index;
4956
- const streamedBlocks = streamedBlocksOf(message.parent_tool_use_id);
4957
- const last = streamedBlocks[streamedBlocks.length - 1];
4958
- if (last && last.index === index && last.type === chunk.type) {
4959
- last.text += chunk.text;
4960
- }
4961
- else {
4962
- streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
4963
- }
4964
- }
5108
+ droppedStreams.delete(message.parent_tool_use_id ?? "");
4965
5109
  }
4966
5110
  if (message.parent_tool_use_id === null &&
4967
5111
  (message.event.type === "message_start" || message.event.type === "message_delta")) {
@@ -5015,7 +5159,71 @@ export class ClaudeAcpAgent {
5015
5159
  });
5016
5160
  }
5017
5161
  }
5018
- for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, this.client, this.logger, {
5162
+ // After a cancel, output streams to the client until the client is
5163
+ // told that the stopped work ended (see workEndReported), so what
5164
+ // the interrupt flushes before then still goes out, as both
5165
+ // protocol versions allow. After that, while the window after the
5166
+ // cancel is open, streamed text, thinking and tool calls are
5167
+ // dropped like the consolidated messages they belong to (see the
5168
+ // `cancelled` check in the user/assistant case). Otherwise the
5169
+ // answer to a prompt the cancel abandoned, which Claude Code had
5170
+ // already taken off its queue, would stream to the client after the
5171
+ // cancel was reported.
5172
+ //
5173
+ // Where the consolidated message delivers the text (the same
5174
+ // condition as in the user/assistant case), a stream that loses a
5175
+ // delta keeps dropping until the record below resets: at the next
5176
+ // message, or when a consolidated message consumes the record. If
5177
+ // the next prompt's echo ends the window mid-block, the record then
5178
+ // stays empty and the consolidated message delivers the block's
5179
+ // whole text once. A record holding only the block's tail is not a
5180
+ // prefix of the text, so the text would be sent again (see
5181
+ // unstreamedRemainder).
5182
+ const streamKey = message.parent_tool_use_id ?? "";
5183
+ if (session.cancelled && this.workEndReported(params.sessionId, session)) {
5184
+ if (message.parent_tool_use_id === null || forwardsSubagentText()) {
5185
+ droppedStreams.add(streamKey);
5186
+ }
5187
+ break;
5188
+ }
5189
+ if (droppedStreams.has(streamKey)) {
5190
+ break;
5191
+ }
5192
+ // Accumulate the text/thinking actually streamed live, so the
5193
+ // `assistant` case below can diff its assembled blocks against what
5194
+ // already reached the client as chunks and forward only the
5195
+ // remainder. Each stream (top level or one subagent) keeps its own
5196
+ // record, so a subagent cannot attribute content to another stream.
5197
+ // Contiguous deltas of the same block (same index and type) extend
5198
+ // the current entry; anything else opens a new one.
5199
+ if (message.event.type === "content_block_delta") {
5200
+ const delta = message.event.delta;
5201
+ const chunk = delta.type === "text_delta"
5202
+ ? { type: "text", text: delta.text }
5203
+ : delta.type === "thinking_delta"
5204
+ ? { type: "thinking", text: delta.thinking }
5205
+ : undefined;
5206
+ // Skip empty deltas (some gateways emit empty thinking chunks —
5207
+ // #793): appending "" is a no-op, but pushing a "" entry would
5208
+ // create a block the consolidated handler's `text.length > 0`
5209
+ // guard can never consume, stalling the diff cursor and
5210
+ // re-emitting the next block as a duplicate.
5211
+ if (chunk?.text) {
5212
+ const index = message.event.index;
5213
+ const streamedBlocks = streamedBlocksOf(message.parent_tool_use_id);
5214
+ const last = streamedBlocks[streamedBlocks.length - 1];
5215
+ if (last && last.index === index && last.type === chunk.type) {
5216
+ last.text += chunk.text;
5217
+ }
5218
+ else {
5219
+ streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
5220
+ }
5221
+ }
5222
+ }
5223
+ // The stream sees a tool_use first, so the tool's PostToolUse hook
5224
+ // is registered here, and publishes through this client: a
5225
+ // subagent's hook update must reach the child session too.
5226
+ for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
5019
5227
  clientCapabilities: this.clientCapabilities,
5020
5228
  toolCallCapabilities: this.toolCallCapabilities,
5021
5229
  cwd: session.cwd,
@@ -5148,6 +5356,13 @@ export class ClaudeAcpAgent {
5148
5356
  }
5149
5357
  }
5150
5358
  if (session.cancelled) {
5359
+ // The work the cancel stopped ends its tools with error results.
5360
+ // Until the client is told that the work ended, they close the
5361
+ // tool calls it opened. Everything else that work sends is
5362
+ // dropped, and so is everything after that report.
5363
+ if (message.type === "user" && !this.workEndReported(params.sessionId, session)) {
5364
+ await sendInterruptedToolResults(message);
5365
+ }
5151
5366
  break;
5152
5367
  }
5153
5368
  // Synthetic assistant frames carry the CLI's local-command output.
@@ -5279,6 +5494,7 @@ export class ClaudeAcpAgent {
5279
5494
  const streamed = streamedBlocksOf(message.parent_tool_use_id);
5280
5495
  content = unstreamedRemainder(message.message.content, streamed);
5281
5496
  streamed.length = 0;
5497
+ droppedStreams.delete(message.parent_tool_use_id ?? "");
5282
5498
  }
5283
5499
  else if (message.type === "assistant") {
5284
5500
  // Nested text/thinking stays internal for a client that does
@@ -5291,19 +5507,41 @@ export class ClaudeAcpAgent {
5291
5507
  }
5292
5508
  const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
5293
5509
  let backgroundedToolCalls = new Set();
5510
+ let runningTerminal;
5294
5511
  if (message.type === "user") {
5295
5512
  rememberResolvedToolNames(session, content);
5296
5513
  const backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
5297
5514
  if (backgroundBashTask)
5298
5515
  await asyncTasks.taskBackgrounded(backgroundBashTask);
5516
+ // The command's terminal runs until its task ends, unless the
5517
+ // task has ended already (a fast command can end before the tool
5518
+ // result comes).
5519
+ const terminalId = backgroundBashTask?.toolCallId;
5520
+ if (terminalId &&
5521
+ this.toolCallCapabilities.terminalOutput &&
5522
+ !this.toolCallCapabilities.air.client &&
5523
+ session.liveBackgroundTasks.has(backgroundBashTask.taskId)) {
5524
+ runningTerminal = terminalId;
5525
+ (session.backgroundTerminals ??= new Map()).set(backgroundBashTask.taskId, {
5526
+ toolCallId: terminalId,
5527
+ ...(message.parent_tool_use_id
5528
+ ? { parentToolUseId: message.parent_tool_use_id }
5529
+ : {}),
5530
+ });
5531
+ }
5299
5532
  await asyncTasks.toolResults(content);
5300
5533
  backgroundedToolCalls = backgroundedBashToolCallIds(content, session.toolUseCache, asyncTasks, backgroundBashTask);
5301
5534
  const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
5302
5535
  if (resumedAgentId) {
5303
- resumeLiveTask(resumedAgentId);
5304
- await subagents.taskResumed(resumedAgentId, sendUpdate, sendMessageResumePrompt(session.toolUseCache, resumedAgentId, Array.isArray(content)
5536
+ const resume = sendMessageResume(session.toolUseCache, resumedAgentId, Array.isArray(content)
5305
5537
  ? content.flatMap((block) => block.type === "tool_result" ? [block.tool_use_id] : [])
5306
- : []));
5538
+ : []);
5539
+ // In the RFD form the result can arrive after the run it
5540
+ // started ended, and then resumes nothing.
5541
+ if (!subagents.isEndedDelegation(resumedAgentId, resume?.toolUseId)) {
5542
+ resumeLiveTask(resumedAgentId);
5543
+ await subagents.sendMessageResumed(resumedAgentId, sendUpdate, resume?.text, resume?.toolUseId);
5544
+ }
5307
5545
  }
5308
5546
  }
5309
5547
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
@@ -5326,7 +5564,7 @@ export class ClaudeAcpAgent {
5326
5564
  // filtered out of `content` above; blocks that do pass through
5327
5565
  // (e.g. a subagent image) carry the stamped parentToolUseId
5328
5566
  // meta and are excluded there.
5329
- await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled));
5567
+ await sendUpdate(withRunningTerminal(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled), runningTerminal));
5330
5568
  }
5331
5569
  break;
5332
5570
  }
@@ -5350,10 +5588,14 @@ export class ClaudeAcpAgent {
5350
5588
  if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
5351
5589
  break;
5352
5590
  }
5353
- const subagentParentToolUseId = message.parent_tool_use_id
5354
- ? [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id)
5355
- ? message.parent_tool_use_id
5356
- : undefined
5591
+ // The beat is in the subagent whose Agent call is
5592
+ // `parent_tool_use_id`, unless it reports on that Agent call
5593
+ // itself: the Agent call's own heartbeat falls back to its id, and
5594
+ // a tool call is never its own parent.
5595
+ const subagentParentToolUseId = message.parent_tool_use_id &&
5596
+ message.parent_tool_use_id !== toolCallId &&
5597
+ [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id)
5598
+ ? message.parent_tool_use_id
5357
5599
  : undefined;
5358
5600
  // `tool_name` names the tool that reports the beat. When the beat
5359
5601
  // falls back to the parent call, AIR gets the name of that call,
@@ -5576,17 +5818,29 @@ export class ClaudeAcpAgent {
5576
5818
  // their compaction is running. Close it before ANY cancelled prompt
5577
5819
  // resolves, not just the active-turn idle/backstop paths.
5578
5820
  await session.contextCompaction?.interrupt();
5821
+ // A cancel drops the state of each tool call (the session that a
5822
+ // permission request emitted it in, the fields the client holds, the hook
5823
+ // callbacks), even when publishing fails. AIR's draft ends every child
5824
+ // here, so a later update of an ended child's tool call is then dropped
5825
+ // instead of reaching that child's session. The RFD form keeps the state:
5826
+ // a background subagent can keep running after a cancel
5827
+ // (agentclientprotocol/agent-client-protocol#2308), and its tool call that
5828
+ // a permission request emitted on the root before the child was announced
5829
+ // must stay on the root, which the RFD requires. Each tool call drops its
5830
+ // own state when it ends.
5579
5831
  try {
5580
- await session.nativeSubagentRuntime?.finishAll("cancelled", session.nativeSubagentDeliver ??
5832
+ await session.nativeSubagentRuntime?.parentCancelled(session.nativeSubagentDeliver ??
5581
5833
  (async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification))));
5582
5834
  }
5583
5835
  catch (error) {
5584
5836
  this.logger.error(`Session ${params.sessionId}: failed to publish cancelled subagent state`, error);
5585
5837
  }
5586
5838
  finally {
5587
- session.eagerToolCallSessions?.clear();
5588
- session.toolCallFields?.clear();
5589
- clearHookCallbacks(params.sessionId);
5839
+ if (session.nativeSubagentRuntime?.form !== "rfd") {
5840
+ session.eagerToolCallSessions?.clear();
5841
+ session.toolCallFields?.clear();
5842
+ clearHookCallbacks(params.sessionId);
5843
+ }
5590
5844
  }
5591
5845
  // The user messages this cancel abandons while Claude Code may still have
5592
5846
  // them queued: they are dropped from its queue before the interrupt.
@@ -5700,7 +5954,8 @@ export class ClaudeAcpAgent {
5700
5954
  // permission request) is left to that cycle's interrupted result, like any
5701
5955
  // live turn, or to the idle when no result comes. The captured outcome
5702
5956
  // preserves usage and metadata (issue #844), while reporter state makes a
5703
- // late checkpoint response harmless.
5957
+ // late checkpoint response harmless. The turn's open tool calls close
5958
+ // before its response, as in the consumer's settle.
5704
5959
  // Since CLI 2.1.270, Claude Code reports `running` for as long as
5705
5960
  // background agents live, so the session state cannot tell a live
5706
5961
  // followup from a hold. With request markers, a followup is live when one
@@ -5713,7 +5968,10 @@ export class ClaudeAcpAgent {
5713
5968
  if (active &&
5714
5969
  (active.settling || (isHeldOpen(active) && !followupLive)) &&
5715
5970
  (active.deferredSettle ?? active.settlingOutcome)) {
5971
+ const closingToolCalls = session.closeCancelledToolCalls?.(active);
5716
5972
  session.fileChangeReporter?.finish(active.fileChangeReport, "cancelled");
5973
+ // Settled before the reports are awaited, so that the consumer, which
5974
+ // keeps running meanwhile, cannot settle the turn a second time.
5717
5975
  active.settled = true;
5718
5976
  // Mirror settleActive's invariants (it is consumer-scoped and
5719
5977
  // unreachable from here): disarm the backstop — none should be
@@ -5745,7 +6003,15 @@ export class ClaudeAcpAgent {
5745
6003
  if (session.lastSessionState !== "idle") {
5746
6004
  session.owedTrailingIdles++;
5747
6005
  }
5748
- active.resolve(cancelledOutcome(session, active));
6006
+ try {
6007
+ await closingToolCalls;
6008
+ }
6009
+ catch (error) {
6010
+ this.logger.error(`Session ${params.sessionId}: failed to publish cancelled tool state`, error);
6011
+ }
6012
+ finally {
6013
+ active.resolve(cancelledOutcome(session, active));
6014
+ }
5749
6015
  }
5750
6016
  }
5751
6017
  // A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
@@ -6048,7 +6314,12 @@ export class ClaudeAcpAgent {
6048
6314
  // A replay rebuilds the client view, so its plan goes out again.
6049
6315
  if (session?.taskState)
6050
6316
  forgetPublishedTaskPlan(session.taskState);
6051
- const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
6317
+ // A subagent's text replays onto the root only for a client that gets it
6318
+ // there live: one without native subagent sessions that asked for the
6319
+ // flattened transcript. With native sessions the text goes to the child
6320
+ // session, which AIR's replay restores and the RFD form leaves out for now.
6321
+ const forwardSubagentText = !clientSupportsSubagents(this.clientCapabilities) &&
6322
+ (session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities));
6052
6323
  const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
6053
6324
  const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
6054
6325
  const sessionFailures = session && supportsTypedFailures
@@ -6067,7 +6338,11 @@ export class ClaudeAcpAgent {
6067
6338
  })
6068
6339
  : undefined;
6069
6340
  let replayTurnId;
6070
- const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
6341
+ // AIR's draft replays each child under a replay id. A client on the RFD
6342
+ // form gets each child under the session id it had live (see
6343
+ // `RfdSubagentReplay`).
6344
+ const form = subagentForm(this.clientCapabilities);
6345
+ const nativeReplayEnabled = form === "air";
6071
6346
  const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
6072
6347
  const replayTerminalStates = new Map();
6073
6348
  const replayChildren = new Map();
@@ -6170,8 +6445,17 @@ export class ClaudeAcpAgent {
6170
6445
  // Only a history with an Agent or Task launch has subagent transcripts to read.
6171
6446
  const subagents = nativeReplayEnabled && replayChildren.size > 0
6172
6447
  ? await subagentHistory(sessionId)
6173
- : { ids: new Map() };
6448
+ : { agents: new Map() };
6174
6449
  const replayedSubagents = new Set();
6450
+ const rfdHistory = form === "rfd" &&
6451
+ messages.some((message) => subagentLaunchIds(message.message?.content)
6452
+ .length > 0)
6453
+ ? await subagentHistory(sessionId)
6454
+ : { agents: new Map() };
6455
+ const rfdReplay = form === "rfd"
6456
+ ? new RfdSubagentReplay(rfdHistory, (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)))
6457
+ : undefined;
6458
+ rfdReplay?.scan(messages, sessionId);
6175
6459
  // History persists no task_started frame, so a background task gets its
6176
6460
  // spawn from the notification of its stop, with the tool call that
6177
6461
  // started it. A task still running at the end of the history gets no
@@ -6190,6 +6474,8 @@ export class ClaudeAcpAgent {
6190
6474
  });
6191
6475
  // The replay counterpart of the live `task_notification` frame.
6192
6476
  const restoreTaskNotification = async (notification) => {
6477
+ if (rfdReplay?.taskNotification(notification.task_id, notification.status))
6478
+ return;
6193
6479
  const toolUseId = notification.tool_use_id;
6194
6480
  if (!toolUseId)
6195
6481
  return;
@@ -6216,7 +6502,8 @@ export class ClaudeAcpAgent {
6216
6502
  });
6217
6503
  await replayAsyncTasks.taskNotification(notification);
6218
6504
  };
6219
- const replayMessage = async (message) => {
6505
+ /** Replays `message`, from the transcript of `child` when it is set (the RFD form). */
6506
+ const replayMessage = async (message, child) => {
6220
6507
  // A long history is sent in order, but with event-loop turns in between,
6221
6508
  // so other sessions and incoming requests are not starved meanwhile.
6222
6509
  const pause = messageLoopYielder.maybeYield();
@@ -6225,6 +6512,10 @@ export class ClaudeAcpAgent {
6225
6512
  if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
6226
6513
  return;
6227
6514
  }
6515
+ // In the RFD form a child's history comes from its own transcript, so a
6516
+ // subagent message that the session transcript stores inline is skipped.
6517
+ if (rfdReplay && !child && parentToolUseIdOf(message))
6518
+ return;
6228
6519
  if (message.type === "user" &&
6229
6520
  message.parent_tool_use_id === null &&
6230
6521
  typeof message.uuid === "string" &&
@@ -6264,11 +6555,14 @@ export class ClaudeAcpAgent {
6264
6555
  // @ts-expect-error - untyped in SDK but we handle all of these
6265
6556
  let content = message.message.content;
6266
6557
  const parentToolUseId = parentToolUseIdOf(message);
6267
- const replayTargetSessionId = nativeReplayEnabled && parentToolUseId
6268
- ? await announceReplayChild(parentToolUseId)
6269
- : sessionId;
6558
+ const replayTargetSessionId = child
6559
+ ? child.sessionId
6560
+ : nativeReplayEnabled && parentToolUseId
6561
+ ? await announceReplayChild(parentToolUseId)
6562
+ : sessionId;
6270
6563
  if (message.type === "assistant" &&
6271
6564
  parentToolUseId &&
6565
+ !child &&
6272
6566
  !nativeReplayEnabled &&
6273
6567
  !forwardSubagentText) {
6274
6568
  content = stripSubagentTextAndThinking(content);
@@ -6282,6 +6576,9 @@ export class ClaudeAcpAgent {
6282
6576
  }
6283
6577
  // Live, the prompt loop skips this record and the SDK frame reports the stop.
6284
6578
  content = isTaskNotificationRecord(message) ? null : stripLocalCommandMetadata(content);
6579
+ if (content !== null && child && rfdReplay) {
6580
+ content = await rfdReplay.childContent(child, message, content);
6581
+ }
6285
6582
  if (content === null)
6286
6583
  return;
6287
6584
  }
@@ -6353,32 +6650,37 @@ export class ClaudeAcpAgent {
6353
6650
  if (nativeReplayEnabled && message.type === "assistant") {
6354
6651
  for (const toolUseId of subagentLaunchIds(content)) {
6355
6652
  await announceReplayChild(toolUseId);
6356
- const agentId = subagents.ids.get(toolUseId);
6653
+ const agentId = subagents.agents.get(toolUseId)?.agentId;
6357
6654
  if (!agentId || replayedSubagents.has(agentId))
6358
6655
  continue;
6359
6656
  replayedSubagents.add(agentId);
6360
- let childMessages = [];
6361
- try {
6362
- // With the project directory, the SDK skips its search of every project.
6363
- // A session opened from another directory falls back to that search.
6364
- if (subagents.dir) {
6365
- childMessages = await getSubagentMessages(sessionId, agentId, { dir: subagents.dir });
6366
- }
6367
- if (childMessages.length === 0) {
6368
- childMessages = await getSubagentMessages(sessionId, agentId);
6369
- }
6370
- }
6371
- catch (error) {
6372
- this.logger.error(`Failed to read the history of subagent ${agentId}:`, error);
6373
- }
6657
+ const childMessages = await readSubagentMessages(sessionId, agentId, subagents, this.logger);
6374
6658
  registerLaunches(childMessages);
6375
6659
  for (const childMessage of childMessages)
6376
6660
  await replayMessage(childMessage);
6377
6661
  }
6378
6662
  }
6663
+ // The RFD form announces a child right after its launch, then sends its
6664
+ // own transcript to it.
6665
+ if (rfdReplay && message.type === "assistant") {
6666
+ for (const toolUseId of subagentLaunchIds(content)) {
6667
+ const launched = rfdReplay.childOfLaunch(toolUseId);
6668
+ if (!launched || launched.announced)
6669
+ continue;
6670
+ await rfdReplay.announce(launched);
6671
+ const childMessages = await readSubagentMessages(sessionId, launched.sessionId, rfdHistory, this.logger);
6672
+ rfdReplay.scan(childMessages, launched.sessionId);
6673
+ for (const childMessage of childMessages)
6674
+ await replayMessage(childMessage, launched);
6675
+ await rfdReplay.childReplayed(launched, childMessages);
6676
+ }
6677
+ }
6379
6678
  };
6380
6679
  for (const message of messages)
6381
6680
  await replayMessage(message);
6681
+ if (rfdReplay && !pending?.stopped) {
6682
+ await rfdReplay.finish(clientSupportsNotices(this.clientCapabilities), sessionId);
6683
+ }
6382
6684
  if (nativeReplayEnabled && !pending?.stopped) {
6383
6685
  // Claude history persists sidechain messages and Agent/Task tool uses,
6384
6686
  // but not task_started/task_updated lifecycle frames. Recover terminal
@@ -6413,6 +6715,16 @@ export class ClaudeAcpAgent {
6413
6715
  const response = await this.client.writeTextFile(params);
6414
6716
  return response;
6415
6717
  }
6718
+ /** Runs `request`, a request that `requestSessionId` sends for the session
6719
+ * `ownerSessionId`. When that is a subagent's child session, the child
6720
+ * reports that it awaits the user while the request is open (the RFD form,
6721
+ * see `NativeSubagentRuntime.awaitingUser`). */
6722
+ withChildAwaitingUser(ownerSessionId, requestSessionId, request) {
6723
+ const runtime = this.sessions[ownerSessionId]?.nativeSubagentRuntime;
6724
+ return runtime && requestSessionId !== ownerSessionId
6725
+ ? runtime.awaitingUser(requestSessionId, request)
6726
+ : request();
6727
+ }
6416
6728
  /** Mark a client request of the subagent `agentID` (absent for the main
6417
6729
  * conversation) as awaiting user input for exactly the lifetime of its
6418
6730
  * promise (see `Session.openUserInputs`). Steering consults the open
@@ -6507,6 +6819,13 @@ export class ClaudeAcpAgent {
6507
6819
  return;
6508
6820
  this.startForeground(sessionId, AUTONOMOUS_CYCLE);
6509
6821
  }
6822
+ /** Whether the client has been told that the session's work ended: on v2 by
6823
+ * an idle state, so no foreground work is open, including a cycle that
6824
+ * Claude Code started on its own; on v1 by the response to the turn's
6825
+ * prompt, so no turn is active. */
6826
+ workEndReported(sessionId, session) {
6827
+ return this.foregroundEvents ? !this.foregroundWork.has(sessionId) : !session.activeTurn;
6828
+ }
6510
6829
  /** End the session's model cycle that Claude Code started on its own, if one
6511
6830
  * runs. */
6512
6831
  endAutonomousCycle(sessionId, end) {
@@ -6536,7 +6855,7 @@ export class ClaudeAcpAgent {
6536
6855
  // The request goes to a subagent's child session for a tool call of that
6537
6856
  // subagent, but it blocks the turn of the owner session.
6538
6857
  try {
6539
- return await this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID);
6858
+ return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, () => this.withPendingUserInput(ownerSessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal), agentID));
6540
6859
  }
6541
6860
  catch (error) {
6542
6861
  if (signal.aborted) {
@@ -6576,6 +6895,8 @@ export class ClaudeAcpAgent {
6576
6895
  return;
6577
6896
  }
6578
6897
  session.emittedToolCalls.add(toolCallId);
6898
+ if (parentToolUseId)
6899
+ recordToolCallParent(session, toolCallId, parentToolUseId);
6579
6900
  // SandboxNetworkAccess is a permission-only callback, not an executing
6580
6901
  // tool: the SDK does not send a tool_result for its synthetic id.
6581
6902
  if (!parentToolUseId &&
@@ -6600,6 +6921,7 @@ export class ClaudeAcpAgent {
6600
6921
  session.emittedToolCalls.delete(toolCallId);
6601
6922
  session.eagerToolCallSessions?.delete(toolCallId);
6602
6923
  session.toolCallFields?.delete(toolCallId);
6924
+ session.toolCallParents?.delete(toolCallId);
6603
6925
  throw error;
6604
6926
  }
6605
6927
  }
@@ -6874,7 +7196,7 @@ export class ClaudeAcpAgent {
6874
7196
  const createRequest = askUserQuestionsToCreateRequest(questions, requestSessionId, toolUseID, this.toolCallCapabilities.air.client);
6875
7197
  let response;
6876
7198
  try {
6877
- response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal), agentID);
7199
+ response = await this.withChildAwaitingUser(sessionId, requestSessionId, () => this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal), agentID));
6878
7200
  }
6879
7201
  catch (error) {
6880
7202
  // A cancellation we requested (signal aborted) settles as an aborted tool
@@ -7745,6 +8067,8 @@ export class ClaudeAcpAgent {
7745
8067
  prompt: input,
7746
8068
  options,
7747
8069
  });
8070
+ this.useAuthStatusSettings(settingsManager, configuredSettingsObject, userProvidedOptions?.env);
8071
+ const authSettingsEpoch = this.authEpoch;
7748
8072
  timing.phase("prepare-query");
7749
8073
  // `query()` spawns the CLI at once. Any throw between here and the
7750
8074
  // registration in `this.sessions` would leave that child process running
@@ -7767,7 +8091,9 @@ export class ClaudeAcpAgent {
7767
8091
  // Publish the identity BEFORE the guard can refuse this session. A
7768
8092
  // refusal is exactly when the client most needs to know which account it
7769
8093
  // was refused for.
7770
- this.publishSessionAccountIdentity(initializationResult.account);
8094
+ if (authSettingsEpoch === this.authEpoch) {
8095
+ this.publishSessionAccountIdentity(initializationResult.account, settingsManager.getApiKeyHelper(configuredSettingsObject));
8096
+ }
7771
8097
  // Shared with the per-turn guard, so "warn once" spans the whole session.
7772
8098
  const claudeSubscriptionGuard = {};
7773
8099
  if (this.claudeSubscriptionGuardActive()) {
@@ -7969,7 +8295,7 @@ export class ClaudeAcpAgent {
7969
8295
  messageIdToUuid: creationOpts.replayState?.messageIdToUuid ?? new Map(),
7970
8296
  sessionFailureState: creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(),
7971
8297
  claudeSubscriptionGuard,
7972
- accountKind: fromAccountInfo(initializationResult.account)?.kind,
8298
+ accountKind: fromAccountInfo(initializationResult.account, settingsManager.getApiKeyHelper(configuredSettingsObject))?.kind,
7973
8299
  fileChangeReporter,
7974
8300
  };
7975
8301
  timing.phase("register");
@@ -8121,27 +8447,34 @@ function cancelledOutcome(session, turn) {
8121
8447
  }
8122
8448
  /** Whether the model request that `status` (a `status: requesting` frame)
8123
8449
  * starts may belong to the work a cancel stopped. It may while:
8124
- * - a turn is unsettled: the stopped turn, whose result is still to come, or
8125
- * a queued prompt, which the cancel abandons (one that arrived while the
8126
- * cancel awaited is recorded only when the cancel sweeps the queue) or
8127
- * whose echo ends the cancel right after its request anyway;
8450
+ * - a turn in {@link Session.cancelledPrompts} is unsettled: the stopped
8451
+ * turn, whose result is still to come;
8128
8452
  * - the orphan accounting still expects the result of an abandoned prompt
8129
8453
  * that Claude Code may still run, which it can fold into its own
8130
8454
  * follow-up after that follow-up's first request;
8131
8455
  * - the request serves one of {@link Session.cancelledPrompts}, as an
8132
8456
  * abandoned prompt's request does even when the interrupt receipt left it
8133
- * out of the orphan accounting. */
8457
+ * out of the orphan accounting;
8458
+ * - the request serves an unsettled prompt. One that arrived while the cancel
8459
+ * awaited is recorded only when the cancel sweeps the queue, and its echo
8460
+ * ends the cancel right after its request when the cancel does not.
8461
+ * A prompt that is merely queued does not make another request belong to
8462
+ * the stopped work: a follow-up that Claude Code runs before a prompt sent
8463
+ * after the cancel names no prompt. */
8134
8464
  function servesCancelledWork(session, status) {
8135
8465
  const cancelled = session.cancelledPrompts;
8136
8466
  if (!cancelled)
8137
8467
  return false;
8138
- if ((session.turnQueue ?? []).some((turn) => !turn.settled))
8468
+ const unsettled = (session.turnQueue ?? []).filter((turn) => !turn.settled);
8469
+ if (unsettled.some((turn) => cancelled.has(turn.promptUuid)))
8139
8470
  return true;
8140
8471
  if ((session.pendingOrphanResults ?? 0) > 0 || (session.orphanCommands?.size ?? 0) > 0) {
8141
8472
  return true;
8142
8473
  }
8143
8474
  const served = status.user_message_uuids;
8144
- return (Array.isArray(served) && served.some((uuid) => typeof uuid === "string" && cancelled.has(uuid)));
8475
+ return (Array.isArray(served) &&
8476
+ served.some((uuid) => typeof uuid === "string" &&
8477
+ (cancelled.has(uuid) || unsettled.some((turn) => turn.promptUuid === uuid))));
8145
8478
  }
8146
8479
  /** `_meta.quota` for a prompt response: what the turn spent, shaped like
8147
8480
  * codex-acp's (snake_case container keys, camelCase counters) so a client
@@ -8176,15 +8509,20 @@ function quotaMeta(usage, modelUsage) {
8176
8509
  * foreground of the main conversation, since the tool call that spawned it
8177
8510
  * waits on it, and so on up a chain of such subagents. A request of a
8178
8511
  * subagent in the background, or nested inside one, does not: the main
8179
- * conversation goes on without it. A subagent without a record counts, as
8180
- * its request can come before its `task_started`, which checks again. */
8512
+ * conversation goes on without it. Nor does one of an agent without a
8513
+ * record: a teammate, a settled subagent, or one that Claude Code left out
8514
+ * of its background tasks and the consumer pruned, which only happens to a
8515
+ * subagent nested in a background one. A subagent that the main
8516
+ * conversation waits on has a record by the time it asks, as its
8517
+ * `task_started` comes first; when the consumer reads the request first, the
8518
+ * `task_started` checks again. */
8181
8519
  function blocksForeground(session, agentID) {
8182
8520
  const seen = new Set();
8183
8521
  for (let id = agentID; id !== undefined && !seen.has(id);) {
8184
8522
  seen.add(id);
8185
8523
  const task = session.liveBackgroundTasks.get(id);
8186
8524
  if (!task)
8187
- return true;
8525
+ return false;
8188
8526
  if (task.backgrounded)
8189
8527
  return false;
8190
8528
  id = task.parentTaskId;
@@ -8862,6 +9200,18 @@ function isTaskTool(toolName) {
8862
9200
  function shouldEmitToolCall(toolName) {
8863
9201
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
8864
9202
  }
9203
+ const MAX_TOOL_CALL_PARENTS = 1000;
9204
+ /** Records the Agent/Task call that started the subagent making `toolCallId`. */
9205
+ function recordToolCallParent(session, toolCallId, parentToolUseId) {
9206
+ const parents = (session.toolCallParents ??= new Map());
9207
+ parents.delete(toolCallId);
9208
+ parents.set(toolCallId, parentToolUseId);
9209
+ if (parents.size > MAX_TOOL_CALL_PARENTS) {
9210
+ const oldest = parents.keys().next().value;
9211
+ if (oldest !== undefined)
9212
+ parents.delete(oldest);
9213
+ }
9214
+ }
8865
9215
  const MAX_DISPATCHED_TOOL_CALLS = 1000;
8866
9216
  /** Records a tool call that Claude Code sent to the tool runner. */
8867
9217
  function recordDispatchedToolCall(session, toolCallId) {
@@ -8974,6 +9324,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
8974
9324
  let update = null;
8975
9325
  // The tool call that this chunk finishes, if it is a tool result.
8976
9326
  let finishedToolCallId;
9327
+ // The plan that this chunk approves, if it is an ExitPlanMode result.
9328
+ let approvedPlanUpdate;
8977
9329
  switch (chunk.type) {
8978
9330
  case "text":
8979
9331
  case "text_delta": {
@@ -9131,7 +9483,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9131
9483
  update: {
9132
9484
  toolCallId: chunk.tool_use_id,
9133
9485
  sessionUpdate: "tool_call_update",
9134
- status: renderer.resultStatus("is_error" in chunk && chunk.is_error === true, nonExecution),
9486
+ status: renderer.resultStatus("is_error" in chunk && chunk.is_error === true, nonExecution, { cancelled: options?.cancelled }),
9135
9487
  rawOutput: chunk.content,
9136
9488
  },
9137
9489
  });
@@ -9160,7 +9512,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9160
9512
  },
9161
9513
  toolCallId: chunk.tool_use_id,
9162
9514
  sessionUpdate: "tool_call_update",
9163
- status: renderer.resultStatus("is_error" in chunk && chunk.is_error === true, nonExecution),
9515
+ status: renderer.resultStatus("is_error" in chunk && chunk.is_error === true, nonExecution, { cancelled: options?.cancelled }),
9164
9516
  rawOutput: chunk.content,
9165
9517
  },
9166
9518
  });
@@ -9212,6 +9564,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9212
9564
  .result(toolUse, chunk, {
9213
9565
  structured: toolUseResult,
9214
9566
  nonExecution,
9567
+ cancelled: options?.cancelled,
9215
9568
  })
9216
9569
  .reverse();
9217
9570
  for (const outputUpdate of rest.reverse()) {
@@ -9220,6 +9573,19 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9220
9573
  }
9221
9574
  output.push({ sessionId, update: outputUpdate });
9222
9575
  }
9576
+ // An approved plan of the main conversation becomes the session's
9577
+ // markdown plan, after the tool call's result. An approval that
9578
+ // clears the context ends the tool with an error result instead, and
9579
+ // the restart into the fresh context publishes that plan (see
9580
+ // ExitPlanCoordinator.restart).
9581
+ if (toolUse.name === "ExitPlanMode" &&
9582
+ options?.clientCapabilities?.plan &&
9583
+ !options.parentToolUseId &&
9584
+ !("is_error" in chunk && chunk.is_error)) {
9585
+ const plan = approvedPlan(toolUse.input, toolUseResult);
9586
+ if (plan)
9587
+ approvedPlanUpdate = planModeUpdate(plan);
9588
+ }
9223
9589
  update = finalUpdate;
9224
9590
  }
9225
9591
  // The tool_use is fully resolved now — drop it so a long session doesn't
@@ -9259,6 +9625,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9259
9625
  output.push({ sessionId, update });
9260
9626
  }
9261
9627
  }
9628
+ if (approvedPlanUpdate)
9629
+ output.push({ sessionId, update: approvedPlanUpdate });
9262
9630
  if (finishedToolCallId !== undefined) {
9263
9631
  // The PostToolUse hook can still send the final diff, so the fields
9264
9632
  // stay tracked until its callback leaves the registry.