@agentclientprotocol/claude-agent-acp 0.89.2-preview.9 → 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 +50 -1
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +493 -135
  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,6 +3060,29 @@ export class ClaudeAcpAgent {
3011
3060
  }
3012
3061
  this.syncAwaitingUser(params.sessionId, session);
3013
3062
  };
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
+ };
3014
3086
  /** Settles a task. Its request may still be open, and an agent without a
3015
3087
  * record no longer blocks the foreground work, so that is checked again. */
3016
3088
  const settleLiveTask = (taskId) => {
@@ -3087,6 +3159,92 @@ export class ClaudeAcpAgent {
3087
3159
  await settleActive(outcome);
3088
3160
  }
3089
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
+ };
3090
3248
  /** At the actual turn boundary, preview its checkpoint, settle the active
3091
3249
  * turn exactly once, disarm the force-cancel backstop, and drop it from
3092
3250
  * the queue. Cancellation and provider failures skip checkpoint I/O. */
@@ -3112,16 +3270,7 @@ export class ClaudeAcpAgent {
3112
3270
  * ended meanwhile.
3113
3271
  */
3114
3272
  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
- });
3273
+ await closeToolCall(toolCallId, renderer.unfinished({ toolCallId, ran }));
3125
3274
  // Cancellation can arrive while we await the client update.
3126
3275
  if (turn.settled || session.activeTurn !== turn)
3127
3276
  return false;
@@ -3154,6 +3303,15 @@ export class ClaudeAcpAgent {
3154
3303
  return;
3155
3304
  }
3156
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
+ }
3157
3315
  turn.settling = true;
3158
3316
  turn.settlingOutcome = result;
3159
3317
  if (reportReason === "notReported") {
@@ -4064,9 +4222,14 @@ export class ClaudeAcpAgent {
4064
4222
  this.logger.log(`Ignoring task_notification for task ${message.task_id} from earlier run ${message.run_id}`);
4065
4223
  break;
4066
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;
4067
4230
  // The task settled — no further tool calls can originate
4068
4231
  // from it, so its registry entry can be dropped.
4069
- 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 });
4070
4233
  await asyncTasks.taskNotification({
4071
4234
  task_id: message.task_id,
4072
4235
  status: message.status,
@@ -4078,7 +4241,9 @@ export class ClaudeAcpAgent {
4078
4241
  subagents.discardPending(message.tool_use_id);
4079
4242
  // The subagent streams no more text under this parent.
4080
4243
  streamedBlocksByParent.delete(message.tool_use_id);
4244
+ droppedStreams.delete(message.tool_use_id);
4081
4245
  }
4246
+ await endBackgroundTerminal(message.task_id);
4082
4247
  settleLiveTask(message.task_id);
4083
4248
  break;
4084
4249
  case "task_updated":
@@ -4103,10 +4268,13 @@ export class ClaudeAcpAgent {
4103
4268
  if (message.patch.status === "completed" ||
4104
4269
  message.patch.status === "failed" ||
4105
4270
  message.patch.status === "killed") {
4106
- 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 });
4107
4272
  const parentToolUseId = session.liveBackgroundTasks.get(message.task_id)?.parentToolUseId;
4108
- if (parentToolUseId)
4273
+ if (parentToolUseId) {
4109
4274
  streamedBlocksByParent.delete(parentToolUseId);
4275
+ droppedStreams.delete(parentToolUseId);
4276
+ }
4277
+ await endBackgroundTerminal(message.task_id);
4110
4278
  settleLiveTask(message.task_id);
4111
4279
  }
4112
4280
  else if (message.patch.status === "running" ||
@@ -4930,43 +5098,14 @@ export class ClaudeAcpAgent {
4930
5098
  // reset, and the synthetic-auth/system/local-command paths
4931
5099
  // `break` early too. Block indices restart at 0 each message, so
4932
5100
  // 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.
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.
4938
5107
  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
- }
5108
+ droppedStreams.delete(message.parent_tool_use_id ?? "");
4970
5109
  }
4971
5110
  if (message.parent_tool_use_id === null &&
4972
5111
  (message.event.type === "message_start" || message.event.type === "message_delta")) {
@@ -5020,7 +5159,71 @@ export class ClaudeAcpAgent {
5020
5159
  });
5021
5160
  }
