@agentclientprotocol/claude-agent-acp 0.81.3-preview.1 → 0.82.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 (95) hide show
  1. package/README.md +8 -5
  2. package/dist/acp-agent.d.ts +31 -37
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +546 -534
  5. package/dist/acp-subagents.d.ts +6 -0
  6. package/dist/acp-subagents.d.ts.map +1 -1
  7. package/dist/air-extension.d.ts +31 -1
  8. package/dist/air-extension.d.ts.map +1 -1
  9. package/dist/air-extension.js +35 -1
  10. package/dist/async-tasks.d.ts +39 -2
  11. package/dist/async-tasks.d.ts.map +1 -1
  12. package/dist/async-tasks.js +147 -44
  13. package/dist/context-compaction-meta.d.ts +6 -5
  14. package/dist/context-compaction-meta.d.ts.map +1 -1
  15. package/dist/context-compaction-meta.js +10 -10
  16. package/dist/context-compaction.d.ts +3 -0
  17. package/dist/context-compaction.d.ts.map +1 -1
  18. package/dist/context-compaction.js +23 -57
  19. package/dist/diff.d.ts +73 -5
  20. package/dist/diff.d.ts.map +1 -1
  21. package/dist/diff.js +435 -64
  22. package/dist/elicitation.d.ts +1 -1
  23. package/dist/elicitation.d.ts.map +1 -1
  24. package/dist/elicitation.js +12 -14
  25. package/dist/native-subagents.d.ts +58 -0
  26. package/dist/native-subagents.d.ts.map +1 -1
  27. package/dist/native-subagents.js +186 -34
  28. package/dist/paths.d.ts +3 -0
  29. package/dist/paths.d.ts.map +1 -0
  30. package/dist/paths.js +6 -0
  31. package/dist/permissions/presentation.d.ts +13 -2
  32. package/dist/permissions/presentation.d.ts.map +1 -1
  33. package/dist/permissions/presentation.js +49 -44
  34. package/dist/resumed-session.d.ts +11 -0
  35. package/dist/resumed-session.d.ts.map +1 -1
  36. package/dist/resumed-session.js +106 -4
  37. package/dist/session-mode.d.ts +2 -0
  38. package/dist/session-mode.d.ts.map +1 -1
  39. package/dist/session-mode.js +12 -5
  40. package/dist/settings.js +2 -2
  41. package/dist/subagent-history.d.ts +20 -0
  42. package/dist/subagent-history.d.ts.map +1 -0
  43. package/dist/subagent-history.js +46 -0
  44. package/dist/tool-calls/background.d.ts +20 -0
  45. package/dist/tool-calls/background.d.ts.map +1 -0
  46. package/dist/tool-calls/background.js +41 -0
  47. package/dist/tool-calls/changed-meta-filter.d.ts +27 -0
  48. package/dist/tool-calls/changed-meta-filter.d.ts.map +1 -0
  49. package/dist/tool-calls/changed-meta-filter.js +102 -0
  50. package/dist/tool-calls/client-capabilities.d.ts +51 -0
  51. package/dist/tool-calls/client-capabilities.d.ts.map +1 -0
  52. package/dist/tool-calls/client-capabilities.js +43 -0
  53. package/dist/tool-calls/content.d.ts +59 -0
  54. package/dist/tool-calls/content.d.ts.map +1 -0
  55. package/dist/tool-calls/content.js +194 -0
  56. package/dist/tool-calls/facts.d.ts +116 -0
  57. package/dist/tool-calls/facts.d.ts.map +1 -0
  58. package/dist/tool-calls/facts.js +1 -0
  59. package/dist/tool-calls/field-tracker.d.ts +58 -0
  60. package/dist/tool-calls/field-tracker.d.ts.map +1 -0
  61. package/dist/tool-calls/field-tracker.js +167 -0
  62. package/dist/tool-calls/renderer.d.ts +216 -0
  63. package/dist/tool-calls/renderer.d.ts.map +1 -0
  64. package/dist/tool-calls/renderer.js +487 -0
  65. package/dist/tool-calls/reporters/agent.d.ts +7 -0
  66. package/dist/tool-calls/reporters/agent.d.ts.map +1 -0
  67. package/dist/tool-calls/reporters/agent.js +190 -0
  68. package/dist/tool-calls/reporters/bash.d.ts +9 -0
  69. package/dist/tool-calls/reporters/bash.d.ts.map +1 -0
  70. package/dist/tool-calls/reporters/bash.js +83 -0
  71. package/dist/tool-calls/reporters/compaction.d.ts +24 -0
  72. package/dist/tool-calls/reporters/compaction.d.ts.map +1 -0
  73. package/dist/tool-calls/reporters/compaction.js +66 -0
  74. package/dist/tool-calls/reporters/file-edit.d.ts +46 -0
  75. package/dist/tool-calls/reporters/file-edit.d.ts.map +1 -0
  76. package/dist/tool-calls/reporters/file-edit.js +180 -0
  77. package/dist/tool-calls/reporters/index.d.ts +4 -0
  78. package/dist/tool-calls/reporters/index.d.ts.map +1 -0
  79. package/dist/tool-calls/reporters/index.js +40 -0
  80. package/dist/tool-calls/reporters/interaction.d.ts +71 -0
  81. package/dist/tool-calls/reporters/interaction.d.ts.map +1 -0
  82. package/dist/tool-calls/reporters/interaction.js +222 -0
  83. package/dist/tool-calls/reporters/read.d.ts +7 -0
  84. package/dist/tool-calls/reporters/read.d.ts.map +1 -0
  85. package/dist/tool-calls/reporters/read.js +64 -0
  86. package/dist/tool-calls/reporters/search.d.ts +10 -0
  87. package/dist/tool-calls/reporters/search.d.ts.map +1 -0
  88. package/dist/tool-calls/reporters/search.js +50 -0
  89. package/dist/tool-calls/reporters/web.d.ts +11 -0
  90. package/dist/tool-calls/reporters/web.d.ts.map +1 -0
  91. package/dist/tool-calls/reporters/web.js +42 -0
  92. package/dist/tools.d.ts +32 -37
  93. package/dist/tools.d.ts.map +1 -1
  94. package/dist/tools.js +48 -970
  95. package/package.json +4 -1
package/dist/acp-agent.js CHANGED
@@ -1,23 +1,22 @@
1
1
  import { agent as acpAgent, CreateElicitationResponse, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
- import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
2
+ import { deleteSession, getSessionMessages, getSubagentMessages, listSessions, query, } 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
5
  import { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, asSdkSessionNotification, clientSupportsSubagents, } from "./acp-subagents.js";
6
- import { isNativeSubagentControlTool, isNativeSubagentControlUpdate, NativeSubagentRuntime, } from "./native-subagents.js";
7
- import { AIR_ASYNC_TASKS_CAPABILITY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, clientSupportsAirCapability, withAirMeta, } from "./air-extension.js";
6
+ import { isNativeSubagentControlTool, isNativeSubagentControlUpdate, NativeSubagentRuntime, resumedNativeSubagentId, sendMessageResumePrompt, } from "./native-subagents.js";
7
+ import { AIR_ASYNC_TASKS_CAPABILITY, AIR_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY, AIR_GOAL_KEY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, clientSupportsAirCapability, withAirMeta, } from "./air-extension.js";
8
8
  import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, clientSupportsAsyncTasks, } from "./async-tasks.js";
9
9
  import { AUTH_STATUS_PROBE_TIMEOUT_MS, AUTH_STATUS_UPDATE_METHOD, authStatusCapability, fromAccountInfo, fromCliStatus, gatewayAuthStatus, mergeAuthStatus, notLoggedInAuthStatus, sameAuthStatus, } from "./auth-status.js";
10
10
  import { execFile } from "node:child_process";
11
11
  import { randomUUID } from "node:crypto";
12
- import { existsSync } from "node:fs";
13
12
  import * as fs from "node:fs/promises";
14
- import * as os from "node:os";
15
13
  import * as path from "node:path";
16
14
  import { promisify } from "node:util";
17
15
  import packageJson from "../package.json" with { type: "json" };
18
16
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
19
17
  import { forkSession } from "./fork-session.js";
20
- import { readResumedSession } from "./resumed-session.js";
18
+ import { subagentHistory } from "./subagent-history.js";
19
+ import { readResumedModel, readResumedSession, } from "./resumed-session.js";
21
20
  import { SessionTiming } from "./session-timing.js";
22
21
  import { ALLOW_BYPASS, resolvePermissionMode } from "./permissions/modes.js";
23
22
  import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js";
@@ -30,10 +29,15 @@ import { billsClaudeSubscription, claudeLoginRequiredError, claudeSubscriptionNo
30
29
  import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, createNativeFileChangeReporter, supportsAgentFileChangeReport, } from "./file-change-audit.js";
31
30
  import { ContextCompactionLifecycle, clientSupportsCompactionUpdates, contextCompactionMetadataFromBoundary, isCompactSummaryMessage, } from "./context-compaction.js";
32
31
  import { clientSupportsNotices, MAX_NOTICE_TITLE_LENGTH, normalizeNoticeText, noticeOrTranscriptUpdate, sentenceCase, splitNoticeText, } from "./session-notices.js";
33
- import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromToolResult, unregisterHookCallback, } from "./tools.js";
34
- import { toolUpdateFromDiffToolResponse } from "./diff.js";
32
+ import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, hasHookCallback, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, changedTaskPlanEntries, forgetPublishedTaskPlan, unregisterHookCallback, } from "./tools.js";
33
+ import { previewPatchContent } from "./diff.js";
34
+ import { backgroundedBashToolCall } from "./tool-calls/background.js";
35
+ import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
36
+ import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
37
+ import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
38
+ import { AcpToolCallRenderer } from "./tool-calls/renderer.js";
35
39
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
36
- import { acceptedPlanToolResult, ExitPlanCoordinator, exitPlanModeRawOutput, isExitPlanInterruptionResult, observeExitPlanToolResults, } from "./exit-plan.js";
40
+ import { acceptedPlanToolResult, ExitPlanCoordinator, isExitPlanInterruptionResult, observeExitPlanToolResults, } from "./exit-plan.js";
37
41
  import { parseToolResultMeta } from "./tool-result-meta.js";
38
42
  import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
39
43
  import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