5022
5161
  }
5023
- 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, {
5024
5227
  clientCapabilities: this.clientCapabilities,
5025
5228
  toolCallCapabilities: this.toolCallCapabilities,
5026
5229
  cwd: session.cwd,
@@ -5153,6 +5356,13 @@ export class ClaudeAcpAgent {
5153
5356
  }
5154
5357
  }
5155
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
+ }
5156
5366
  break;
5157
5367
  }
5158
5368
  // Synthetic assistant frames carry the CLI's local-command output.
@@ -5284,6 +5494,7 @@ export class ClaudeAcpAgent {
5284
5494
  const streamed = streamedBlocksOf(message.parent_tool_use_id);
5285
5495
  content = unstreamedRemainder(message.message.content, streamed);
5286
5496
  streamed.length = 0;
5497
+ droppedStreams.delete(message.parent_tool_use_id ?? "");
5287
5498
  }
5288
5499
  else if (message.type === "assistant") {
5289
5500
  // Nested text/thinking stays internal for a client that does
@@ -5296,19 +5507,41 @@ export class ClaudeAcpAgent {
5296
5507
  }
5297
5508
  const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
5298
5509
  let backgroundedToolCalls = new Set();
5510
+ let runningTerminal;
5299
5511
  if (message.type === "user") {
5300
5512
  rememberResolvedToolNames(session, content);
5301
5513
  const backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
5302
5514
  if (backgroundBashTask)
5303
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
+ }
5304
5532
  await asyncTasks.toolResults(content);
5305
5533
  backgroundedToolCalls = backgroundedBashToolCallIds(content, session.toolUseCache, asyncTasks, backgroundBashTask);
5306
5534
  const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
5307
5535
  if (resumedAgentId) {
5308
- resumeLiveTask(resumedAgentId);
5309
- await subagents.taskResumed(resumedAgentId, sendUpdate, sendMessageResumePrompt(session.toolUseCache, resumedAgentId, Array.isArray(content)
5536
+ const resume = sendMessageResume(session.toolUseCache, resumedAgentId, Array.isArray(content)
5310
5537
  ? content.flatMap((block) => block.type === "tool_result" ? [block.tool_use_id] : [])
5311
- : []));
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
+ }
5312
5545
  }
5313
5546
  }
5314
5547
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
@@ -5331,7 +5564,7 @@ export class ClaudeAcpAgent {
5331
5564
  // filtered out of `content` above; blocks that do pass through
5332
5565
  // (e.g. a subagent image) carry the stamped parentToolUseId
5333
5566
  // meta and are excluded there.
5334
- await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled));
5567
+ await sendUpdate(withRunningTerminal(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundedToolCalls, asyncTasks.enabled), runningTerminal));
5335
5568
  }
5336
5569
  break;
5337
5570
  }
@@ -5355,10 +5588,14 @@ export class ClaudeAcpAgent {
5355
5588
  if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
5356
5589
  break;
5357
5590
  }
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
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
5362
5599
  : undefined;
5363
5600
  // `tool_name` names the tool that reports the beat. When the beat
5364
5601
  // falls back to the parent call, AIR gets the name of that call,
@@ -5581,17 +5818,29 @@ export class ClaudeAcpAgent {
5581
5818
  // their compaction is running. Close it before ANY cancelled prompt
5582
5819
  // resolves, not just the active-turn idle/backstop paths.
5583
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.
5584
5831
  try {
5585
- await session.nativeSubagentRuntime?.finishAll("cancelled", session.nativeSubagentDeliver ??
5832
+ await session.nativeSubagentRuntime?.parentCancelled(session.nativeSubagentDeliver ??
5586
5833
  (async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification))));
5587
5834
  }
5588
5835
  catch (error) {
5589
5836
  this.logger.error(`Session ${params.sessionId}: failed to publish cancelled subagent state`, error);
5590
5837
  }
5591
5838
  finally {
5592
- session.eagerToolCallSessions?.clear();
5593
- session.toolCallFields?.clear();
5594
- clearHookCallbacks(params.sessionId);
5839
+ if (session.nativeSubagentRuntime?.form !== "rfd") {
5840
+ session.eagerToolCallSessions?.clear();
5841
+ session.toolCallFields?.clear();
5842
+ clearHookCallbacks(params.sessionId);
5843
+ }
5595
5844
  }
5596
5845
  // The user messages this cancel abandons while Claude Code may still have
5597
5846
  // them queued: they are dropped from its queue before the interrupt.
@@ -5705,7 +5954,8 @@ export class ClaudeAcpAgent {
5705
5954
  // permission request) is left to that cycle's interrupted result, like any
5706
5955
  // live turn, or to the idle when no result comes. The captured outcome
5707
5956
  // preserves usage and metadata (issue #844), while reporter state makes a
5708
- // 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.
5709
5959
  // Since CLI 2.1.270, Claude Code reports `running` for as long as
5710
5960
  // background agents live, so the session state cannot tell a live
5711
5961
  // followup from a hold. With request markers, a followup is live when one
@@ -5718,7 +5968,10 @@ export class ClaudeAcpAgent {
5718
5968
  if (active &&
5719
5969
  (active.settling || (isHeldOpen(active) && !followupLive)) &&
5720
5970
  (active.deferredSettle ?? active.settlingOutcome)) {
5971
+ const closingToolCalls = session.closeCancelledToolCalls?.(active);
5721
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.
5722
5975
  active.settled = true;
5723
5976
  // Mirror settleActive's invariants (it is consumer-scoped and
5724
5977
  // unreachable from here): disarm the backstop — none should be
@@ -5750,7 +6003,15 @@ export class ClaudeAcpAgent {
5750
6003
  if (session.lastSessionState !== "idle") {
5751
6004
  session.owedTrailingIdles++;
5752
6005
  }
5753
- 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
+ }
5754
6015
  }
5755
6016
  }
5756
6017
  // A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
@@ -6053,7 +6314,12 @@ export class ClaudeAcpAgent {
6053
6314
  // A replay rebuilds the client view, so its plan goes out again.
6054
6315
  if (session?.taskState)
6055
6316
  forgetPublishedTaskPlan(session.taskState);
6056
- 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));
6057
6323
  const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
6058
6324
  const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
6059
6325
  const sessionFailures = session && supportsTypedFailures
@@ -6072,7 +6338,11 @@ export class ClaudeAcpAgent {
6072
6338
  })
6073
6339
  : undefined;
6074
6340
  let replayTurnId;
6075
- 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";
6076
6346
  const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
6077
6347
  const replayTerminalStates = new Map();
6078
6348
  const replayChildren = new Map();
@@ -6175,8 +6445,17 @@ export class ClaudeAcpAgent {
6175
6445
  // Only a history with an Agent or Task launch has subagent transcripts to read.
6176
6446
  const subagents = nativeReplayEnabled && replayChildren.size > 0
6177
6447
  ? await subagentHistory(sessionId)
6178
- : { ids: new Map() };
6448
+ : { agents: new Map() };
6179
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);
6180
6459
  // History persists no task_started frame, so a background task gets its
6181
6460
  // spawn from the notification of its stop, with the tool call that
6182
6461
  // started it. A task still running at the end of the history gets no
@@ -6195,6 +6474,8 @@ export class ClaudeAcpAgent {
6195
6474
  });
6196
6475
  // The replay counterpart of the live `task_notification` frame.
6197
6476
  const restoreTaskNotification = async (notification) => {
6477
+ if (rfdReplay?.taskNotification(notification.task_id, notification.status))
6478
+ return;
6198
6479
  const toolUseId = notification.tool_use_id;
6199
6480
  if (!toolUseId)
6200
6481
  return;
@@ -6221,7 +6502,8 @@ export class ClaudeAcpAgent {
6221
6502
  });
6222
6503
  await replayAsyncTasks.taskNotification(notification);