@@ -41,7 +45,6 @@ import { applyAvailableModelsAllowlist, buildModelConfigOption, getAvailableMode
41
45
  import { buildEffortConfigOption, EFFORT_CONFIG_ID, mergeEffortSettings, settingsEffortForModel, toSdkEffortLevel, } from "./session-effort.js";
42
46
  export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
43
47
  export { applyAvailableModelsAllowlist, matchResumedModel, MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js";
44
- export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
45
48
  const execFileAsync = promisify(execFile);
46
49
  /** Claude CLI emits this synthetic result when an interrupted cycle ends on
47
50
  * queued user input before producing any assistant content. It is a hand-off
@@ -74,6 +77,27 @@ const DEFAULT_CONTEXT_WINDOW = 200000;
74
77
  * pre-empt a slow-but-healthy interrupt. */
75
78
  const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
76
79
  const STRUCTURED_USAGE_TIMEOUT_MS = 5_000;
80
+ /** The number of settled subagents whose parent tool call a session keeps for
81
+ * a later resume (see `resumableSubagents`). */
82
+ const MAX_RESUMABLE_SUBAGENTS = 256;
83
+ /** Removes a settled task from `liveBackgroundTasks`. A subagent keeps its
84
+ * parent tool call in `resumableSubagents` for a later resume. */
85
+ function settleLiveBackgroundTask(session, taskId) {
86
+ const record = session.liveBackgroundTasks.get(taskId);
87
+ if (!record)
88
+ return;
89
+ session.liveBackgroundTasks.delete(taskId);
90
+ if (!record.isSubagent)
91
+ return;
92
+ const resumable = (session.resumableSubagents ??= new Map());
93
+ resumable.delete(taskId);
94
+ resumable.set(taskId, { parentToolUseId: record.parentToolUseId });
95
+ if (resumable.size > MAX_RESUMABLE_SUBAGENTS) {
96
+ const oldest = resumable.keys().next().value;
97
+ if (oldest !== undefined)
98
+ resumable.delete(oldest);
99
+ }
100
+ }
77
101
  /** Best-effort structured presentation for a local `/usage` turn. The command
78
102
  * itself always runs through Claude Code; null tells the consumer to forward
79
103
  * its original output unchanged. The timeout prevents an unstable control
@@ -412,15 +436,95 @@ const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
412
436
  const PROVIDER_ID = "main";
413
437
  const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
414
438
  const DEFAULT_VERTEX_BASE_URL = "https://aiplatform.googleapis.com";
439
+ /**
440
+ * The blocks of a consolidated assistant message without the text that
441
+ * already streamed as deltas.
442
+ *
443
+ * Each assembled text/thinking block is diffed against the streamed blocks in
444
+ * document order: nothing is left if it streamed in full (the common case),
445
+ * the whole block if it never streamed (a non-streaming gateway), and just the
446
+ * tail if the stream was cut short mid-block. Matching on content rather than
447
+ * the message id keeps the dedupe robust for gateways without a stable id.
448
+ * Tool-use and other blocks pass through untouched.
449
+ */
450
+ function unstreamedRemainder(blocks, streamedBlocks) {
451
+ const kept = [];
452
+ let streamPos = 0;
453
+ for (const item of blocks) {
454
+ if (item.type !== "text" && item.type !== "thinking") {
455
+ kept.push(item);
456
+ continue;
457
+ }
458
+ const block = item;
459
+ const full = (item.type === "text" ? block.text : block.thinking) ?? "";
460
+ // Empty assembled blocks carry nothing: drop them.
461
+ if (full.length === 0)
462
+ continue;
463
+ // A streamed block of the same type whose text is a prefix of this one
464
+ // was already delivered, at least partly. A non-empty streamed text is
465
+ // required so an empty or aborted streamed block does not swallow it.
466
+ const streamed = streamedBlocks[streamPos];
467
+ if (streamed &&
468
+ streamed.type === item.type &&
469
+ streamed.text.length > 0 &&
470
+ full.startsWith(streamed.text)) {
471
+ streamPos++;
472
+ const remainder = full.slice(streamed.text.length);
473
+ if (remainder.length === 0)
474
+ continue;
475
+ kept.push({ ...item, [item.type === "text" ? "text" : "thinking"]: remainder });
476
+ continue;
477
+ }
478
+ kept.push(item);
479
+ }
480
+ return kept;
481
+ }
482
+ /** Attributes a report to the Agent/Task tool call of the subagent that made it. */
483
+ function stampParentToolUseId(update, parentToolUseId) {
484
+ update._meta = {
485
+ ...update._meta,
486
+ claudeCode: {
487
+ ...(update._meta?.claudeCode ?? {}),
488
+ parentToolUseId,
489
+ },
490
+ };
491
+ }
415
492
  const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
416
493
  function supportsSubagentTranscript(capabilities) {
417
494
  return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
418
495
  }
496
+ /**
497
+ * The number of sessions in one page of session/list. The SDK scans the whole
498
+ * project directory for each page, so a page holds all sessions of a usual
499
+ * project.
500
+ */
501
+ const SESSION_LIST_PAGE_SIZE = 1000;
502
+ /** The offset that a session/list cursor names. */
503
+ function sessionListOffset(cursor) {
504
+ if (cursor === null || cursor === undefined)
505
+ return 0;
506
+ const match = /^offset:(\d+)$/.exec(cursor);
507
+ if (!match)
508
+ throw RequestError.invalidParams(undefined, `Unknown session/list cursor: ${cursor}`);
509
+ return Number(match[1]);
510
+ }
419
511
  function parentToolUseIdOf(message) {
420
512
  if (!("parent_tool_use_id" in message))
421
513
  return null;
422
514
  return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null;
423
515
  }
516
+ /** The ids of the Agent and Task tool uses in the content of a message. */
517
+ function subagentLaunchIds(content) {
518
+ if (!Array.isArray(content))
519
+ return [];
520
+ return content.flatMap((block) => typeof block === "object" &&
521
+ block !== null &&
522
+ block.type === "tool_use" &&
523
+ typeof block.id === "string" &&
524
+ isNativeSubagentControlTool(block.name)
525
+ ? [block.id]
526
+ : []);
527
+ }
424
528
  function replaySubagentTerminalState(block) {
425
529
  if (block.is_error !== true)
426
530
  return "completed";
@@ -457,11 +561,19 @@ function stripSubagentTextAndThinking(content) {
457
561
  * that sit at the top level of the input object — everything before such a
458
562
  * comma is a set of complete fields. Returns true once the input object's
459
563
  * closing brace arrives.
564
+ *
565
+ * The lexer reads the fragment itself, never `partialJson` by index. V8 keeps a
566
+ * string built with `+=` as a chain of parts, and the first index access
567
+ * copies the whole chain into one flat string. An index scan of `partialJson`
568
+ * would copy the whole input on each delta, so a large Write would cost
569
+ * quadratic time. A test in `acp-agent.test.ts` checks this.
460
570
  */
461
- function scanStreamedToolInput(state) {
571
+ function scanStreamedToolInput(state, fragment) {
572
+ const offset = state.partialJson.length;
573
+ state.partialJson += fragment;
462
574
  let complete = false;
463
- for (let index = state.scannedTo; index < state.partialJson.length; index++) {
464
- const character = state.partialJson[index];
575
+ for (let index = 0; index < fragment.length; index++) {
576
+ const character = fragment[index];
465
577
  if (state.inString) {
466
578
  if (state.escaped) {
467
579
  state.escaped = false;
@@ -493,10 +605,9 @@ function scanStreamedToolInput(state) {
493
605
  state.arrayDepth--;
494
606
  }
495
607
  else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
496
- state.lastTopLevelComma = index;
608
+ state.lastTopLevelComma = offset + index;
497
609
  }
498
610
  }
499
- state.scannedTo = state.partialJson.length;
500
611
  return complete;
501
612
  }
502
613
  /** Parse the complete top-level fields before a top-level comma by closing the
@@ -758,6 +869,47 @@ class ClientConnection {
758
869
  return this.ctx.notify(method, params);
759
870
  }
760
871
  }
872
+ /**
873
+ * The client that the agent talks to. For an AIR client, every session update
874
+ * passes the {@link ChangedMetaFilter} last, after the native subagent routing,
875
+ * so a `tool_call_update` carries only the `_meta` keys that changed. ACP
876
+ * merges only the top-level tool call fields, not the `_meta` keys. So another
877
+ * client gets the full `_meta` on each update.
878
+ */
879
+ class ChangedMetaClient {
880
+ inner;
881
+ airClient;
882
+ filter = new ChangedMetaFilter();
883
+ constructor(inner, airClient) {
884
+ this.inner = inner;
885
+ this.airClient = airClient;
886
+ }
887
+ async sessionUpdate(params) {
888
+ if (!this.airClient())
889
+ return this.inner.sessionUpdate(params);
890
+ const update = this.filter.apply(params.update);
891
+ if (update)
892
+ await this.inner.sessionUpdate({ ...params, update });
893
+ }
894
+ requestPermission(params, signal) {
895
+ return this.inner.requestPermission(params, signal);
896
+ }
897
+ readTextFile(params) {
898
+ return this.inner.readTextFile(params);
899
+ }
900
+ writeTextFile(params) {
901
+ return this.inner.writeTextFile(params);
902
+ }
903
+ createElicitation(params, signal) {
904
+ return this.inner.createElicitation(params, signal);
905
+ }
906
+ completeElicitation(params) {
907
+ return this.inner.completeElicitation(params);
908
+ }
909
+ extNotification(method, params) {
910
+ return this.inner.extNotification(method, params);
911
+ }
912
+ }
761
913
  function raceWithAbort(operation, signal) {
762
914
  return new Promise((resolve, reject) => {
763
915
  const cleanup = () => signal.removeEventListener("abort", onAbort);
@@ -896,6 +1048,8 @@ export class ClaudeAcpAgent {
896
1048
  sessions;
897
1049
  client;
898
1050
  clientCapabilities;
1051
+ /** The tool call report choices, read once from {@link clientCapabilities} in `initialize`. */
1052
+ toolCallCapabilities = new ToolCallClientCapabilities();
899
1053
  logger;
900
1054
  sessionModes;
901
1055
  gatewayAuthRequest;
@@ -929,7 +1083,7 @@ export class ClaudeAcpAgent {
929
1083
  forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
930
1084
  constructor(client, logger) {
931
1085
  this.sessions = {};
932
- this.client = client;
1086
+ this.client = new ChangedMetaClient(client, () => this.toolCallCapabilities.air.client);
933
1087
  this.logger = logger ?? console;
934
1088
  this.exitPlan = new ExitPlanCoordinator({
935
1089
  currentSession: (id) => this.sessions[id],
@@ -951,6 +1105,7 @@ export class ClaudeAcpAgent {
951
1105
  this.closeQueryStream(session);
952
1106
  session.abortController.abort();
953
1107
  session.eagerToolCallSessions?.clear();
1108
+ session.toolCallFields?.clear();
954
1109
  clearHookCallbacks(id);
955
1110
  session.nativeSubagentRuntime?.clear();
956
1111
  session.asyncTaskRuntime?.clear();
@@ -972,6 +1127,7 @@ export class ClaudeAcpAgent {
972
1127
  });
973
1128
  this.sessionModes = new SessionModeManager({
974
1129
  getSession: (sessionId) => this.sessions[sessionId],
1130
+ airClient: () => this.toolCallCapabilities.air.client,
975
1131
  sessionEndedMessage: SESSION_ENDED_MESSAGE,
976
1132
  updateConfigOption: (sessionId, configId, value) => this.updateConfigOption(sessionId, configId, value),
977
1133
  sessionUpdate: (params) => this.client.sessionUpdate(params),
@@ -982,6 +1138,7 @@ export class ClaudeAcpAgent {
982
1138
  }
983
1139
  async initialize(request) {
984
1140
  this.clientCapabilities = request.clientCapabilities;
1141
+ this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities);
985
1142
  // Learn the auth identity in the background: `initialize` never waits on
986
1143
  // the CLI probe, and no snapshot rides in its response. When the probe
987
1144
  // lands it calls `setAuthStatus`, which pushes `_auth/status_update` — the
@@ -1135,16 +1292,19 @@ export class ClaudeAcpAgent {
1135
1292
  // Top-level `_meta` (sibling of `agentCapabilities`), per the existing ACP
1136
1293
  // steering extension contract: advertises the `_session/steering` request
1137
1294
  // so clients know they may inject a follow-up into a running turn.
1295
+ // Only AIR gets the AIR capabilities and the goal capability, under
1296
+ // `jetbrains.air`.
1138
1297
  _meta: {
1139
- ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY),
1298
+ ...(this.toolCallCapabilities.air.client
1299
+ ? withAirMeta(airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, AIR_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY), AIR_GOAL_KEY, {
1300
+ version: GOAL_EXTENSION_VERSION,
1301
+ controlMethod: GOAL_CONTROL_METHOD,
1302
+ actions: [...GOAL_ACTIONS],
1303
+ })
1304
+ : {}),
1140
1305
  steering: {
1141
1306
  supported: true,
1142
1307
  },
1143
- goal: {
1144
- version: GOAL_EXTENSION_VERSION,
1145
- controlMethod: GOAL_CONTROL_METHOD,
1146
- actions: [...GOAL_ACTIONS],
1147
- },
1148
1308
  },
1149
1309
  };
1150
1310
  }
@@ -1198,10 +1358,21 @@ export class ClaudeAcpAgent {
1198
1358
  }, 0);
1199
1359
  return result;
1200
1360
  }
1361
+ /**
1362
+ * One page of the sessions, newest first. The cursor is the offset of the
1363
+ * page. The SDK then orders the transcripts by their modification time and
1364
+ * reads the start and the end only of the transcripts of the page.
1365
+ */
1201
1366
  async listSessions(params) {
1202
- const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
1367
+ const offset = sessionListOffset(params.cursor);
1368
+ // One more session than the page tells whether a next page exists.
1369
+ const sdkSessions = await listSessions({
1370
+ dir: params.cwd ?? undefined,
1371
+ limit: SESSION_LIST_PAGE_SIZE + 1,
1372
+ offset,
1373
+ });
1203
1374
  const sessions = [];
1204
- for (const session of sdk_sessions) {
1375
+ for (const session of sdkSessions.slice(0, SESSION_LIST_PAGE_SIZE)) {
1205
1376
  if (!session.cwd)
1206
1377
  continue;
1207
1378
  sessions.push({
@@ -1211,9 +1382,9 @@ export class ClaudeAcpAgent {
1211
1382
  updatedAt: new Date(session.lastModified).toISOString(),
1212
1383
  });
1213
1384
  }
1214
- return {
1215
- sessions,
1216
- };
1385
+ return sdkSessions.length > SESSION_LIST_PAGE_SIZE
1386
+ ? { sessions, nextCursor: `offset:${offset + SESSION_LIST_PAGE_SIZE}` }
1387
+ : { sessions };
1217
1388
  }
1218
1389
  /**
1219
1390
  * `authenticate` — the legacy gateway methods store a provider override that
@@ -1811,21 +1982,24 @@ export class ClaudeAcpAgent {
1811
1982
  if (session) {
1812
1983
  session.lastPublishedGoal = goal;
1813
1984
  }
1985
+ // The goal is an AIR extension: only AIR gets it.
1986
+ if (!this.toolCallCapabilities.air.client)
1987
+ return;
1814
1988
  await this.client.sessionUpdate({
1815
1989
  sessionId,
1816
1990
  update: {
1817
1991
  sessionUpdate: "session_info_update",
1818
- _meta: { goal },
1992
+ _meta: withAirMeta(undefined, AIR_GOAL_KEY, goal),
1819
1993
  },
1820
1994
  });
1821
1995
  }
1822
1996
  async publishTaskPlan(sessionId, taskState) {
1997
+ const entries = changedTaskPlanEntries(taskState, this.toolCallCapabilities.air.client);
1998
+ if (!entries)
1999
+ return;
1823
2000
  await this.client.sessionUpdate({
1824
2001
  sessionId,
1825
- update: {
1826
- sessionUpdate: "plan",
1827
- entries: taskStateToPlanEntries(taskState),
1828
- },
2002
+ update: { sessionUpdate: "plan", entries },
1829
2003
  });
1830
2004
  }
1831
2005
  async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
@@ -2025,7 +2199,28 @@ export class ClaudeAcpAgent {
2025
2199
  // on content rather than the Anthropic message id makes dedupe robust to
2026
2200
  // gateways that don't carry a stable/matching id across the stream and the
2027
2201
  // consolidated message. Reset after each consolidated message consumes it.
2028
- const streamedBlocks = [];
2202
+ //
2203
+ // Keyed by the parent tool use of the stream ("" for the top level), so a
2204
+ // subagent message gets the same remainder diff as a top-level message.
2205
+ // The entry of a subagent goes when the subagent finishes.
2206
+ const streamedBlocksByParent = new Map();
2207
+ const streamedBlocksOf = (parentToolUseId) => {
2208
+ const key = parentToolUseId ?? "";
2209
+ let blocks = streamedBlocksByParent.get(key);
2210
+ if (!blocks)
2211
+ streamedBlocksByParent.set(key, (blocks = []));
2212
+ return blocks;
2213
+ };
2214
+ // A client gets the consolidated subagent text when it negotiated the
2215
+ // transcript extension or the `forwardSubagentText` session option. AIR
2216
+ // also gets it with native subagent sessions, and AIR gets no streamed
2217
+ // subagent text without it: nested text then stays internal to the Agent
2218
+ // tool call. Every other client gets the streamed subagent text, like
2219
+ // upstream.
2220
+ const airClient = this.toolCallCapabilities.air.client;
2221
+ const forwardsSubagentText = () => session.forwardSubagentText ||
2222
+ supportsSubagentTranscript(this.clientCapabilities) ||
2223
+ (airClient && clientSupportsSubagents(this.clientCapabilities));
2029
2224
  // Tool-use blocks start streaming before their JSON input. Keep the
2030
2225
  // partial input per parent message and block index so completed top-level
2031
2226
  // fields can refine the pending tool call while it streams. Entries are
@@ -2054,9 +2249,15 @@ export class ClaudeAcpAgent {
2054
2249
  * `tool_use_id` so hosts can collapse them; a notice has no lifecycle to
2055
2250
  * update, so only the first becomes one. */
2056
2251
  const noticedToolUses = new Set();
2057
- const asyncTasks = (session.asyncTaskRuntime ??= new AsyncTaskRuntime(clientSupportsAsyncTasks(this.clientCapabilities), params.sessionId, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), { notices: supportsNotices }));
2252
+ const asyncTasks = (session.asyncTaskRuntime ??= new AsyncTaskRuntime(clientSupportsAsyncTasks(this.clientCapabilities), params.sessionId, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), {
2253
+ notices: supportsNotices,
2254
+ // A task that a subagent tool call started belongs to the child
2255
+ // session of that tool call, like the tool call itself.
2256
+ routeOf: (toolCallId) => session.nativeSubagentRuntime?.routeOfToolCall(toolCallId, session.eagerToolCallSessions?.get(toolCallId)),
2257
+ }));
2058
2258
  const compaction = new ContextCompactionLifecycle((notification) => sendUpdate(notification), {
2059
2259
  sessionId: params.sessionId,
2260
+ airClient: this.toolCallCapabilities.air.client,
2060
2261
  presentation: clientSupportsCompactionUpdates(this.clientCapabilities)
2061
2262
  ? "compaction_update"
2062
2263
  : "tool_call",
@@ -2180,12 +2381,12 @@ export class ClaudeAcpAgent {
2180
2381
  lastAssistantWasUsageLimit = false;
2181
2382
  lastAssistantFailureTitle = undefined;
2182
2383
  lastRefusalExplanation = null;
2183
- // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
2384
+ // Do NOT reset currentStreamMessageId or the streamed blocks here. Turn
2184
2385
  // activation can fire mid-message (the replayed user echo with
2185
2386
  // --replay-user-messages lands between a message's blocks); clearing the
2186
2387
  // streamed-content record on activation would drop the blocks that
2187
2388
  // streamed before the echo, so the consolidated assistant message would
2188
- // re-emit them as duplicates. streamedBlocks is bounded instead by being
2389
+ // re-emit them as duplicates. The streamed blocks are bounded instead by being
2189
2390
  // cleared when each consolidated message consumes it. #785 stopped
2190
2391
  // resetting the streamed-content tracking here but left this line.
2191
2392
  stopReason = "end_turn";
@@ -2233,7 +2434,7 @@ export class ClaudeAcpAgent {
2233
2434
  continue;
2234
2435
  }
2235
2436
  if (record.endedPerLevel === "sweep-armed") {
2236
- session.liveBackgroundTasks.delete(taskId);
2437
+ settleLiveBackgroundTask(session, taskId);
2237
2438
  }
2238
2439
  else {
2239
2440
  record.endedPerLevel = "sweep-armed";
@@ -2423,6 +2624,28 @@ export class ClaudeAcpAgent {
2423
2624
  turn.usageOriginalOutput = originalOutput;
2424
2625
  return markdown;
2425
2626
  };
2627
+ /** Registers a live background task (see `liveBackgroundTasks`). A
2628
+ * subagent is also recorded on the active turn, so that turn holds until
2629
+ * the subagent settles (see `Turn.spawnedTaskIds`). */
2630
+ const registerLiveTask = (taskId, parentToolUseId, isSubagent) => {
2631
+ session.liveBackgroundTasks.set(taskId, { parentToolUseId, isSubagent });
2632
+ session.resumableSubagents?.delete(taskId);
2633
+ if (isSubagent && session.activeTurn && !session.activeTurn.settled) {
2634
+ (session.activeTurn.spawnedTaskIds ??= new Set()).add(taskId);
2635
+ }
2636
+ };
2637
+ const settleLiveTask = (taskId) => settleLiveBackgroundTask(session, taskId);
2638
+ /** Registers a settled subagent again when the SDK resumes it without a
2639
+ * new `task_started`. The active turn is the one that resumed it, so it
2640
+ * holds until the subagent settles again. A repeated resume signal finds
2641
+ * the subagent live and changes nothing. */
2642
+ const resumeLiveTask = (taskId) => {
2643
+ if (session.liveBackgroundTasks.has(taskId))
2644
+ return;
2645
+ const settled = session.resumableSubagents?.get(taskId);
2646
+ if (settled)
2647
+ registerLiveTask(taskId, settled.parentToolUseId, true);
2648
+ };
2426
2649
  /** Whether any background subagent this turn spawned is still live —
2427
2650
  * while true, the turn's settlement stays deferred so the subagent's
2428
2651
  * output and permission requests land inside it (see
@@ -2881,7 +3104,7 @@ export class ClaudeAcpAgent {
2881
3104
  // A fresh `system`/init (e.g. after reinitialize) can carry an
2882
3105
  // updated Fast mode state; reconcile it with what we seeded at
2883
3106
  // session creation.
2884
- await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
3107
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state, message.fast_mode_disabled_reason);
2885
3108
  // Terminal-bound slash commands (absent when none, and on
2886
3109
  // older CLIs). The session/new advertisement runs before any
2887
3110
  // init frame can be observed, so the first latch (or a
@@ -2891,7 +3114,7 @@ export class ClaudeAcpAgent {
2891
3114
  JSON.stringify(session.terminalSlashCommands)) {
2892
3115
  session.terminalSlashCommands = message.terminal_slash_commands;
2893
3116
  try {
2894
- await this.sendAvailableCommandsUpdate(message.session_id);
3117
+ await this.sendAvailableCommandsUpdate(params.sessionId);
2895
3118
  }
2896
3119
  catch (error) {
2897
3120
  // Advisory reconcile only — the client keeps its current
@@ -2937,7 +3160,7 @@ export class ClaudeAcpAgent {
2937
3160
  lastAssistantTotalUsage = usedTokens;
2938
3161
  session.contextUsedTokens = usedTokens;
2939
3162
  await sendUpdate({
2940
- sessionId: message.session_id,
3163
+ sessionId: params.sessionId,
2941
3164
  update: attachUsageModel({
2942
3165
  sessionUpdate: "usage_update",
2943
3166
  used: lastAssistantTotalUsage,
@@ -2957,7 +3180,7 @@ export class ClaudeAcpAgent {
2957
3180
  if (usageMarkdown === null)
2958
3181
  break;
2959
3182
  await sendUpdate({
2960
- sessionId: message.session_id,
3183
+ sessionId: params.sessionId,
2961
3184
  update: {
2962
3185
  sessionUpdate: "agent_message_chunk",
2963
3186
  content: { type: "text", text: usageMarkdown ?? message.content },
@@ -3106,39 +3329,9 @@ export class ClaudeAcpAgent {
3106
3329
  break;
3107
3330
  }
3108
3331
  case "memory_recall": {
3109
- const isSynthesis = message.mode === "synthesize";
3110
- const locations = isSynthesis
3111
- ? []
3112
- : message.memories.map((m) => ({ path: m.path }));
3113
- const content = isSynthesis
3114
- ? message.memories
3115
- .filter((m) => typeof m.content === "string")
3116
- .map((m) => ({
3117
- type: "content",
3118
- content: { type: "text", text: m.content },
3119
- }))
3120
- : [];
3121
- const count = message.memories.length;
3122
- const title = isSynthesis
3123
- ? "Recalled synthesized memory"
3124
- : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
3125
3332
  await sendUpdate({
3126
- sessionId: message.session_id,
3127
- update: {
3128
- sessionUpdate: "tool_call",
3129
- toolCallId: message.uuid,
3130
- title,
3131
- kind: "read",
3132
- status: "completed",
3133
- ...(locations.length > 0 && { locations }),
3134
- ...(content.length > 0 && { content }),
3135
- _meta: {
3136
- claudeCode: {
3137
- toolName: "memory_recall",
3138
- toolResponse: { mode: message.mode },
3139
- },
3140
- },
3141
- },
3333
+ sessionId: params.sessionId,
3334
+ update: new AcpToolCallRenderer(this.toolCallCapabilities).memoryRecall(message),
3142
3335
  });
3143
3336
  break;
3144
3337
  }
@@ -3150,7 +3343,7 @@ export class ClaudeAcpAgent {
3150
3343
  // it's authoritative, and re-querying supportedCommands()
3151
3344
  // would just return the same list with an extra round-trip.
3152
3345
  await sendUpdate({
3153
- sessionId: message.session_id,
3346
+ sessionId: params.sessionId,
3154
3347
  update: {
3155
3348
  sessionUpdate: "available_commands_update",
3156
3349
  availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
@@ -3205,32 +3398,18 @@ export class ClaudeAcpAgent {
3205
3398
  // child tool call was not announced there.
3206
3399
  break;
3207
3400
  }
3208
- const reason = message.decision_reason ?? message.message;
3209
- await sendUpdate({
3210
- sessionId: message.session_id,
3211
- update: {
3212
- sessionUpdate: "tool_call_update",
3213
- toolCallId: message.tool_use_id,
3214
- status: "failed",
3215
- content: [
3216
- {
3217
- type: "content",
3218
- content: { type: "text", text: `Permission denied: ${reason}` },
3219
- },
3220
- ],
3221
- _meta: {
3222
- claudeCode: {
3223
- toolName: message.tool_name,
3224
- ...(parentToolUseId ? { parentToolUseId } : {}),
3225
- toolResponse: {
3226
- decisionReasonType: message.decision_reason_type,
3227
- decisionReason: message.decision_reason,
3228
- message: message.message,
3229
- },
3230
- },
3231
- },
3232
- },
3401
+ const denied = new AcpToolCallRenderer(this.toolCallCapabilities).permissionDenied({
3402
+ toolCallId: message.tool_use_id,
3403
+ toolName: message.tool_name,
3404
+ parentToolUseId,
3405
+ decisionReasonType: message.decision_reason_type,
3406
+ decisionReason: message.decision_reason,
3407
+ message: message.message,
3233
3408
  });
3409
+ // A denial is final, so it replaces a pinned approval patch.
3410
+ if (toolCallFieldsOf(session).apply(denied, { replacePinnedContent: true })) {
3411
+ await sendUpdate({ sessionId: params.sessionId, update: denied });
3412
+ }
3234
3413
  break;
3235
3414
  }
3236
3415
  case "informational": {
@@ -3265,7 +3444,7 @@ export class ClaudeAcpAgent {
3265
3444
  ? message.content
3266
3445
  : `**${sentenceCase(message.level)}:** ${message.content}`;
3267
3446
  await sendUpdate({
3268
- sessionId: message.session_id,
3447
+ sessionId: params.sessionId,
3269
3448
  update: noticeOrTranscriptUpdate({
3270
3449
  severity,
3271
3450
  ...splitNoticeText(message.content, severity === "warning"
@@ -3291,6 +3470,7 @@ export class ClaudeAcpAgent {
3291
3470
  summary: message.summary,
3292
3471
  last_tool_name: message.last_tool_name,
3293
3472
  usage: message.usage,
3473
+ tool_use_id: message.tool_use_id,
3294
3474
  });
3295
3475
  break;
3296
3476
  case "task_started":
@@ -3312,10 +3492,7 @@ export class ClaudeAcpAgent {
3312
3492
  // turn: a turn only ever waits on its own subagents, and a
3313
3493
  // spawn during a held-open drain window (an agent chain)
3314
3494
  // extends that turn's hold.
3315
- session.liveBackgroundTasks.set(message.task_id, {
3316
- parentToolUseId: message.tool_use_id,
3317
- isSubagent: !!message.subagent_type,
3318
- });
3495
+ registerLiveTask(message.task_id, message.tool_use_id, !!message.subagent_type);
3319
3496
  await subagents.taskStarted({
3320
3497
  taskId: message.task_id,
3321
3498
  toolUseId: message.tool_use_id,
@@ -3333,9 +3510,6 @@ export class ClaudeAcpAgent {
3333
3510
  skip_transcript: message.skip_transcript,
3334
3511
  tool_use_id: message.tool_use_id,
3335
3512
  });
3336
- if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
3337
- (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
3338
- }
3339
3513
  break;
3340
3514
  case "task_notification":
3341
3515
  // The task settled — no further tool calls can originate
@@ -3346,10 +3520,14 @@ export class ClaudeAcpAgent {
3346
3520
  status: message.status,
3347
3521
  summary: message.summary,
3348
3522
  output_file: message.output_file,
3523
+ tool_use_id: message.tool_use_id,
3349
3524
  });
3350
- if (message.tool_use_id)
3525
+ if (message.tool_use_id) {
3351
3526
  subagents.discardPending(message.tool_use_id);
3352
- session.liveBackgroundTasks.delete(message.task_id);
3527
+ // The subagent streams no more text under this parent.
3528
+ streamedBlocksByParent.delete(message.tool_use_id);
3529
+ }
3530
+ settleLiveTask(message.task_id);
3353
3531
  break;
3354
3532
  case "task_updated":
3355
3533
  await asyncTasks.taskUpdated(message.task_id, message.patch);
@@ -3362,7 +3540,17 @@ export class ClaudeAcpAgent {
3362
3540
  message.patch.status === "failed" ||
3363
3541
  message.patch.status === "killed") {
3364
3542
  await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
3365
- session.liveBackgroundTasks.delete(message.task_id);
3543
+ const parentToolUseId = session.liveBackgroundTasks.get(message.task_id)?.parentToolUseId;
3544
+ if (parentToolUseId)
3545
+ streamedBlocksByParent.delete(parentToolUseId);
3546
+ settleLiveTask(message.task_id);
3547
+ }
3548
+ else if (message.patch.status === "running" ||
3549
+ message.patch.status === "pending") {
3550
+ // The SDK can resume a finished subagent under the same
3551
+ // agent id without a new task_started.
3552
+ resumeLiveTask(message.task_id);
3553
+ await subagents.taskResumed(message.task_id, sendUpdate, sendMessageResumePrompt(session.toolUseCache, message.task_id));
3366
3554
  }
3367
3555
  break;
3368
3556
  case "worker_shutting_down":
@@ -3480,7 +3668,7 @@ export class ClaudeAcpAgent {
3480
3668
  ...(explanation ? { description: explanation } : {}),
3481
3669
  };
3482
3670
  await sendUpdate({
3483
- sessionId: message.session_id,
3671
+ sessionId: params.sessionId,
3484
3672
  update: noticeOrTranscriptUpdate({ severity: "warning", ...notice }, supportsNotices, `**Model fallback:** ${fallbackNotice}`),
3485
3673
  });
3486
3674
  }
@@ -3562,6 +3750,9 @@ export class ClaudeAcpAgent {
3562
3750
  }
3563
3751
  break;
3564
3752
  case "result": {
3753
+ // The result ends the model turn. A background task that still
3754
+ // waits for its tool call id gets its spawn now, without the id.
3755
+ await asyncTasks.releaseHeld();
3565
3756
  // A result from an autonomous cycle — a task-notification
3566
3757
  // followup, or a peer/coordinator/observer message the model
3567
3758
  // handled on its own (see AUTONOMOUS_RESULT_ORIGINS) — is not
@@ -4130,19 +4321,16 @@ export class ClaudeAcpAgent {
4130
4321
  // the top-level record. Fires once, before any of this message's
4131
4322
  // blocks, so it doesn't disturb the mid-message turn-activation
4132
4323
  // path the way resetting on turn activation would.
4133
- if (message.parent_tool_use_id === null) {
4134
- streamedBlocks.length = 0;
4135
- }
4324
+ streamedBlocksOf(message.parent_tool_use_id).length = 0;
4136
4325
  }
4137
4326
  // Accumulate the text/thinking actually streamed live, so the
4138
4327
  // `assistant` case below can diff its assembled blocks against what
4139
4328
  // already reached the client as chunks and forward only the
4140
- // remainder. Gated on `parent_tool_use_id === null` so a subagent
4141
- // stream can't attribute its content to the top-level message.
4329
+ // remainder. Each stream (top level or one subagent) keeps its own
4330
+ // record, so a subagent cannot attribute content to another stream.
4142
4331
  // Contiguous deltas of the same block (same index and type) extend
4143
4332
  // the current entry; anything else opens a new one.
4144
- if (message.parent_tool_use_id === null &&
4145
- message.event.type === "content_block_delta") {
4333
+ if (message.event.type === "content_block_delta") {
4146
4334
  const delta = message.event.delta;
4147
4335
  const chunk = delta.type === "text_delta"
4148
4336
  ? { type: "text", text: delta.text }
@@ -4156,6 +4344,7 @@ export class ClaudeAcpAgent {
4156
4344
  // re-emitting the next block as a duplicate.
4157
4345
  if (chunk?.text) {
4158
4346
  const index = message.event.index;
4347
+ const streamedBlocks = streamedBlocksOf(message.parent_tool_use_id);
4159
4348
  const last = streamedBlocks[streamedBlocks.length - 1];
4160
4349
  if (last && last.index === index && last.type === chunk.type) {
4161
4350
  last.text += chunk.text;
@@ -4222,9 +4411,19 @@ export class ClaudeAcpAgent {
4222
4411
  cwd: session.cwd,
4223
4412
  taskState: session.taskState,
4224
4413
  emittedToolCalls: session.emittedToolCalls,
4414
+ toolCallFields: toolCallFieldsOf(session),
4225
4415
  messageId: currentStreamMessageId,
4226
4416
  streamedToolInputs,
4227
4417
  })) {
4418
+ // Nested text stays internal for an AIR client that does not
4419
+ // get it, like the consolidated subagent message below.
4420
+ if (message.parent_tool_use_id !== null &&
4421
+ airClient &&
4422
+ !forwardsSubagentText() &&
4423
+ (notification.update.sessionUpdate === "agent_message_chunk" ||
4424
+ notification.update.sessionUpdate === "agent_thought_chunk")) {
4425
+ continue;
4426
+ }
4228
4427
  // sendUpdate records delivery; a subagent stream's chunks carry
4229
4428
  // the stamped parentToolUseId meta and are excluded there.
4230
4429
  await sendUpdate(notification);
@@ -4236,7 +4435,7 @@ export class ClaudeAcpAgent {
4236
4435
  // Record the ACP messageId -> SDK uuid mapping for this message
4237
4436
  // (including replays). The consolidated message carries both ids, so
4238
4437
  // this is where we learn the uuid the SDK's rewind/resume APIs key on
4239
- // for the id we hand clients. Not read yet (see messageIdToUuid).
4438
+ // for the id we hand clients. Fork reads it (see messageIdToUuid).
4240
4439
  const mappedMessageId = messageIdForGrouping(message);
4241
4440
  if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
4242
4441
  session.messageIdToUuid.set(mappedMessageId, message.uuid);
@@ -4457,73 +4656,22 @@ export class ClaudeAcpAgent {
4457
4656
  break;
4458
4657
  }
4459
4658
  let content;
4460
- if (message.type === "assistant" && message.parent_tool_use_id === null) {
4461
- // Top-level assistant message: each text/thinking block may have
4462
- // already been streamed live as deltas. Diff each against what
4463
- // streamed (`streamedBlocks`, in document order) and forward only
4464
- // the un-streamed remainder — nothing if it streamed in full (the
4465
- // common case), the whole block if it never streamed (a
4466
- // non-streaming gateway), or just the tail if the stream was cut
4467
- // short mid-block. `streamPos` walks the streamed blocks in step
4468
- // with the assembled text/thinking blocks; tool_use and other
4469
- // blocks pass through untouched (their own `toolUseCache` collapses
4470
- // the streamed/assembled pair) without advancing it.
4471
- const blocks = message.message.content;
4472
- const kept = [];
4473
- let streamPos = 0;
4474
- for (const item of blocks) {
4475
- if (item.type !== "text" && item.type !== "thinking") {
4476
- kept.push(item);
4477
- continue;
4478
- }
4479
- const full = item.type === "text" ? item.text : item.thinking;
4480
- // Empty assembled blocks carry nothing (some gateways emit an
4481
- // empty `thinking` block before the real text) — drop them.
4482
- if (full.length === 0) {
4483
- continue;
4484
- }
4485
- // A streamed block of the same type whose accumulated text is a
4486
- // prefix of this one was already (at least partly) delivered as
4487
- // chunks; consume it and forward only what's left. A non-empty
4488
- // streamed text is required so an empty/aborted streamed block
4489
- // doesn't swallow the assembled copy.
4490
- const streamed = streamedBlocks[streamPos];
4491
- if (streamed &&
4492
- streamed.type === item.type &&
4493
- streamed.text.length > 0 &&
4494
- full.startsWith(streamed.text)) {
4495
- streamPos++;
4496
- const remainder = full.slice(streamed.text.length);
4497
- if (remainder.length === 0) {
4498
- continue;
4499
- }
4500
- // Overwrite in place with just the un-streamed tail (the
4501
- // assembled message isn't read again after this) so the block
4502
- // keeps its exact SDK type.
4503
- if (item.type === "text") {
4504
- item.text = remainder;
4505
- }
4506
- else {
4507
- item.thinking = remainder;
4508
- }
4509
- kept.push(item);
4510
- continue;
4511
- }
4512
- // Not matched: never streamed (or the stream diverged from the
4513
- // assembled text) — forward the block in full.
4514
- kept.push(item);
4515
- }
4516
- content = kept;
4517
- // Consumed: reset so the next message's blocks accumulate fresh and
4518
- // the record stays bounded to the in-flight message.
4519
- streamedBlocks.length = 0;
4659
+ if (message.type === "assistant" &&
4660
+ (message.parent_tool_use_id === null || forwardsSubagentText())) {
4661
+ // Each text/thinking block may have streamed live as deltas
4662
+ // already, for the top level and for a subagent. Forward only
4663
+ // the un-streamed remainder (see `unstreamedRemainder`), and
4664
+ // reset the record of the stream so the next message starts
4665
+ // fresh.
4666
+ const streamed = streamedBlocksOf(message.parent_tool_use_id);
4667
+ content = unstreamedRemainder(message.message.content, streamed);
4668
+ streamed.length = 0;
4520
4669
  }
4521
- else if (message.type === "assistant" &&
4522
- !(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
4523
- // Legacy clients keep the flattened tool-call representation,
4524
- // but nested text/thinking stays internal unless explicitly
4525
- // requested through the historical transcript extension.
4670
+ else if (message.type === "assistant") {
4671
+ // Nested text/thinking stays internal for a client that does
4672
+ // not get subagent text.
4526
4673
  content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
4674
+ streamedBlocksOf(message.parent_tool_use_id).length = 0;
4527
4675
  }
4528
4676
  else {
4529
4677
  content = message.message.content;
@@ -4534,6 +4682,13 @@ export class ClaudeAcpAgent {
4534
4682
  backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
4535
4683
  if (backgroundBashTask)
4536
4684
  await asyncTasks.taskBackgrounded(backgroundBashTask);
4685
+ const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
4686
+ if (resumedAgentId) {
4687
+ resumeLiveTask(resumedAgentId);
4688
+ await subagents.taskResumed(resumedAgentId, sendUpdate, sendMessageResumePrompt(session.toolUseCache, resumedAgentId, Array.isArray(content)
4689
+ ? content.flatMap((block) => block.type === "tool_result" ? [block.tool_use_id] : [])
4690
+ : []));
4691
+ }
4537
4692
  }
4538
4693
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
4539
4694
  clientCapabilities: this.clientCapabilities,
@@ -4541,6 +4696,7 @@ export class ClaudeAcpAgent {
4541
4696
  cwd: session.cwd,
4542
4697
  taskState: session.taskState,
4543
4698
  emittedToolCalls: session.emittedToolCalls,
4699
+ toolCallFields: toolCallFieldsOf(session),
4544
4700
  messageId: messageIdForGrouping(message),
4545
4701
  toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
4546
4702
  // On the wire since CLI 2.1.216 but not in SDKUserMessage's
@@ -4582,42 +4738,29 @@ export class ClaudeAcpAgent {
4582
4738
  ? message.parent_tool_use_id
4583
4739
  : undefined
4584
4740
  : undefined;
4585
- await sendUpdate({
4586
- sessionId: message.session_id,
4587
- update: {
4588
- sessionUpdate: "tool_call_update",
4589
- toolCallId,
4590
- status: "in_progress",
4591
- _meta: {
4592
- claudeCode: {
4593
- toolName: message.tool_name,
4594
- ...(subagentParentToolUseId
4595
- ? { parentToolUseId: subagentParentToolUseId }
4596
- : {}),
4597
- toolResponse: {
4598
- elapsedTimeSeconds: message.elapsed_time_seconds,
4599
- // For Agent/Task calls: the subagent's type, and — when
4600
- // the subagent is waiting out an API rate-limit retry —
4601
- // the SDK's retry counters (attempt, max_retries,
4602
- // retry_delay_ms, …), forwarded verbatim so clients can
4603
- // show why a spawn looks stalled.
4604
- ...(message.subagent_type !== undefined && {
4605
- subagentType: message.subagent_type,
4606
- }),
4607
- ...(message.subagent_retry !== undefined && {
4608
- subagentRetry: message.subagent_retry,
4609
- }),
4610
- },
4611
- },
4612
- },
4613
- },
4741
+ // `tool_name` names the tool that reports the beat. When the beat
4742
+ // falls back to the parent call, AIR gets the name of that call,
4743
+ // or no name. Every other client gets `tool_name`, like upstream.
4744
+ const toolName = toolCallId !== message.tool_use_id && this.toolCallCapabilities.air.client
4745
+ ? session.toolUseCache[toolCallId]?.name
4746
+ : message.tool_name;
4747
+ const beat = new AcpToolCallRenderer(this.toolCallCapabilities).progress({
4748
+ toolCallId,
4749
+ toolName,
4750
+ parentToolUseId: subagentParentToolUseId,
4751
+ elapsedTimeSeconds: message.elapsed_time_seconds,
4752
+ subagentType: message.subagent_type,
4753
+ subagentRetry: message.subagent_retry,
4614
4754
  });
4755
+ if (toolCallFieldsOf(session).apply(beat)) {
4756
+ await sendUpdate({ sessionId: params.sessionId, update: beat });
4757
+ }
4615
4758
  break;
4616
4759
  }
4617
4760
  case "rate_limit_event": {
4618
4761
  if (lastAssistantTotalUsage !== null) {
4619
4762
  await sendUpdate({
4620
- sessionId: message.session_id,
4763
+ sessionId: params.sessionId,
4621
4764
  update: attachUsageModel({
4622
4765
  sessionUpdate: "usage_update",
4623
4766
  used: lastAssistantTotalUsage,
@@ -4637,6 +4780,7 @@ export class ClaudeAcpAgent {
4637
4780
  subagents.clear();
4638
4781
  asyncTasks.clear();
4639
4782
  session.eagerToolCallSessions?.clear();
4783
+ session.toolCallFields?.clear();
4640
4784
  clearHookCallbacks(params.sessionId);
4641
4785
  session.taskState.clear();
4642
4786
  await this.publishTaskPlan(params.sessionId, session.taskState);
@@ -4698,6 +4842,7 @@ export class ClaudeAcpAgent {
4698
4842
  failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
4699
4843
  this.closeQueryStream(session);
4700
4844
  session.eagerToolCallSessions?.clear();
4845
+ session.toolCallFields?.clear();
4701
4846
  clearHookCallbacks(params.sessionId);
4702
4847
  session.nativeSubagentRuntime?.clear();
4703
4848
  session.asyncTaskRuntime?.clear();
@@ -4753,6 +4898,7 @@ export class ClaudeAcpAgent {
4753
4898
  // this fire-and-forget notification, so there is nothing to do here.
4754
4899
  if (session.queryClosed) {
4755
4900
  session.eagerToolCallSessions?.clear();
4901
+ session.toolCallFields?.clear();
4756
4902
  clearHookCallbacks(params.sessionId);
4757
4903
  return;
4758
4904
  }
@@ -4769,6 +4915,7 @@ export class ClaudeAcpAgent {
4769
4915
  }
4770
4916
  finally {
4771
4917
  session.eagerToolCallSessions?.clear();
4918
+ session.toolCallFields?.clear();
4772
4919
  clearHookCallbacks(params.sessionId);
4773
4920
  }
4774
4921
  // A priority steer may still be queued in the SDK when cancellation
@@ -5066,6 +5213,7 @@ export class ClaudeAcpAgent {
5066
5213
  // appropriate; query.close() above has already torn the subprocess down.
5067
5214
  session.abortController.abort();
5068
5215
  session.eagerToolCallSessions?.clear();
5216
+ session.toolCallFields?.clear();
5069
5217
  clearHookCallbacks(sessionId);
5070
5218
  session.nativeSubagentRuntime?.clear();
5071
5219
  session.asyncTaskRuntime?.clear();
@@ -5183,6 +5331,9 @@ export class ClaudeAcpAgent {
5183
5331
  const historyLoadedAt = performance.now();
5184
5332
  this.logger.log(`[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`);
5185
5333
  const session = this.sessions[sessionId];
5334
+ // A replay rebuilds the client view, so its plan goes out again.
5335
+ if (session?.taskState)
5336
+ forgetPublishedTaskPlan(session.taskState);
5186
5337
  const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
5187
5338
  const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
5188
5339
  const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
@@ -5201,8 +5352,10 @@ export class ClaudeAcpAgent {
5201
5352
  const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
5202
5353
  const replayTerminalStates = new Map();
5203
5354
  const replayChildren = new Map();
5204
- if (nativeReplayEnabled) {
5205
- for (const message of messages) {
5355
+ // Registers the Agent and Task launches of `list`, and the terminal state
5356
+ // of each launch from its tool_result.
5357
+ const registerLaunches = (list) => {
5358
+ for (const message of list) {
5206
5359
  const content = message.message
5207
5360
  ?.content;
5208
5361
  if (!Array.isArray(content))
@@ -5252,7 +5405,9 @@ export class ClaudeAcpAgent {
5252
5405
  if (child)
5253
5406
  child.terminalState = terminalState;
5254
5407
  }
5255
- }
5408
+ };
5409
+ if (nativeReplayEnabled)
5410
+ registerLaunches(messages);
5256
5411
  const announceReplayChild = async (parentToolUseId, ancestors = new Set()) => {
5257
5412
  let child = replayChildren.get(parentToolUseId);
5258
5413
  if (!child) {
@@ -5293,7 +5448,12 @@ export class ClaudeAcpAgent {
5293
5448
  }
5294
5449
  return child.sessionId;
5295
5450
  };
5296
- for (const message of messages) {
5451
+ // Only a history with an Agent or Task launch has subagent transcripts to read.
5452
+ const subagents = nativeReplayEnabled && replayChildren.size > 0
5453
+ ? await subagentHistory(sessionId)
5454
+ : { ids: new Map() };
5455
+ const replayedSubagents = new Set();
5456
+ const replayMessage = async (message) => {
5297
5457
  if (message.type === "user" &&
5298
5458
  message.parent_tool_use_id === null &&
5299
5459
  typeof message.uuid === "string" &&
@@ -5313,7 +5473,7 @@ export class ClaudeAcpAgent {
5313
5473
  // assistant message into an authRequired error instead of showing its
5314
5474
  // TUI-specific text; skip it on replay too (issue #863).
5315
5475
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
5316
- continue;
5476
+ return;
5317
5477
  }
5318
5478
  // Capable clients saw every synthetic usage-limit message as a typed
5319
5479
  // failure live, so replay it at the same transcript position. The
@@ -5328,7 +5488,7 @@ export class ClaudeAcpAgent {
5328
5488
  if (title) {
5329
5489
  await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
5330
5490
  }
5331
- continue;
5491
+ return;
5332
5492
  }
5333
5493
  // @ts-expect-error - untyped in SDK but we handle all of these
5334
5494
  let content = message.message.content;
@@ -5346,7 +5506,7 @@ export class ClaudeAcpAgent {
5346
5506
  if (message.message.role === "user") {
5347
5507
  content = stripLocalCommandMetadata(content);
5348
5508
  if (content === null)
5349
- continue;
5509
+ return;
5350
5510
  }
5351
5511
  // Claude persists the retained summary as a user message framed with
5352
5512
  // continuation instructions for the model. Clients on the compaction
@@ -5365,10 +5525,14 @@ export class ClaudeAcpAgent {
5365
5525
  parentToolUseId === null &&
5366
5526
  typeof message.uuid === "string" &&
5367
5527
  message.uuid.length > 0) {
5368
- const replayCompaction = new ContextCompactionLifecycle((notification) => this.client.sessionUpdate(notification), { sessionId, presentation: "compaction_update" });
5528
+ const replayCompaction = new ContextCompactionLifecycle((notification) => this.client.sessionUpdate(notification), {
5529
+ sessionId,
5530
+ presentation: "compaction_update",
5531
+ airClient: this.toolCallCapabilities.air.client,
5532
+ });
5369
5533
  if (replayCompaction.recordSummary(assistantMessageText(message.message))) {
5370
5534
  await replayCompaction.finish(message.uuid, "completed");
5371
- continue;
5535
+ return;
5372
5536
  }
5373
5537
  }
5374
5538
  for (const notification of toAcpNotifications(
@@ -5377,6 +5541,7 @@ export class ClaudeAcpAgent {
5377
5541
  // @ts-expect-error - untyped in SDK but we handle all of these
5378
5542
  message.message.role, sessionId, toolUseCache, this.client, this.logger, {
5379
5543
  registerHooks: false,
5544
+ replay: true,
5380
5545
  clientCapabilities: this.clientCapabilities,
5381
5546
  cwd: this.sessions[sessionId]?.cwd,
5382
5547
  taskState: this.sessions[sessionId]?.taskState,
@@ -5392,7 +5557,38 @@ export class ClaudeAcpAgent {
5392
5557
  }
5393
5558
  await this.client.sessionUpdate({ ...notification, sessionId: replayTargetSessionId });
5394
5559
  }
5395
- }
5560
+ // The history of each subagent is in its own transcript. The replay
5561
+ // sends it to the child session right after the launch, one message at
5562
+ // a time, and keeps no subagent history after that.
5563
+ if (nativeReplayEnabled && message.type === "assistant") {
5564
+ for (const toolUseId of subagentLaunchIds(content)) {
5565
+ await announceReplayChild(toolUseId);
5566
+ const agentId = subagents.ids.get(toolUseId);
5567
+ if (!agentId || replayedSubagents.has(agentId))
5568
+ continue;
5569
+ replayedSubagents.add(agentId);
5570
+ let childMessages = [];
5571
+ try {
5572
+ // With the project directory, the SDK skips its search of every project.
5573
+ // A session opened from another directory falls back to that search.
5574
+ if (subagents.dir) {
5575
+ childMessages = await getSubagentMessages(sessionId, agentId, { dir: subagents.dir });
5576
+ }
5577
+ if (childMessages.length === 0) {
5578
+ childMessages = await getSubagentMessages(sessionId, agentId);
5579
+ }
5580
+ }
5581
+ catch (error) {
5582
+ this.logger.error(`Failed to read the history of subagent ${agentId}:`, error);
5583
+ }
5584
+ registerLaunches(childMessages);
5585
+ for (const childMessage of childMessages)
5586
+ await replayMessage(childMessage);
5587
+ }
5588
+ }
5589
+ };
5590
+ for (const message of messages)
5591
+ await replayMessage(message);
5396
5592
  if (nativeReplayEnabled) {
5397
5593
  // Claude history persists sidechain messages and Agent/Task tool uses,
5398
5594
  // but not task_started/task_updated lifecycle frames. Recover terminal
@@ -5449,7 +5645,7 @@ export class ClaudeAcpAgent {
5449
5645
  * `$/cancel_request`, and our local abort race settles even if the client
5450
5646
  * ignores it. A `cancelled` outcome, request rejection, and local abort all
5451
5647
  * surface the same "Tool use aborted" the callers already expect. */
5452
- async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId = params.sessionId) {
5648
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId, toolInput, previewContent) {
5453
5649
  if (signal.aborted)
5454
5650
  throw new Error("Tool use aborted");
5455
5651
  // The SDK may invoke `canUseTool` (and therefore this permission request)
@@ -5458,7 +5654,7 @@ export class ClaudeAcpAgent {
5458
5654
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
5459
5655
  // later refines it with a `tool_call_update` rather than emitting a
5460
5656
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
5461
- await this.ensureToolCallEmitted(ownerSessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal, params.sessionId);
5657
+ await this.ensureToolCallEmitted(ownerSessionId, toolName, params.toolCall.toolCallId, toolInput, parentToolUseId, signal, params.sessionId, previewContent);
5462
5658
  if (signal.aborted)
5463
5659
  throw new Error("Tool use aborted");
5464
5660
  // Do not rely on every ACP client settling requestPermission after the
@@ -5486,28 +5682,29 @@ export class ClaudeAcpAgent {
5486
5682
  * are resolved at tool_result time instead (see `toAcpNotifications`).
5487
5683
  * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
5488
5684
  * that spawned it, matching the streamed path's `_meta`. */
5489
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal, notificationSessionId = sessionId) {
5685
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal, notificationSessionId = sessionId, previewContent) {
5490
5686
  const session = this.sessions[sessionId];
5491
5687
  if (!session) {
5492
5688
  return;
5493
5689
  }
5690
+ // The permission request shows an exact approval patch. The streamed
5691
+ // tool input must not replace it with the standard diff of the snippet.
5692
+ const pinPreview = () => {
5693
+ if (previewContent) {
5694
+ toolCallFieldsOf(session).pinContent(toolCallId, previewContent);
5695
+ }
5696
+ };
5494
5697
  if (session.emittedToolCalls.has(toolCallId)) {
5698
+ pinPreview();
5495
5699
  return;
5496
5700
  }
5497
5701
  session.emittedToolCalls.add(toolCallId);
5498
5702
  (session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
5499
- const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true ||
5500
- this.clientCapabilities?._meta?.["terminal_output_delta"] === true;
5501
- const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
5502
- if (parentToolUseId) {
5503
- update._meta = {
5504
- ...update._meta,
5505
- claudeCode: {
5506
- ...(update._meta?.claudeCode || {}),
5507
- parentToolUseId,
5508
- },
5509
- };
5510
- }
5703
+ const update = new AcpToolCallRenderer(this.toolCallCapabilities).toolCall({ id: toolCallId, name: toolName, input: toolInput }, { cwd: session.cwd, previewContent });
5704
+ if (parentToolUseId)
5705
+ stampParentToolUseId(update, parentToolUseId);
5706
+ toolCallFieldsOf(session).apply(update);
5707
+ pinPreview();
5511
5708
  try {
5512
5709
  const emission = this.client.sessionUpdate({ sessionId: notificationSessionId, update });
5513
5710
  await (signal ? raceWithAbort(emission, signal) : emission);
@@ -5518,13 +5715,12 @@ export class ClaudeAcpAgent {
5518
5715
  // be allowed to publish the tool call instead of refining a phantom one.
5519
5716
  session.emittedToolCalls.delete(toolCallId);
5520
5717
  session.eagerToolCallSessions?.delete(toolCallId);
5718
+ session.toolCallFields?.delete(toolCallId);
5521
5719
  throw error;
5522
5720
  }
5523
5721
  }
5524
5722
  canUseTool(sessionId) {
5525
5723
  return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, defaultToNo, suppressAlwaysAllowRule, mcpServer, }) => {
5526
- const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true ||
5527
- this.clientCapabilities?._meta?.["terminal_output_delta"] === true;
5528
5724
  const session = this.sessions[sessionId];
5529
5725
  if (!session) {
5530
5726
  return {
@@ -5583,12 +5779,17 @@ export class ClaudeAcpAgent {
5583
5779
  // Artifact publishes).
5584
5780
  const noPersistentRule = matchedAskRule !== undefined || suppressAlwaysAllowRule === true;
5585
5781
  const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, noPersistentRule);
5782
+ const capabilities = this.toolCallCapabilities;
5783
+ const previewContent = capabilities.diffPatch
5784
+ ? await previewPatchContent(toolName, toolInput, session.cwd)
5785
+ : undefined;
5586
5786
  const presentation = buildClaudePermissionPresentation({
5587
5787
  toolName,
5588
5788
  input: toolInput,
5589
5789
  toolUseID,
5590
5790
  cwd: session.cwd,
5591
- supportsTerminalOutput,
5791
+ capabilities,
5792
+ previewContent,
5592
5793
  blockedPath,
5593
5794
  title,
5594
5795
  displayName,
@@ -5602,11 +5803,14 @@ export class ClaudeAcpAgent {
5602
5803
  // server; anything else is configuration) instead of parsing the
5603
5804
  // tool-name prefix. The name is the config key as authored — untrusted
5604
5805
  // text, so it rides `_meta` rather than the title.
5605
- if (parentToolUseId || mcpServer) {
5806
+ // AIR already holds the tool name and the parent tool call. Every
5807
+ // other client gets them again, like upstream.
5808
+ const airClient = capabilities.air.client;
5809
+ if (mcpServer || (parentToolUseId && !airClient)) {
5606
5810
  presentation.toolCall._meta = {
5607
5811
  claudeCode: {
5608
- toolName,
5609
- ...(parentToolUseId ? { parentToolUseId } : {}),
5812
+ ...(airClient ? {} : { toolName }),
5813
+ ...(parentToolUseId && !airClient ? { parentToolUseId } : {}),
5610
5814
  ...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}),
5611
5815
  },
5612
5816
  };
@@ -5629,7 +5833,7 @@ export class ClaudeAcpAgent {
5629
5833
  ...presentation,
5630
5834
  options: permissionOptions,
5631
5835
  sessionId: permissionSessionId,
5632
- }, toolName, signal, parentToolUseId, sessionId);
5836
+ }, toolName, signal, parentToolUseId, sessionId, toolInput, previewContent);
5633
5837
  if (signal.aborted)
5634
5838
  throw new Error("Tool use aborted");
5635
5839
  const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
@@ -5720,7 +5924,7 @@ export class ClaudeAcpAgent {
5720
5924
  if (!questions) {
5721
5925
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
5722
5926
  }
5723
- const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
5927
+ const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, this.toolCallCapabilities.air.client);
5724
5928
  let response;
5725
5929
  try {
5726
5930
  response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal));
@@ -6097,7 +6301,7 @@ export class ClaudeAcpAgent {
6097
6301
  }
6098
6302
  const resumedModelHint = resumedSession
6099
6303
  ? resumedSession.model
6100
- : (await readResumedSession(params.sessionId, this.logger)).model;
6304
+ : await readResumedModel(params.sessionId, this.logger);
6101
6305
  const response = await this.createSession({
6102
6306
  cwd: params.cwd,
6103
6307
  mcpServers: params.mcpServers ?? [],
@@ -6174,7 +6378,7 @@ export class ClaudeAcpAgent {
6174
6378
  let resumedModelHint = creationOpts.resumedModelHint;
6175
6379
  if (creationOpts.resume !== undefined &&
6176
6380
  !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint")) {
6177
- resumedModelHint = (await readResumedSession(creationOpts.resume, this.logger)).model;
6381
+ resumedModelHint = await readResumedModel(creationOpts.resume, this.logger);
6178
6382
  timing.phase("resume-transcript");
6179
6383
  }
6180
6384
  const input = new Pushable();
@@ -7426,164 +7630,9 @@ function isTaskTool(toolName) {
7426
7630
  function shouldEmitToolCall(toolName) {
7427
7631
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
7428
7632
  }
7429
- /** Build the Claude Code-specific metadata for a tool call. Shell (Bash and
7430
- * PowerShell) descriptions are kept out of ACP's standard `title`, which
7431
- * clients may use as the shell command preview, while still giving clients
7432
- * access to Claude's concise human-readable title. */
7433
- function claudeCodeMetaFromToolUse(toolUse, cwd) {
7434
- const description = (toolUse.name === "Bash" || toolUse.name === "PowerShell") &&
7435
- toolUse.input !== null &&
7436
- typeof toolUse.input === "object" &&
7437
- "description" in toolUse.input &&
7438
- typeof toolUse.input.description === "string"
7439
- ? toolUse.input.description
7440
- : undefined;
7441
- const skillName = toolUse.name === "Skill"
7442
- ? toolUse.input?.skill
7443
- : undefined;
7444
- const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
7445
- return {
7446
- toolName: toolUse.name,
7447
- ...(description ? { title: description } : {}),
7448
- ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
7449
- ...(skillName ? { skill: skillName } : {}),
7450
- ...(skillPath ? { skillPath } : {}),
7451
- };
7452
- }
7453
- /**
7454
- * Marks the Bash `tool_call_update` whose command detached into the background.
7455
- *
7456
- * A backgrounded Bash call returns as soon as the command is handed off, so the
7457
- * card reaches `completed` while the command itself runs on for minutes. ACP has
7458
- * no tool-call status for "still running elsewhere", so this marker is what lets
7459
- * a client render the card as backgrounded work instead of finished work. It
7460
- * rides the update the tool result already emits, so it costs no extra
7461
- * notification and cannot arrive out of order.
7462
- *
7463
- * The command's own lifecycle -- progress, completion, the stop control -- is
7464
- * published separately as an async task; this says only that the card has one.
7465
- * Hence the AIR namespace rather than `claudeCode`: to a client without the
7466
- * `asyncTasks` capability, which is never sent that lifecycle, the marker would
7467
- * promise a card state it has no way to ever resolve.
7468
- */
7469
- function backgroundedBashToolCall(notification, task, asyncTasksSupported) {
7470
- const update = notification.update;
7471
- const toolCallId = task ? nonBlankTaskField(task.toolCallId ?? task.tool_use_id) : undefined;
7472
- if (!asyncTasksSupported ||
7473
- !toolCallId ||
7474
- update.sessionUpdate !== "tool_call_update" ||
7475
- update.toolCallId !== toolCallId) {
7476
- return notification;
7477
- }
7478
- return {
7479
- ...notification,
7480
- update: {
7481
- ...update,
7482
- _meta: withAirMeta(update._meta, AIR_ASYNC_TASKS_CAPABILITY, { backgrounded: true }),
7483
- },
7484
- };
7485
- }
7486
- /** The task fields arrive as `unknown` off the wire; only non-blank strings carry a link. */
7487
- function nonBlankTaskField(value) {
7488
- if (typeof value !== "string")
7489
- return undefined;
7490
- const trimmed = value.trim();
7491
- return trimmed ? trimmed : undefined;
7492
- }
7493
- /** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
7494
- const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
7495
- /**
7496
- * Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
7497
- *
7498
- * The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
7499
- * actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
7500
- * for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
7501
- * directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
7502
- * on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
7503
- */
7504
- function resolveSkillPath(skillName, cwd) {
7505
- if (!cwd) {
7506
- return undefined;
7507
- }
7508
- const colon = skillName.indexOf(":");
7509
- const scope = colon < 0 ? undefined : skillName.slice(0, colon);
7510
- const name = colon < 0 ? skillName : skillName.slice(colon + 1);
7511
- if (!name) {
7512
- return undefined;
7513
- }
7514
- const candidates = [];
7515
- const addCandidates = (base) => {
7516
- for (const container of SKILL_CONTAINER_DIRS) {
7517
- candidates.push(path.join(base, container, name, "SKILL.md"));
7518
- }
7519
- };
7520
- if (scope) {
7521
- // A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
7522
- addCandidates(path.join(cwd, scope));
7523
- candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
7524
- }
7525
- addCandidates(cwd);
7526
- addCandidates(os.homedir());
7527
- return candidates.find((candidate) => existsSync(candidate));
7528
- }
7529
- /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
7530
- * notification for a tool_use. Shared by every site that surfaces a tool call:
7531
- * the streamed tool_use path (first encounter → tool_call, later encounter →
7532
- * refine) and the permission flow (`ensureToolCallEmitted`), so they can't
7533
- * drift. The initial `tool_call` carries `status: "pending"` and, for shell tools,
7534
- * the `terminal_info` _meta that the later `terminal_output`/`terminal_exit`
7535
- * updates key off of, and the programmatic tool `name` (ACP's tool-call-name
7536
- * RFD); a refining `tool_call_update` carries none of these. `name` is set
7537
- * once at first report — on a v1 update, omitting it means "unchanged", and
7538
- * the tool behind a `toolCallId` never changes. */
7539
- function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
7540
- if (refine) {
7541
- return {
7542
- _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
7543
- toolCallId: toolUse.id,
7544
- sessionUpdate: "tool_call_update",
7545
- rawInput,
7546
- ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
7547
- };
7548
- }
7549
- return {
7550
- _meta: {
7551
- claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
7552
- ...((toolUse.name === "Bash" || toolUse.name === "PowerShell") && supportsTerminalOutput
7553
- ? { terminal_info: { terminal_id: toolUse.id } }
7554
- : {}),
7555
- },
7556
- toolCallId: toolUse.id,
7557
- sessionUpdate: "tool_call",
7558
- name: toolUse.name,
7559
- rawInput,
7560
- status: "pending",
7561
- ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
7562
- };
7563
- }
7564
- /** Refine a pending tool call from the complete top-level fields recovered
7565
- * from its still-streaming input. Shares `toolInfoFromToolUse` with the
7566
- * consolidated path but never carries `content`: content built from partial
7567
- * input is misleading (an Edit missing its `new_string` renders as a pure
7568
- * deletion) or invalid (a Write diff without `content` lacks the required
7569
- * `newText`), and the consolidated message supplies it moments later. */
7570
- function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
7571
- // TodoWrite/Task* never surfaced a tool_call to refine (plan lane).
7572
- if (!shouldEmitToolCall(toolUse.name)) {
7573
- return undefined;
7574
- }
7575
- const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
7576
- return {
7577
- _meta: {
7578
- claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
7579
- },
7580
- toolCallId: toolUse.id,
7581
- sessionUpdate: "tool_call_update",
7582
- rawInput: input,
7583
- title,
7584
- kind,
7585
- ...(locations ? { locations } : {}),
7586
- };
7633
+ /** The tool-call field tracker of a session, created on first use. */
7634
+ function toolCallFieldsOf(session) {
7635
+ return (session.toolCallFields ??= new ToolCallFieldTracker());
7587
7636
  }
7588
7637
  /**
7589
7638
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
@@ -7592,8 +7641,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
7592
7641
  export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
7593
7642
  const taskState = options?.taskState ?? new Map();
7594
7643
  const registerHooks = options?.registerHooks !== false;
7595
- const supportsTerminalOutputDelta = options?.clientCapabilities?._meta?.["terminal_output_delta"] === true;
7596
- const supportsTerminalOutput = supportsTerminalOutputDelta || options?.clientCapabilities?._meta?.["terminal_output"] === true;
7644
+ const renderer = AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
7597
7645
  if (typeof content === "string") {
7598
7646
  if (content.length === 0) {
7599
7647
  return [];
@@ -7606,15 +7654,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7606
7654
  },
7607
7655
  };
7608
7656
  applyMessageId(update, options?.messageId);
7609
- if (options?.parentToolUseId) {
7610
- update._meta = {
7611
- ...update._meta,
7612
- claudeCode: {
7613
- ...(update._meta?.claudeCode || {}),
7614
- parentToolUseId: options.parentToolUseId,
7615
- },
7616
- };
7617
- }
7657
+ if (options?.parentToolUseId)
7658
+ stampParentToolUseId(update, options.parentToolUseId);
7618
7659
  return [{ sessionId, update }];
7619
7660
  }
7620
7661
  // `tool_use_result` is message-level and carries no tool_use_id of its own:
@@ -7633,6 +7674,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7633
7674
  // Only handle the first chunk for streaming; extend as needed for batching
7634
7675
  for (const chunk of content) {
7635
7676
  let update = null;
7677
+ // The tool call that this chunk finishes, if it is a tool result.
7678
+ let finishedToolCallId;
7636
7679
  switch (chunk.type) {
7637
7680
  case "text":
7638
7681
  case "text_delta": {
@@ -7701,38 +7744,27 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7701
7744
  // closing over the name keeps the diff working without depending on
7702
7745
  // (or pinning) the cache entry's lifetime.
7703
7746
  const toolName = chunk.name;
7747
+ const hookToolCallId = chunk.id;
7704
7748
  registerHookCallback(chunk.id, {
7705
- onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
7706
- // Both `Edit` and `Write` produce a structuredPatch in their
7707
- // PostToolUse tool_response. For Edit the diff replaces the
7708
- // optimistic content built at tool_use time. For Write the
7709
- // optimistic content (built from `input.content` alone with
7710
- // `oldText: null`) shows "creation" semantics regardless of
7711
- // whether the file existed; the structuredPatch from the
7712
- // hook lets us emit the real diff for `type: "update"`. The
7713
- // helper returns `{}` if the response shape isn't usable.
7714
- const editDiff = toolName === "Edit" || toolName === "Write"
7715
- ? toolUpdateFromDiffToolResponse(toolResponse)
7716
- : {};
7717
- const update = {
7718
- _meta: {
7719
- claudeCode: {
7720
- toolResponse,
7721
- toolName,
7722
- ...(options?.parentToolUseId
7723
- ? { parentToolUseId: options.parentToolUseId }
7724
- : {}),
7725
- },
7726
- },
7727
- toolCallId: toolUseId,
7728
- sessionUpdate: "tool_call_update",
7729
- ...editDiff,
7730
- };
7731
- await client.sessionUpdate({
7732
- sessionId,
7733
- update,
7734
- });
7749
+ onPostToolUseHook: async (toolUseId, _toolInput, toolResponse) => {
7750
+ // The final diff of an Edit or a Write replaces the
7751
+ // optimistic content built from the input. Only the marker
7752
+ // fields of the tool_response travel: the rest repeats
7753
+ // output that the content already carries.
7754
+ const update = await renderer.hookResult({ id: toolUseId, name: toolName }, toolResponse, options?.cwd);
7755
+ if (!update)
7756
+ return;
7757
+ if (options?.parentToolUseId) {
7758
+ stampParentToolUseId(update, options.parentToolUseId);
7759
+ }
7760
+ // The final result may replace a pinned approval patch.
7761
+ if (options?.toolCallFields &&
7762
+ !options.toolCallFields.apply(update, { replacePinnedContent: true })) {
7763
+ return;
7764
+ }
7765
+ await client.sessionUpdate({ sessionId, update });
7735
7766
  },
7767
+ onRelease: () => options?.toolCallFields?.finishHook(hookToolCallId),
7736
7768
  }, sessionId);
7737
7769
  }
7738
7770
  let rawInput;
@@ -7742,6 +7774,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7742
7774
  catch {
7743
7775
  // ignore if we can't turn it to JSON
7744
7776
  }
7777
+ const toolUse = { id: chunk.id, name: chunk.name, input: rawInput };
7745
7778
  // Emit a `tool_call` only the first time this id surfaces to the
7746
7779
  // client; afterwards refine it with a `tool_call_update`. The first
7747
7780
  // surface may be this stream chunk OR an earlier permission request
@@ -7751,17 +7784,20 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7751
7784
  const emittedToolCalls = options?.emittedToolCalls;
7752
7785
  const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
7753
7786
  emittedToolCalls?.add(chunk.id);
7754
- if (alreadyEmitted) {
7755
- // Already surfaced (full assistant message after streaming, or a
7756
- // permission request emitted it first) — refine with a
7757
- // tool_call_update rather than emitting a duplicate tool_call.
7758
- update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd, true);
7759
- }
7760
- else {
7761
- // First surface (streaming content_block_start or replay) — send as
7762
- // tool_call (with terminal_info for Bash).
7763
- update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd);
7764
- }
7787
+ // A permission request surfaced the tool call with its complete
7788
+ // input. The empty input at the stream start has nothing to add.
7789
+ if (alreadyEmitted && options?.inputComplete === false)
7790
+ break;
7791
+ update = alreadyEmitted
7792
+ ? // Already surfaced (full assistant message after streaming, or a
7793
+ // permission request emitted it first): refine it with the
7794
+ // complete input.
7795
+ renderer.refinement(toolUse, options?.cwd)
7796
+ : // First surface (streaming content_block_start or replay).
7797
+ renderer.toolCall(toolUse, {
7798
+ cwd: options?.cwd,
7799
+ inputComplete: options?.inputComplete,
7800
+ });
7765
7801
  }
7766
7802
  break;
7767
7803
  }
@@ -7776,6 +7812,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7776
7812
  const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
7777
7813
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
7778
7814
  completeHookCallback(chunk.tool_use_id);
7815
+ finishedToolCallId = chunk.tool_use_id;
7779
7816
  // Why this is_error result carries harness prose instead of tool
7780
7817
  // output (user-rejected / interrupted / …), when the SDK said so.
7781
7818
  // Spread into the claudeCode meta of every update emitted below; the
@@ -7869,58 +7906,27 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7869
7906
  }
7870
7907
  }
7871
7908
  }
7872
- if (shouldEmitTaskPlan) {
7873
- update = {
7874
- sessionUpdate: "plan",
7875
- entries: taskStateToPlanEntries(taskState),
7876
- };
7877
- }
7909
+ const entries = shouldEmitTaskPlan
7910
+ ? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
7911
+ : undefined;
7912
+ if (entries)
7913
+ update = { sessionUpdate: "plan", entries };
7878
7914
  }
7879
7915
  else if (toolUse.name !== "TodoWrite") {
7880
- const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, toolUseResult, supportsTerminalOutputDelta);
7881
- const terminalOutput = toolMeta?.terminal_output_delta ?? toolMeta?.terminal_output;
7882
- const terminalOutputKey = toolMeta?.terminal_output_delta
7883
- ? "terminal_output_delta"
7884
- : "terminal_output";
7885
- // When terminal output is supported, send its payload as a
7886
- // separate notification to match codex-acp's streaming lifecycle:
7887
- // 1. tool_call → _meta.terminal_info (already sent above)
7888
- // 2. tool_call_update → terminal output (sent here)
7889
- // 3. tool_call_update → _meta.terminal_exit (sent below with status)
7890
- if (terminalOutput) {
7891
- output.push({
7892
- sessionId,
7893
- update: {
7894
- _meta: {
7895
- [terminalOutputKey]: terminalOutput,
7896
- ...(options?.parentToolUseId
7897
- ? { claudeCode: { parentToolUseId: options.parentToolUseId } }
7898
- : {}),
7899
- },
7900
- toolCallId: chunk.tool_use_id,
7901
- sessionUpdate: "tool_call_update",
7902
- },
7903
- });
7916
+ // A command sends its output first, then the exit and the status.
7917
+ const [finalUpdate, ...rest] = renderer
7918
+ .result(toolUse, chunk, {
7919
+ structured: toolUseResult,
7920
+ nonExecution: nonExecution,
7921
+ })
7922
+ .reverse();
7923
+ for (const outputUpdate of rest.reverse()) {
7924
+ if (options?.parentToolUseId) {
7925
+ stampParentToolUseId(outputUpdate, options.parentToolUseId);
7926
+ }
7927
+ output.push({ sessionId, update: outputUpdate });
7904
7928
  }
7905
- update = {
7906
- _meta: {
7907
- claudeCode: {
7908
- toolName: toolUse.name,
7909
- ...(nonExecution ?? {}),
7910
- },
7911
- ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
7912
- },
7913
- toolCallId: chunk.tool_use_id,
7914
- sessionUpdate: "tool_call_update",
7915
- status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
7916
- // The terminal output already carried the exact bytes in the preceding
7917
- // update. Repeating them as rawOutput wastes bandwidth and lets a
7918
- // client accidentally render the same output twice.
7919
- ...(terminalOutput
7920
- ? {}
7921
- : { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
7922
- ...toolUpdate,
7923
- };
7929
+ update = finalUpdate;
7924
7930
  }
7925
7931
  // The tool_use is fully resolved now — drop it so a long session doesn't
7926
7932
  // retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
@@ -7946,17 +7952,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7946
7952
  break;
7947
7953
  }
7948
7954
  if (update) {
7949
- if (options?.parentToolUseId) {
7950
- update._meta = {
7951
- ...update._meta,
7952
- claudeCode: {
7953
- ...(update._meta?.claudeCode || {}),
7954
- parentToolUseId: options.parentToolUseId,
7955
- },
7956
- };
7957
- }
7955
+ if (options?.parentToolUseId)
7956
+ stampParentToolUseId(update, options.parentToolUseId);
7958
7957
  applyMessageId(update, options?.messageId);
7959
- output.push({ sessionId, update });
7958
+ // A tool result is final, so it may replace a pinned approval patch,
7959
+ // for example with the error text of a rejected Edit.
7960
+ if (!options?.toolCallFields ||
7961
+ options.toolCallFields.apply(update, {
7962
+ replacePinnedContent: finishedToolCallId !== undefined,
7963
+ })) {
7964
+ output.push({ sessionId, update });
7965
+ }
7966
+ }
7967
+ if (finishedToolCallId !== undefined) {
7968
+ // The PostToolUse hook can still send the final diff, so the fields
7969
+ // stay tracked until its callback leaves the registry.
7970
+ options?.toolCallFields?.finishResult(finishedToolCallId, hasHookCallback(finishedToolCallId));
7960
7971
  }
7961
7972
  }
7962
7973
  return output;
@@ -7971,6 +7982,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
7971
7982
  cwd: options?.cwd,
7972
7983
  taskState: options?.taskState,
7973
7984
  emittedToolCalls: options?.emittedToolCalls,
7985
+ toolCallFields: options?.toolCallFields,
7974
7986
  messageId: options?.messageId,
7975
7987
  };
7976
7988
  switch (event.type) {
@@ -7989,7 +8001,6 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
7989
8001
  id: block.id,
7990
8002
  name: block.name,
7991
8003
  partialJson: "",
7992
- scannedTo: 0,
7993
8004
  inString: false,
7994
8005
  escaped: false,
7995
8006
  objectDepth: 0,
@@ -7998,15 +8009,19 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
7998
8009
  emittedThroughComma: -1,
7999
8010
  });
8000
8011
  }
8001
- return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
8012
+ // The input of a streamed tool_use starts empty and streams after this
8013
+ // event, so the tool_call waits for the complete input.
8014
+ return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, {
8015
+ ...forwardedOptions,
8016
+ inputComplete: false,
8017
+ });
8002
8018
  }
8003
8019
  case "content_block_delta": {
8004
8020
  if (event.delta.type === "input_json_delta") {
8005
8021
  const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
8006
8022
  if (!streamedInput)
8007
8023
  return [];
8008
- streamedInput.partialJson += event.delta.partial_json;
8009
- if (scanStreamedToolInput(streamedInput)) {
8024
+ if (scanStreamedToolInput(streamedInput, event.delta.partial_json)) {
8010
8025
  // Input complete: the consolidated assistant message replays the
8011
8026
  // block with its full input and refines the call there; emitting
8012
8027
  // here too would send a duplicate identical update.
@@ -8023,20 +8038,17 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8023
8038
  const input = recoveredToolInput(streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma));
8024
8039
  if (!input)
8025
8040
  return [];
8026
- const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
8027
- const update = streamedInputRefinement(streamedInput, input, supportsTerminalOutput, options?.cwd);
8028
- if (!update)
8041
+ // TodoWrite and the Task* tools never surfaced a tool_call to refine.
8042
+ if (!shouldEmitToolCall(streamedInput.name))
8029
8043
  return [];
8030
- if (message.parent_tool_use_id) {
8031
- update._meta = {
8032
- ...update._meta,
8033
- claudeCode: {
8034
- ...(update._meta?.claudeCode || {}),
8035
- parentToolUseId: message.parent_tool_use_id,
8036
- },
8037
- };
8038
- }
8044
+ const update = AcpToolCallRenderer.for(options?.clientCapabilities).partialRefinement(streamedInput, input, options?.cwd);
8045
+ if (message.parent_tool_use_id)
8046
+ stampParentToolUseId(update, message.parent_tool_use_id);
8039
8047
  applyMessageId(update, options?.messageId);
8048
+ // A refinement resends only what changed: rawInput grows with every
8049
+ // field, and title, kind, and locations usually stay the same.
8050
+ if (options?.toolCallFields && !options.toolCallFields.apply(update))
8051
+ return [];
8040
8052
  return [{ sessionId, update }];
8041
8053
  }
8042
8054
  return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);