6223
6504
  };
6224
- 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) => {
6225
6507
  // A long history is sent in order, but with event-loop turns in between,
6226
6508
  // so other sessions and incoming requests are not starved meanwhile.
6227
6509
  const pause = messageLoopYielder.maybeYield();
@@ -6230,6 +6512,10 @@ export class ClaudeAcpAgent {
6230
6512
  if (pending?.stopped || isReplayHiddenMetaMessage(message)) {
6231
6513
  return;
6232
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;
6233
6519
  if (message.type === "user" &&
6234
6520
  message.parent_tool_use_id === null &&
6235
6521
  typeof message.uuid === "string" &&
@@ -6269,11 +6555,14 @@ export class ClaudeAcpAgent {
6269
6555
  // @ts-expect-error - untyped in SDK but we handle all of these
6270
6556
  let content = message.message.content;
6271
6557
  const parentToolUseId = parentToolUseIdOf(message);
6272
- const replayTargetSessionId = nativeReplayEnabled && parentToolUseId
6273
- ? await announceReplayChild(parentToolUseId)
6274
- : sessionId;
6558
+ const replayTargetSessionId = child
6559
+ ? child.sessionId
6560
+ : nativeReplayEnabled && parentToolUseId
6561
+ ? await announceReplayChild(parentToolUseId)
6562
+ : sessionId;
6275
6563
  if (message.type === "assistant" &&
6276
6564
  parentToolUseId &&
6565
+ !child &&
6277
6566
  !nativeReplayEnabled &&
6278
6567
  !forwardSubagentText) {
6279
6568
  content = stripSubagentTextAndThinking(content);
@@ -6287,6 +6576,9 @@ export class ClaudeAcpAgent {
6287
6576
  }
6288
6577
  // Live, the prompt loop skips this record and the SDK frame reports the stop.
6289
6578
  content = isTaskNotificationRecord(message) ? null : stripLocalCommandMetadata(content);
6579
+ if (content !== null && child && rfdReplay) {
6580
+ content = await rfdReplay.childContent(child, message, content);
6581
+ }
6290
6582
  if (content === null)
6291
6583
  return;
6292
6584
  }
@@ -6358,32 +6650,37 @@ export class ClaudeAcpAgent {
6358
6650
  if (nativeReplayEnabled && message.type === "assistant") {
6359
6651
  for (const toolUseId of subagentLaunchIds(content)) {
6360
6652
  await announceReplayChild(toolUseId);
6361
- const agentId = subagents.ids.get(toolUseId);
6653
+ const agentId = subagents.agents.get(toolUseId)?.agentId;
6362
6654
  if (!agentId || replayedSubagents.has(agentId))
6363
6655
  continue;
6364
6656
  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
- }
6657
+ const childMessages = await readSubagentMessages(sessionId, agentId, subagents, this.logger);
6379
6658
  registerLaunches(childMessages);
6380
6659
  for (const childMessage of childMessages)
6381
6660
  await replayMessage(childMessage);
6382
6661
  }
6383
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
+ }
6384
6678
  };
6385
6679
  for (const message of messages)
6386
6680
  await replayMessage(message);
6681
+ if (rfdReplay && !pending?.stopped) {
6682
+ await rfdReplay.finish(clientSupportsNotices(this.clientCapabilities), sessionId);
6683
+ }
6387
6684
  if (nativeReplayEnabled && !pending?.stopped) {
6388
6685
  // Claude history persists sidechain messages and Agent/Task tool uses,
6389
6686
  // but not task_started/task_updated lifecycle frames. Recover terminal
@@ -6418,6 +6715,16 @@ export class ClaudeAcpAgent {
6418
6715
  const response = await this.client.writeTextFile(params);
6419
6716
  return response;
6420
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
+ }
6421
6728
  /** Mark a client request of the subagent `agentID` (absent for the main
6422
6729
  * conversation) as awaiting user input for exactly the lifetime of its
6423
6730
  * promise (see `Session.openUserInputs`). Steering consults the open
@@ -6512,6 +6819,13 @@ export class ClaudeAcpAgent {
6512
6819
  return;
6513
6820
  this.startForeground(sessionId, AUTONOMOUS_CYCLE);
6514
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
+ }
6515
6829
  /** End the session's model cycle that Claude Code started on its own, if one
6516
6830
  * runs. */
6517
6831
  endAutonomousCycle(sessionId, end) {
@@ -6541,7 +6855,7 @@ export class ClaudeAcpAgent {
6541
6855
  // The request goes to a subagent's child session for a tool call of that
6542
6856
  // subagent, but it blocks the turn of the owner session.
6543
6857
  try {
6544
- 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));
6545
6859
  }
6546
6860
  catch (error) {
6547
6861
  if (signal.aborted) {
@@ -6581,6 +6895,8 @@ export class ClaudeAcpAgent {
6581
6895
  return;
6582
6896
  }
6583
6897
  session.emittedToolCalls.add(toolCallId);
6898
+ if (parentToolUseId)
6899
+ recordToolCallParent(session, toolCallId, parentToolUseId);
6584
6900
  // SandboxNetworkAccess is a permission-only callback, not an executing
6585
6901
  // tool: the SDK does not send a tool_result for its synthetic id.
6586
6902
  if (!parentToolUseId &&
@@ -6605,6 +6921,7 @@ export class ClaudeAcpAgent {
6605
6921
  session.emittedToolCalls.delete(toolCallId);
6606
6922
  session.eagerToolCallSessions?.delete(toolCallId);
6607
6923
  session.toolCallFields?.delete(toolCallId);
6924
+ session.toolCallParents?.delete(toolCallId);
6608
6925
  throw error;
6609
6926
  }
6610
6927
  }
@@ -6879,7 +7196,7 @@ export class ClaudeAcpAgent {
6879
7196
  const createRequest = askUserQuestionsToCreateRequest(questions, requestSessionId, toolUseID, this.toolCallCapabilities.air.client);
6880
7197
  let response;
6881
7198
  try {
6882
- 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));
6883
7200
  }
6884
7201
  catch (error) {
6885
7202
  // A cancellation we requested (signal aborted) settles as an aborted tool
@@ -7750,6 +8067,8 @@ export class ClaudeAcpAgent {
7750
8067
  prompt: input,
7751
8068
  options,
7752
8069
  });
8070
+ this.useAuthStatusSettings(settingsManager, configuredSettingsObject, userProvidedOptions?.env);
8071
+ const authSettingsEpoch = this.authEpoch;
7753
8072
  timing.phase("prepare-query");
7754
8073
  // `query()` spawns the CLI at once. Any throw between here and the
7755
8074
  // registration in `this.sessions` would leave that child process running
@@ -7772,7 +8091,9 @@ export class ClaudeAcpAgent {
7772
8091
  // Publish the identity BEFORE the guard can refuse this session. A
7773
8092
  // refusal is exactly when the client most needs to know which account it
7774
8093
  // was refused for.
7775
- this.publishSessionAccountIdentity(initializationResult.account);
8094
+ if (authSettingsEpoch === this.authEpoch) {
8095
+ this.publishSessionAccountIdentity(initializationResult.account, settingsManager.getApiKeyHelper(configuredSettingsObject));
8096
+ }
7776
8097
  // Shared with the per-turn guard, so "warn once" spans the whole session.
7777
8098
  const claudeSubscriptionGuard = {};
7778
8099
  if (this.claudeSubscriptionGuardActive()) {
@@ -7974,7 +8295,7 @@ export class ClaudeAcpAgent {
7974
8295
  messageIdToUuid: creationOpts.replayState?.messageIdToUuid ?? new Map(),
7975
8296
  sessionFailureState: creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(),
7976
8297
  claudeSubscriptionGuard,
7977
- accountKind: fromAccountInfo(initializationResult.account)?.kind,
8298
+ accountKind: fromAccountInfo(initializationResult.account, settingsManager.getApiKeyHelper(configuredSettingsObject))?.kind,
7978
8299
  fileChangeReporter,
7979
8300
  };
7980
8301
  timing.phase("register");
@@ -8126,27 +8447,34 @@ function cancelledOutcome(session, turn) {
8126
8447
  }
8127
8448
  /** Whether the model request that `status` (a `status: requesting` frame)
8128
8449
  * 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;
8450
+ * - a turn in {@link Session.cancelledPrompts} is unsettled: the stopped
8451
+ * turn, whose result is still to come;
8133
8452
  * - the orphan accounting still expects the result of an abandoned prompt
8134
8453
  * that Claude Code may still run, which it can fold into its own
8135
8454
  * follow-up after that follow-up's first request;
8136
8455
  * - the request serves one of {@link Session.cancelledPrompts}, as an
8137
8456
  * abandoned prompt's request does even when the interrupt receipt left it
8138
- * 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. */
8139
8464
  function servesCancelledWork(session, status) {
8140
8465
  const cancelled = session.cancelledPrompts;
8141
8466
  if (!cancelled)
8142
8467
  return false;
8143
- 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)))
8144
8470
  return true;
8145
8471
  if ((session.pendingOrphanResults ?? 0) > 0 || (session.orphanCommands?.size ?? 0) > 0) {
8146
8472
  return true;
8147
8473
  }
8148
8474
  const served = status.user_message_uuids;
8149
- 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))));
8150
8478
  }
8151
8479
  /** `_meta.quota` for a prompt response: what the turn spent, shaped like
8152
8480
  * codex-acp's (snake_case container keys, camelCase counters) so a client
@@ -8872,6 +9200,18 @@ function isTaskTool(toolName) {
8872
9200
  function shouldEmitToolCall(toolName) {
8873
9201
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
8874
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
+ }
8875
9215
  const MAX_DISPATCHED_TOOL_CALLS = 1000;
8876
9216
  /** Records a tool call that Claude Code sent to the tool runner. */
8877
9217
  function recordDispatchedToolCall(session, toolCallId) {
@@ -8984,6 +9324,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
8984
9324
  let update = null;
8985
9325
  // The tool call that this chunk finishes, if it is a tool result.
8986
9326
  let finishedToolCallId;
9327
+ // The plan that this chunk approves, if it is an ExitPlanMode result.
9328
+ let approvedPlanUpdate;
8987
9329
  switch (chunk.type) {
8988
9330
  case "text":
8989
9331
  case "text_delta": {
@@ -9141,7 +9483,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9141
9483
  update: {
9142
9484
  toolCallId: chunk.tool_use_id,
9143
9485
  sessionUpdate: "tool_call_update",
9144
- 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 }),
9145
9487
  rawOutput: chunk.content,
9146
9488
  },
9147
9489
  });
@@ -9170,7 +9512,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9170
9512
  },
9171
9513
  toolCallId: chunk.tool_use_id,
9172
9514
  sessionUpdate: "tool_call_update",
9173
- 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 }),
9174
9516
  rawOutput: chunk.content,
9175
9517
  },
9176
9518
  });
@@ -9222,6 +9564,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9222
9564
  .result(toolUse, chunk, {
9223
9565
  structured: toolUseResult,
9224
9566
  nonExecution,
9567
+ cancelled: options?.cancelled,
9225
9568
  })
9226
9569
  .reverse();
9227
9570
  for (const outputUpdate of rest.reverse()) {
@@ -9230,6 +9573,19 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9230
9573
  }
9231
9574
  output.push({ sessionId, update: outputUpdate });
9232
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
+ }
9233
9589
  update = finalUpdate;
9234
9590
  }
9235
9591
  // The tool_use is fully resolved now — drop it so a long session doesn't
@@ -9269,6 +9625,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
9269
9625
  output.push({ sessionId, update });
9270
9626
  }
9271
9627
  }
9628
+ if (approvedPlanUpdate)
9629
+ output.push({ sessionId, update: approvedPlanUpdate });
9272
9630
  if (finishedToolCallId !== undefined) {
9273
9631
  // The PostToolUse hook can still send the final diff, so the fields
9274
9632
  // stay tracked until its callback leaves the registry.