@agentclientprotocol/claude-agent-acp 0.81.2 → 0.81.3-preview.2

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 (99) hide show
  1. package/README.md +8 -5
  2. package/dist/acp-agent.d.ts +32 -40
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +557 -577
  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/hide-claude-auth.d.ts +2 -6
  26. package/dist/hide-claude-auth.d.ts.map +1 -1
  27. package/dist/hide-claude-auth.js +3 -21
  28. package/dist/native-subagents.d.ts +58 -0
  29. package/dist/native-subagents.d.ts.map +1 -1
  30. package/dist/native-subagents.js +186 -34
  31. package/dist/paths.d.ts +3 -0
  32. package/dist/paths.d.ts.map +1 -0
  33. package/dist/paths.js +6 -0
  34. package/dist/permissions/presentation.d.ts +13 -2
  35. package/dist/permissions/presentation.d.ts.map +1 -1
  36. package/dist/permissions/presentation.js +49 -44
  37. package/dist/resumed-session.d.ts +11 -0
  38. package/dist/resumed-session.d.ts.map +1 -1
  39. package/dist/resumed-session.js +106 -4
  40. package/dist/session-failure-extension.js +1 -1
  41. package/dist/session-mode.d.ts +2 -0
  42. package/dist/session-mode.d.ts.map +1 -1
  43. package/dist/session-mode.js +12 -5
  44. package/dist/settings.js +2 -2
  45. package/dist/subagent-history.d.ts +20 -0
  46. package/dist/subagent-history.d.ts.map +1 -0
  47. package/dist/subagent-history.js +46 -0
  48. package/dist/tool-calls/background.d.ts +20 -0
  49. package/dist/tool-calls/background.d.ts.map +1 -0
  50. package/dist/tool-calls/background.js +41 -0
  51. package/dist/tool-calls/changed-meta-filter.d.ts +27 -0
  52. package/dist/tool-calls/changed-meta-filter.d.ts.map +1 -0
  53. package/dist/tool-calls/changed-meta-filter.js +102 -0
  54. package/dist/tool-calls/client-capabilities.d.ts +51 -0
  55. package/dist/tool-calls/client-capabilities.d.ts.map +1 -0
  56. package/dist/tool-calls/client-capabilities.js +43 -0
  57. package/dist/tool-calls/content.d.ts +59 -0
  58. package/dist/tool-calls/content.d.ts.map +1 -0
  59. package/dist/tool-calls/content.js +194 -0
  60. package/dist/tool-calls/facts.d.ts +116 -0
  61. package/dist/tool-calls/facts.d.ts.map +1 -0
  62. package/dist/tool-calls/facts.js +1 -0
  63. package/dist/tool-calls/field-tracker.d.ts +58 -0
  64. package/dist/tool-calls/field-tracker.d.ts.map +1 -0
  65. package/dist/tool-calls/field-tracker.js +167 -0
  66. package/dist/tool-calls/renderer.d.ts +216 -0
  67. package/dist/tool-calls/renderer.d.ts.map +1 -0
  68. package/dist/tool-calls/renderer.js +487 -0
  69. package/dist/tool-calls/reporters/agent.d.ts +7 -0
  70. package/dist/tool-calls/reporters/agent.d.ts.map +1 -0
  71. package/dist/tool-calls/reporters/agent.js +190 -0
  72. package/dist/tool-calls/reporters/bash.d.ts +9 -0
  73. package/dist/tool-calls/reporters/bash.d.ts.map +1 -0
  74. package/dist/tool-calls/reporters/bash.js +83 -0
  75. package/dist/tool-calls/reporters/compaction.d.ts +24 -0
  76. package/dist/tool-calls/reporters/compaction.d.ts.map +1 -0
  77. package/dist/tool-calls/reporters/compaction.js +66 -0
  78. package/dist/tool-calls/reporters/file-edit.d.ts +46 -0
  79. package/dist/tool-calls/reporters/file-edit.d.ts.map +1 -0
  80. package/dist/tool-calls/reporters/file-edit.js +180 -0
  81. package/dist/tool-calls/reporters/index.d.ts +4 -0
  82. package/dist/tool-calls/reporters/index.d.ts.map +1 -0
  83. package/dist/tool-calls/reporters/index.js +40 -0
  84. package/dist/tool-calls/reporters/interaction.d.ts +71 -0
  85. package/dist/tool-calls/reporters/interaction.d.ts.map +1 -0
  86. package/dist/tool-calls/reporters/interaction.js +222 -0
  87. package/dist/tool-calls/reporters/read.d.ts +7 -0
  88. package/dist/tool-calls/reporters/read.d.ts.map +1 -0
  89. package/dist/tool-calls/reporters/read.js +64 -0
  90. package/dist/tool-calls/reporters/search.d.ts +10 -0
  91. package/dist/tool-calls/reporters/search.d.ts.map +1 -0
  92. package/dist/tool-calls/reporters/search.js +50 -0
  93. package/dist/tool-calls/reporters/web.d.ts +11 -0
  94. package/dist/tool-calls/reporters/web.d.ts.map +1 -0
  95. package/dist/tool-calls/reporters/web.js +42 -0
  96. package/dist/tools.d.ts +32 -37
  97. package/dist/tools.d.ts.map +1 -1
  98. package/dist/tools.js +48 -970
  99. 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";
@@ -26,14 +25,19 @@ import { buildClaudePermissionPresentation } from "./permissions/presentation.js
26
25
  import { decodeClaudePermissionResponse } from "./permissions/response.js";
27
26
  import { SettingsManager } from "./settings.js";
28
27
  import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
29
- import { billsClaudeSubscription, CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE, CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON, claudeLoginRequiredError, claudeSubscriptionNotSupportedError, holdsNonSubscriptionCredential, refuseClaudeSubscriptionTurn, shouldHideClaudeAuth, warnClaudeSubscriptionGuardDegraded, } from "./hide-claude-auth.js";
28
+ import { billsClaudeSubscription, claudeLoginRequiredError, claudeSubscriptionNotSupportedError, holdsNonSubscriptionCredential, refuseClaudeSubscriptionTurn, shouldHideClaudeAuth, warnClaudeSubscriptionGuardDegraded, } from "./hide-claude-auth.js";
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
@@ -1791,14 +1962,6 @@ export class ClaudeAcpAgent {
1791
1962
  // The guard reads the account anyway; reuse that read to keep the
1792
1963
  // reported identity current, refusal or not.
1793
1964
  onAccount: (account) => this.publishSessionAccountIdentity(account),
1794
- sessionFailures: () => new SessionFailureController({
1795
- sessionId,
1796
- state: session.sessionFailureState,
1797
- capabilities: this.clientCapabilities,
1798
- isCurrent: () => this.sessions[sessionId] === session,
1799
- sendUpdate: (notification) => this.client.sessionUpdate(notification),
1800
- logger: this.logger,
1801
- }),
1802
1965
  });
1803
1966
  }
1804
1967
  async goal(params) {
@@ -1819,21 +1982,24 @@ export class ClaudeAcpAgent {
1819
1982
  if (session) {
1820
1983
  session.lastPublishedGoal = goal;
1821
1984
  }
1985
+ // The goal is an AIR extension: only AIR gets it.
1986
+ if (!this.toolCallCapabilities.air.client)
1987
+ return;
1822
1988
  await this.client.sessionUpdate({
1823
1989
  sessionId,
1824
1990
  update: {
1825
1991
  sessionUpdate: "session_info_update",
1826
- _meta: { goal },
1992
+ _meta: withAirMeta(undefined, AIR_GOAL_KEY, goal),
1827
1993
  },
1828
1994
  });
1829
1995
  }
1830
1996
  async publishTaskPlan(sessionId, taskState) {
1997
+ const entries = changedTaskPlanEntries(taskState, this.toolCallCapabilities.air.client);
1998
+ if (!entries)
1999
+ return;
1831
2000
  await this.client.sessionUpdate({
1832
2001
  sessionId,
1833
- update: {
1834
- sessionUpdate: "plan",
1835
- entries: taskStateToPlanEntries(taskState),
1836
- },
2002
+ update: { sessionUpdate: "plan", entries },
1837
2003
  });
1838
2004
  }
1839
2005
  async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
@@ -2033,7 +2199,28 @@ export class ClaudeAcpAgent {
2033
2199
  // on content rather than the Anthropic message id makes dedupe robust to
2034
2200
  // gateways that don't carry a stable/matching id across the stream and the
2035
2201
  // consolidated message. Reset after each consolidated message consumes it.
2036
- 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));
2037
2224
  // Tool-use blocks start streaming before their JSON input. Keep the
2038
2225
  // partial input per parent message and block index so completed top-level
2039
2226
  // fields can refine the pending tool call while it streams. Entries are
@@ -2062,9 +2249,15 @@ export class ClaudeAcpAgent {
2062
2249
  * `tool_use_id` so hosts can collapse them; a notice has no lifecycle to
2063
2250
  * update, so only the first becomes one. */
2064
2251
  const noticedToolUses = new Set();
2065
- 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
+ }));
2066
2258
  const compaction = new ContextCompactionLifecycle((notification) => sendUpdate(notification), {
2067
2259
  sessionId: params.sessionId,
2260
+ airClient: this.toolCallCapabilities.air.client,
2068
2261
  presentation: clientSupportsCompactionUpdates(this.clientCapabilities)
2069
2262
  ? "compaction_update"
2070
2263
  : "tool_call",
@@ -2188,12 +2381,12 @@ export class ClaudeAcpAgent {
2188
2381
  lastAssistantWasUsageLimit = false;
2189
2382
  lastAssistantFailureTitle = undefined;
2190
2383
  lastRefusalExplanation = null;
2191
- // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
2384
+ // Do NOT reset currentStreamMessageId or the streamed blocks here. Turn
2192
2385
  // activation can fire mid-message (the replayed user echo with
2193
2386
  // --replay-user-messages lands between a message's blocks); clearing the
2194
2387
  // streamed-content record on activation would drop the blocks that
2195
2388
  // streamed before the echo, so the consolidated assistant message would
2196
- // 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
2197
2390
  // cleared when each consolidated message consumes it. #785 stopped
2198
2391
  // resetting the streamed-content tracking here but left this line.
2199
2392
  stopReason = "end_turn";
@@ -2241,7 +2434,7 @@ export class ClaudeAcpAgent {
2241
2434
  continue;
2242
2435
  }
2243
2436
  if (record.endedPerLevel === "sweep-armed") {
2244
- session.liveBackgroundTasks.delete(taskId);
2437
+ settleLiveBackgroundTask(session, taskId);
2245
2438
  }
2246
2439
  else {
2247
2440
  record.endedPerLevel = "sweep-armed";
@@ -2431,6 +2624,28 @@ export class ClaudeAcpAgent {
2431
2624
  turn.usageOriginalOutput = originalOutput;
2432
2625
  return markdown;
2433
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
+ };
2434
2649
  /** Whether any background subagent this turn spawned is still live —
2435
2650
  * while true, the turn's settlement stays deferred so the subagent's
2436
2651
  * output and permission requests land inside it (see
@@ -2561,34 +2776,16 @@ export class ClaudeAcpAgent {
2561
2776
  * signal that starts the client's own auth flow (AIR parks the refused
2562
2777
  * prompt, shows its method chooser, and resumes the prompt after sign-in),
2563
2778
  * so replacing it with a successful `end_turn` would disable that flow.
2564
- * Every client keeps the rejection; capable clients additionally receive
2565
- * the signed-out *state* as one session-scoped failure whose `login`
2566
- * action remains the way back in after the client's auth prompt is
2567
- * dismissed. Its title stays the policy's client-neutral fallback — the
2568
- * CLI's own text ("… Please run /login") is TUI advice, meaningless in an
2569
- * ACP client, so it travels as expandable details instead. */
2779
+ * Every client keeps the rejection. The client owns the login UI. */
2570
2780
  const failActiveWithSessionFailure = async (kind, error, title) => {
2571
2781
  if (session.activeTurn && !session.activeTurn.settled) {
2572
2782
  await compaction.interrupt();
2573
2783
  }
2574
2784
  if (kind === "auth_required") {
2575
- // One sign-out arrives twice — the synthetic login assistant message
2576
- // and the turn's error-shaped result repeat the same text — and the
2577
- // signed-out state does not change in between: publish once, and skip
2578
- // republishing while the previous sign-out is still active. A retry
2579
- // warning of the same kind is not a sign-out and must not suppress it.
2580
- if (!sessionFailures.hasActiveSessionError(kind)) {
2581
- await publishSessionFailure(kind, { turnScoped: false, details: title });
2582
- }
2583
- // Preserve legacy codes: only explicit `/login` signals trigger ACP's auth flow.
2584
- // The first delivery rejects the turn, so the second one finds it settled.
2585
- // That is the normal course here, not the lost-failure case `failActive`
2586
- // logs an error for.
2785
+ // The auth error starts the client's login flow. Do not send an access
2786
+ // failure as a second notification or render login text in the chat.
2587
2787
  if (session.activeTurn && !session.activeTurn.settled)
2588
- failActive(error);
2589
- // The row is published and the turn is rejected. Under
2590
- // `--hide-claude-auth` the account cached at `initialize` is now
2591
- // wrong, so end the query and let the next turn recreate it.
2788
+ failActive(RequestError.authRequired());
2592
2789
  this.markSessionForSignOutRespawn(params.sessionId, session);
2593
2790
  return;
2594
2791
  }
@@ -2907,7 +3104,7 @@ export class ClaudeAcpAgent {
2907
3104
  // A fresh `system`/init (e.g. after reinitialize) can carry an
2908
3105
  // updated Fast mode state; reconcile it with what we seeded at
2909
3106
  // session creation.
2910
- 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);
2911
3108
  // Terminal-bound slash commands (absent when none, and on
2912
3109
  // older CLIs). The session/new advertisement runs before any
2913
3110
  // init frame can be observed, so the first latch (or a
@@ -2917,7 +3114,7 @@ export class ClaudeAcpAgent {
2917
3114
  JSON.stringify(session.terminalSlashCommands)) {
2918
3115
  session.terminalSlashCommands = message.terminal_slash_commands;
2919
3116
  try {
2920
- await this.sendAvailableCommandsUpdate(message.session_id);
3117
+ await this.sendAvailableCommandsUpdate(params.sessionId);
2921
3118
  }
2922
3119
  catch (error) {
2923
3120
  // Advisory reconcile only — the client keeps its current
@@ -2963,7 +3160,7 @@ export class ClaudeAcpAgent {
2963
3160
  lastAssistantTotalUsage = usedTokens;
2964
3161
  session.contextUsedTokens = usedTokens;
2965
3162
  await sendUpdate({
2966
- sessionId: message.session_id,
3163
+ sessionId: params.sessionId,
2967
3164
  update: attachUsageModel({
2968
3165
  sessionUpdate: "usage_update",
2969
3166
  used: lastAssistantTotalUsage,
@@ -2983,7 +3180,7 @@ export class ClaudeAcpAgent {
2983
3180
  if (usageMarkdown === null)
2984
3181
  break;
2985
3182
  await sendUpdate({
2986
- sessionId: message.session_id,
3183
+ sessionId: params.sessionId,
2987
3184
  update: {
2988
3185
  sessionUpdate: "agent_message_chunk",
2989
3186
  content: { type: "text", text: usageMarkdown ?? message.content },
@@ -3132,39 +3329,9 @@ export class ClaudeAcpAgent {
3132
3329
  break;
3133
3330
  }
3134
3331
  case "memory_recall": {
3135
- const isSynthesis = message.mode === "synthesize";
3136
- const locations = isSynthesis
3137
- ? []
3138
- : message.memories.map((m) => ({ path: m.path }));
3139
- const content = isSynthesis
3140
- ? message.memories
3141
- .filter((m) => typeof m.content === "string")
3142
- .map((m) => ({
3143
- type: "content",
3144
- content: { type: "text", text: m.content },
3145
- }))
3146
- : [];
3147
- const count = message.memories.length;
3148
- const title = isSynthesis
3149
- ? "Recalled synthesized memory"
3150
- : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
3151
3332
  await sendUpdate({
3152
- sessionId: message.session_id,
3153
- update: {
3154
- sessionUpdate: "tool_call",
3155
- toolCallId: message.uuid,
3156
- title,
3157
- kind: "read",
3158
- status: "completed",
3159
- ...(locations.length > 0 && { locations }),
3160
- ...(content.length > 0 && { content }),
3161
- _meta: {
3162
- claudeCode: {
3163
- toolName: "memory_recall",
3164
- toolResponse: { mode: message.mode },
3165
- },
3166
- },
3167
- },
3333
+ sessionId: params.sessionId,
3334
+ update: new AcpToolCallRenderer(this.toolCallCapabilities).memoryRecall(message),
3168
3335
  });
3169
3336
  break;
3170
3337
  }
@@ -3176,7 +3343,7 @@ export class ClaudeAcpAgent {
3176
3343
  // it's authoritative, and re-querying supportedCommands()
3177
3344
  // would just return the same list with an extra round-trip.
3178
3345
  await sendUpdate({
3179
- sessionId: message.session_id,
3346
+ sessionId: params.sessionId,
3180
3347
  update: {
3181
3348
  sessionUpdate: "available_commands_update",
3182
3349
  availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
@@ -3231,32 +3398,18 @@ export class ClaudeAcpAgent {
3231
3398
  // child tool call was not announced there.
3232
3399
  break;
3233
3400
  }
3234
- const reason = message.decision_reason ?? message.message;
3235
- await sendUpdate({
3236
- sessionId: message.session_id,
3237
- update: {
3238
- sessionUpdate: "tool_call_update",
3239
- toolCallId: message.tool_use_id,
3240
- status: "failed",
3241
- content: [
3242
- {
3243
- type: "content",
3244
- content: { type: "text", text: `Permission denied: ${reason}` },
3245
- },
3246
- ],
3247
- _meta: {
3248
- claudeCode: {
3249
- toolName: message.tool_name,
3250
- ...(parentToolUseId ? { parentToolUseId } : {}),
3251
- toolResponse: {
3252
- decisionReasonType: message.decision_reason_type,
3253
- decisionReason: message.decision_reason,
3254
- message: message.message,
3255
- },
3256
- },
3257
- },
3258
- },
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,
3259
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
+ }
3260
3413
  break;
3261
3414
  }
3262
3415
  case "informational": {
@@ -3291,7 +3444,7 @@ export class ClaudeAcpAgent {
3291
3444
  ? message.content
3292
3445
  : `**${sentenceCase(message.level)}:** ${message.content}`;
3293
3446
  await sendUpdate({
3294
- sessionId: message.session_id,
3447
+ sessionId: params.sessionId,
3295
3448
  update: noticeOrTranscriptUpdate({
3296
3449
  severity,
3297
3450
  ...splitNoticeText(message.content, severity === "warning"
@@ -3317,6 +3470,7 @@ export class ClaudeAcpAgent {
3317
3470
  summary: message.summary,
3318
3471
  last_tool_name: message.last_tool_name,
3319
3472
  usage: message.usage,
3473
+ tool_use_id: message.tool_use_id,
3320
3474
  });
3321
3475
  break;
3322
3476
  case "task_started":
@@ -3338,10 +3492,7 @@ export class ClaudeAcpAgent {
3338
3492
  // turn: a turn only ever waits on its own subagents, and a
3339
3493
  // spawn during a held-open drain window (an agent chain)
3340
3494
  // extends that turn's hold.
3341
- session.liveBackgroundTasks.set(message.task_id, {
3342
- parentToolUseId: message.tool_use_id,
3343
- isSubagent: !!message.subagent_type,
3344
- });
3495
+ registerLiveTask(message.task_id, message.tool_use_id, !!message.subagent_type);
3345
3496
  await subagents.taskStarted({
3346
3497
  taskId: message.task_id,
3347
3498
  toolUseId: message.tool_use_id,
@@ -3359,9 +3510,6 @@ export class ClaudeAcpAgent {
3359
3510
  skip_transcript: message.skip_transcript,
3360
3511
  tool_use_id: message.tool_use_id,
3361
3512
  });
3362
- if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
3363
- (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
3364
- }
3365
3513
  break;
3366
3514
  case "task_notification":
3367
3515
  // The task settled — no further tool calls can originate
@@ -3372,10 +3520,14 @@ export class ClaudeAcpAgent {
3372
3520
  status: message.status,
3373
3521
  summary: message.summary,
3374
3522
  output_file: message.output_file,
3523
+ tool_use_id: message.tool_use_id,
3375
3524
  });
3376
- if (message.tool_use_id)
3525
+ if (message.tool_use_id) {
3377
3526
  subagents.discardPending(message.tool_use_id);
3378
- 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);
3379
3531
  break;
3380
3532
  case "task_updated":
3381
3533
  await asyncTasks.taskUpdated(message.task_id, message.patch);
@@ -3388,7 +3540,17 @@ export class ClaudeAcpAgent {
3388
3540
  message.patch.status === "failed" ||
3389
3541
  message.patch.status === "killed") {
3390
3542
  await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
3391
- 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));
3392
3554
  }
3393
3555
  break;
3394
3556
  case "worker_shutting_down":
@@ -3506,7 +3668,7 @@ export class ClaudeAcpAgent {
3506
3668
  ...(explanation ? { description: explanation } : {}),
3507
3669
  };
3508
3670
  await sendUpdate({
3509
- sessionId: message.session_id,
3671
+ sessionId: params.sessionId,
3510
3672
  update: noticeOrTranscriptUpdate({ severity: "warning", ...notice }, supportsNotices, `**Model fallback:** ${fallbackNotice}`),
3511
3673
  });
3512
3674
  }
@@ -3588,6 +3750,9 @@ export class ClaudeAcpAgent {
3588
3750
  }
3589
3751
  break;
3590
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();
3591
3756
  // A result from an autonomous cycle — a task-notification
3592
3757
  // followup, or a peer/coordinator/observer message the model
3593
3758
  // handled on its own (see AUTONOMOUS_RESULT_ORIGINS) — is not
@@ -3984,9 +4149,7 @@ export class ClaudeAcpAgent {
3984
4149
  // it reports a login the query process itself runs, and a
3985
4150
  // client can sign the user in out of band (AIR runs
3986
4151
  // `claude /login` in a terminal, in a separate process).
3987
- // Without this a stale signed-out record would outlive the
3988
- // sign-out and suppress the next one, which the
3989
- // `auth_required` dedupe below reads.
4152
+ // Clear any older signed-out record after a real answer.
3990
4153
  failure.recoveryPolicy === "auth_status" ||
3991
4154
  (failure.severity === "warning" && failure.turnId === activeTurnId));
3992
4155
  }
@@ -4158,19 +4321,16 @@ export class ClaudeAcpAgent {
4158
4321
  // the top-level record. Fires once, before any of this message's
4159
4322
  // blocks, so it doesn't disturb the mid-message turn-activation
4160
4323
  // path the way resetting on turn activation would.
4161
- if (message.parent_tool_use_id === null) {
4162
- streamedBlocks.length = 0;
4163
- }
4324
+ streamedBlocksOf(message.parent_tool_use_id).length = 0;
4164
4325
  }
4165
4326
  // Accumulate the text/thinking actually streamed live, so the
4166
4327
  // `assistant` case below can diff its assembled blocks against what
4167
4328
  // already reached the client as chunks and forward only the
4168
- // remainder. Gated on `parent_tool_use_id === null` so a subagent
4169
- // 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.
4170
4331
  // Contiguous deltas of the same block (same index and type) extend
4171
4332
  // the current entry; anything else opens a new one.
4172
- if (message.parent_tool_use_id === null &&
4173
- message.event.type === "content_block_delta") {
4333
+ if (message.event.type === "content_block_delta") {
4174
4334
  const delta = message.event.delta;
4175
4335
  const chunk = delta.type === "text_delta"
4176
4336
  ? { type: "text", text: delta.text }
@@ -4184,6 +4344,7 @@ export class ClaudeAcpAgent {
4184
4344
  // re-emitting the next block as a duplicate.
4185
4345
  if (chunk?.text) {
4186
4346
  const index = message.event.index;
4347
+ const streamedBlocks = streamedBlocksOf(message.parent_tool_use_id);
4187
4348
  const last = streamedBlocks[streamedBlocks.length - 1];
4188
4349
  if (last && last.index === index && last.type === chunk.type) {
4189
4350
  last.text += chunk.text;
@@ -4250,9 +4411,19 @@ export class ClaudeAcpAgent {
4250
4411
  cwd: session.cwd,
4251
4412
  taskState: session.taskState,
4252
4413
  emittedToolCalls: session.emittedToolCalls,
4414
+ toolCallFields: toolCallFieldsOf(session),
4253
4415
  messageId: currentStreamMessageId,
4254
4416
  streamedToolInputs,
4255
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
+ }
4256
4427
  // sendUpdate records delivery; a subagent stream's chunks carry
4257
4428
  // the stamped parentToolUseId meta and are excluded there.
4258
4429
  await sendUpdate(notification);
@@ -4264,7 +4435,7 @@ export class ClaudeAcpAgent {
4264
4435
  // Record the ACP messageId -> SDK uuid mapping for this message
4265
4436
  // (including replays). The consolidated message carries both ids, so
4266
4437
  // this is where we learn the uuid the SDK's rewind/resume APIs key on
4267
- // for the id we hand clients. Not read yet (see messageIdToUuid).
4438
+ // for the id we hand clients. Fork reads it (see messageIdToUuid).
4268
4439
  const mappedMessageId = messageIdForGrouping(message);
4269
4440
  if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
4270
4441
  session.messageIdToUuid.set(mappedMessageId, message.uuid);
@@ -4485,73 +4656,22 @@ export class ClaudeAcpAgent {
4485
4656
  break;
4486
4657
  }
4487
4658
  let content;
4488
- if (message.type === "assistant" && message.parent_tool_use_id === null) {
4489
- // Top-level assistant message: each text/thinking block may have
4490
- // already been streamed live as deltas. Diff each against what
4491
- // streamed (`streamedBlocks`, in document order) and forward only
4492
- // the un-streamed remainder — nothing if it streamed in full (the
4493
- // common case), the whole block if it never streamed (a
4494
- // non-streaming gateway), or just the tail if the stream was cut
4495
- // short mid-block. `streamPos` walks the streamed blocks in step
4496
- // with the assembled text/thinking blocks; tool_use and other
4497
- // blocks pass through untouched (their own `toolUseCache` collapses
4498
- // the streamed/assembled pair) without advancing it.
4499
- const blocks = message.message.content;
4500
- const kept = [];
4501
- let streamPos = 0;
4502
- for (const item of blocks) {
4503
- if (item.type !== "text" && item.type !== "thinking") {
4504
- kept.push(item);
4505
- continue;
4506
- }
4507
- const full = item.type === "text" ? item.text : item.thinking;
4508
- // Empty assembled blocks carry nothing (some gateways emit an
4509
- // empty `thinking` block before the real text) — drop them.
4510
- if (full.length === 0) {
4511
- continue;
4512
- }
4513
- // A streamed block of the same type whose accumulated text is a
4514
- // prefix of this one was already (at least partly) delivered as
4515
- // chunks; consume it and forward only what's left. A non-empty
4516
- // streamed text is required so an empty/aborted streamed block
4517
- // doesn't swallow the assembled copy.
4518
- const streamed = streamedBlocks[streamPos];
4519
- if (streamed &&
4520
- streamed.type === item.type &&
4521
- streamed.text.length > 0 &&
4522
- full.startsWith(streamed.text)) {
4523
- streamPos++;
4524
- const remainder = full.slice(streamed.text.length);
4525
- if (remainder.length === 0) {
4526
- continue;
4527
- }
4528
- // Overwrite in place with just the un-streamed tail (the
4529
- // assembled message isn't read again after this) so the block
4530
- // keeps its exact SDK type.
4531
- if (item.type === "text") {
4532
- item.text = remainder;
4533
- }
4534
- else {
4535
- item.thinking = remainder;
4536
- }
4537
- kept.push(item);
4538
- continue;
4539
- }
4540
- // Not matched: never streamed (or the stream diverged from the
4541
- // assembled text) — forward the block in full.
4542
- kept.push(item);
4543
- }
4544
- content = kept;
4545
- // Consumed: reset so the next message's blocks accumulate fresh and
4546
- // the record stays bounded to the in-flight message.
4547
- 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;
4548
4669
  }
4549
- else if (message.type === "assistant" &&
4550
- !(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
4551
- // Legacy clients keep the flattened tool-call representation,
4552
- // but nested text/thinking stays internal unless explicitly
4553
- // 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.
4554
4673
  content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
4674
+ streamedBlocksOf(message.parent_tool_use_id).length = 0;
4555
4675
  }
4556
4676
  else {
4557
4677
  content = message.message.content;
@@ -4562,6 +4682,13 @@ export class ClaudeAcpAgent {
4562
4682
  backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
4563
4683
  if (backgroundBashTask)
4564
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
+ }
4565
4692
  }
4566
4693
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
4567
4694
  clientCapabilities: this.clientCapabilities,
@@ -4569,6 +4696,7 @@ export class ClaudeAcpAgent {
4569
4696
  cwd: session.cwd,
4570
4697
  taskState: session.taskState,
4571
4698
  emittedToolCalls: session.emittedToolCalls,
4699
+ toolCallFields: toolCallFieldsOf(session),
4572
4700
  messageId: messageIdForGrouping(message),
4573
4701
  toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
4574
4702
  // On the wire since CLI 2.1.216 but not in SDKUserMessage's
@@ -4610,42 +4738,29 @@ export class ClaudeAcpAgent {
4610
4738
  ? message.parent_tool_use_id
4611
4739
  : undefined
4612
4740
  : undefined;
4613
- await sendUpdate({
4614
- sessionId: message.session_id,
4615
- update: {
4616
- sessionUpdate: "tool_call_update",
4617
- toolCallId,
4618
- status: "in_progress",
4619
- _meta: {
4620
- claudeCode: {
4621
- toolName: message.tool_name,
4622
- ...(subagentParentToolUseId
4623
- ? { parentToolUseId: subagentParentToolUseId }
4624
- : {}),
4625
- toolResponse: {
4626
- elapsedTimeSeconds: message.elapsed_time_seconds,
4627
- // For Agent/Task calls: the subagent's type, and — when
4628
- // the subagent is waiting out an API rate-limit retry —
4629
- // the SDK's retry counters (attempt, max_retries,
4630
- // retry_delay_ms, …), forwarded verbatim so clients can
4631
- // show why a spawn looks stalled.
4632
- ...(message.subagent_type !== undefined && {
4633
- subagentType: message.subagent_type,
4634
- }),
4635
- ...(message.subagent_retry !== undefined && {
4636
- subagentRetry: message.subagent_retry,
4637
- }),
4638
- },
4639
- },
4640
- },
4641
- },
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,
4642
4754
  });
4755
+ if (toolCallFieldsOf(session).apply(beat)) {
4756
+ await sendUpdate({ sessionId: params.sessionId, update: beat });
4757
+ }
4643
4758
  break;
4644
4759
  }
4645
4760
  case "rate_limit_event": {
4646
4761
  if (lastAssistantTotalUsage !== null) {
4647
4762
  await sendUpdate({
4648
- sessionId: message.session_id,
4763
+ sessionId: params.sessionId,
4649
4764
  update: attachUsageModel({
4650
4765
  sessionUpdate: "usage_update",
4651
4766
  used: lastAssistantTotalUsage,
@@ -4665,6 +4780,7 @@ export class ClaudeAcpAgent {
4665
4780
  subagents.clear();
4666
4781
  asyncTasks.clear();
4667
4782
  session.eagerToolCallSessions?.clear();
4783
+ session.toolCallFields?.clear();
4668
4784
  clearHookCallbacks(params.sessionId);
4669
4785
  session.taskState.clear();
4670
4786
  await this.publishTaskPlan(params.sessionId, session.taskState);
@@ -4726,6 +4842,7 @@ export class ClaudeAcpAgent {
4726
4842
  failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
4727
4843
  this.closeQueryStream(session);
4728
4844
  session.eagerToolCallSessions?.clear();
4845
+ session.toolCallFields?.clear();
4729
4846
  clearHookCallbacks(params.sessionId);
4730
4847
  session.nativeSubagentRuntime?.clear();
4731
4848
  session.asyncTaskRuntime?.clear();
@@ -4781,6 +4898,7 @@ export class ClaudeAcpAgent {
4781
4898
  // this fire-and-forget notification, so there is nothing to do here.
4782
4899
  if (session.queryClosed) {
4783
4900
  session.eagerToolCallSessions?.clear();
4901
+ session.toolCallFields?.clear();
4784
4902
  clearHookCallbacks(params.sessionId);
4785
4903
  return;
4786
4904
  }
@@ -4797,6 +4915,7 @@ export class ClaudeAcpAgent {
4797
4915
  }
4798
4916
  finally {
4799
4917
  session.eagerToolCallSessions?.clear();
4918
+ session.toolCallFields?.clear();
4800
4919
  clearHookCallbacks(params.sessionId);
4801
4920
  }
4802
4921
  // A priority steer may still be queued in the SDK when cancellation
@@ -5094,6 +5213,7 @@ export class ClaudeAcpAgent {
5094
5213
  // appropriate; query.close() above has already torn the subprocess down.
5095
5214
  session.abortController.abort();
5096
5215
  session.eagerToolCallSessions?.clear();
5216
+ session.toolCallFields?.clear();
5097
5217
  clearHookCallbacks(sessionId);
5098
5218
  session.nativeSubagentRuntime?.clear();
5099
5219
  session.asyncTaskRuntime?.clear();
@@ -5211,6 +5331,9 @@ export class ClaudeAcpAgent {
5211
5331
  const historyLoadedAt = performance.now();
5212
5332
  this.logger.log(`[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`);
5213
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);
5214
5337
  const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
5215
5338
  const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
5216
5339
  const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
@@ -5229,8 +5352,10 @@ export class ClaudeAcpAgent {
5229
5352
  const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
5230
5353
  const replayTerminalStates = new Map();
5231
5354
  const replayChildren = new Map();
5232
- if (nativeReplayEnabled) {
5233
- 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) {
5234
5359
  const content = message.message
5235
5360
  ?.content;
5236
5361
  if (!Array.isArray(content))
@@ -5280,7 +5405,9 @@ export class ClaudeAcpAgent {
5280
5405
  if (child)
5281
5406
  child.terminalState = terminalState;
5282
5407
  }
5283
- }
5408
+ };
5409
+ if (nativeReplayEnabled)
5410
+ registerLaunches(messages);
5284
5411
  const announceReplayChild = async (parentToolUseId, ancestors = new Set()) => {
5285
5412
  let child = replayChildren.get(parentToolUseId);
5286
5413
  if (!child) {
@@ -5321,7 +5448,12 @@ export class ClaudeAcpAgent {
5321
5448
  }
5322
5449
  return child.sessionId;
5323
5450
  };
5324
- 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) => {
5325
5457
  if (message.type === "user" &&
5326
5458
  message.parent_tool_use_id === null &&
5327
5459
  typeof message.uuid === "string" &&
@@ -5341,7 +5473,7 @@ export class ClaudeAcpAgent {
5341
5473
  // assistant message into an authRequired error instead of showing its
5342
5474
  // TUI-specific text; skip it on replay too (issue #863).
5343
5475
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
5344
- continue;
5476
+ return;
5345
5477
  }
5346
5478
  // Capable clients saw every synthetic usage-limit message as a typed
5347
5479
  // failure live, so replay it at the same transcript position. The
@@ -5356,7 +5488,7 @@ export class ClaudeAcpAgent {
5356
5488
  if (title) {
5357
5489
  await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
5358
5490
  }
5359
- continue;
5491
+ return;
5360
5492
  }
5361
5493
  // @ts-expect-error - untyped in SDK but we handle all of these
5362
5494
  let content = message.message.content;
@@ -5374,7 +5506,7 @@ export class ClaudeAcpAgent {
5374
5506
  if (message.message.role === "user") {
5375
5507
  content = stripLocalCommandMetadata(content);
5376
5508
  if (content === null)
5377
- continue;
5509
+ return;
5378
5510
  }
5379
5511
  // Claude persists the retained summary as a user message framed with
5380
5512
  // continuation instructions for the model. Clients on the compaction
@@ -5393,10 +5525,14 @@ export class ClaudeAcpAgent {
5393
5525
  parentToolUseId === null &&
5394
5526
  typeof message.uuid === "string" &&
5395
5527
  message.uuid.length > 0) {
5396
- 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
+ });
5397
5533
  if (replayCompaction.recordSummary(assistantMessageText(message.message))) {
5398
5534
  await replayCompaction.finish(message.uuid, "completed");
5399
- continue;
5535
+ return;
5400
5536
  }
5401
5537
  }
5402
5538
  for (const notification of toAcpNotifications(
@@ -5405,6 +5541,7 @@ export class ClaudeAcpAgent {
5405
5541
  // @ts-expect-error - untyped in SDK but we handle all of these
5406
5542
  message.message.role, sessionId, toolUseCache, this.client, this.logger, {
5407
5543
  registerHooks: false,
5544
+ replay: true,
5408
5545
  clientCapabilities: this.clientCapabilities,
5409
5546
  cwd: this.sessions[sessionId]?.cwd,
5410
5547
  taskState: this.sessions[sessionId]?.taskState,
@@ -5420,7 +5557,38 @@ export class ClaudeAcpAgent {
5420
5557
  }
5421
5558
  await this.client.sessionUpdate({ ...notification, sessionId: replayTargetSessionId });
5422
5559
  }
5423
- }
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);
5424
5592
  if (nativeReplayEnabled) {
5425
5593
  // Claude history persists sidechain messages and Agent/Task tool uses,
5426
5594
  // but not task_started/task_updated lifecycle frames. Recover terminal
@@ -5477,7 +5645,7 @@ export class ClaudeAcpAgent {
5477
5645
  * `$/cancel_request`, and our local abort race settles even if the client
5478
5646
  * ignores it. A `cancelled` outcome, request rejection, and local abort all
5479
5647
  * surface the same "Tool use aborted" the callers already expect. */
5480
- async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId = params.sessionId) {
5648
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId, toolInput, previewContent) {
5481
5649
  if (signal.aborted)
5482
5650
  throw new Error("Tool use aborted");
5483
5651
  // The SDK may invoke `canUseTool` (and therefore this permission request)
@@ -5486,7 +5654,7 @@ export class ClaudeAcpAgent {
5486
5654
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
5487
5655
  // later refines it with a `tool_call_update` rather than emitting a
5488
5656
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
5489
- 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);
5490
5658
  if (signal.aborted)
5491
5659
  throw new Error("Tool use aborted");
5492
5660
  // Do not rely on every ACP client settling requestPermission after the
@@ -5514,28 +5682,29 @@ export class ClaudeAcpAgent {
5514
5682
  * are resolved at tool_result time instead (see `toAcpNotifications`).
5515
5683
  * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
5516
5684
  * that spawned it, matching the streamed path's `_meta`. */
5517
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal, notificationSessionId = sessionId) {
5685
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal, notificationSessionId = sessionId, previewContent) {
5518
5686
  const session = this.sessions[sessionId];
5519
5687
  if (!session) {
5520
5688
  return;
5521
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
+ };
5522
5697
  if (session.emittedToolCalls.has(toolCallId)) {
5698
+ pinPreview();
5523
5699
  return;
5524
5700
  }
5525
5701
  session.emittedToolCalls.add(toolCallId);
5526
5702
  (session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
5527
- const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true ||
5528
- this.clientCapabilities?._meta?.["terminal_output_delta"] === true;
5529
- const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
5530
- if (parentToolUseId) {
5531
- update._meta = {
5532
- ...update._meta,
5533
- claudeCode: {
5534
- ...(update._meta?.claudeCode || {}),
5535
- parentToolUseId,
5536
- },
5537
- };
5538
- }
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();
5539
5708
  try {
5540
5709
  const emission = this.client.sessionUpdate({ sessionId: notificationSessionId, update });
5541
5710
  await (signal ? raceWithAbort(emission, signal) : emission);
@@ -5546,13 +5715,12 @@ export class ClaudeAcpAgent {
5546
5715
  // be allowed to publish the tool call instead of refining a phantom one.
5547
5716
  session.emittedToolCalls.delete(toolCallId);
5548
5717
  session.eagerToolCallSessions?.delete(toolCallId);
5718
+ session.toolCallFields?.delete(toolCallId);
5549
5719
  throw error;
5550
5720
  }
5551
5721
  }
5552
5722
  canUseTool(sessionId) {
5553
5723
  return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, defaultToNo, suppressAlwaysAllowRule, mcpServer, }) => {
5554
- const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true ||
5555
- this.clientCapabilities?._meta?.["terminal_output_delta"] === true;
5556
5724
  const session = this.sessions[sessionId];
5557
5725
  if (!session) {
5558
5726
  return {
@@ -5611,12 +5779,17 @@ export class ClaudeAcpAgent {
5611
5779
  // Artifact publishes).
5612
5780
  const noPersistentRule = matchedAskRule !== undefined || suppressAlwaysAllowRule === true;
5613
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;
5614
5786
  const presentation = buildClaudePermissionPresentation({
5615
5787
  toolName,
5616
5788
  input: toolInput,
5617
5789
  toolUseID,
5618
5790
  cwd: session.cwd,
5619
- supportsTerminalOutput,
5791
+ capabilities,
5792
+ previewContent,
5620
5793
  blockedPath,
5621
5794
  title,
5622
5795
  displayName,
@@ -5630,11 +5803,14 @@ export class ClaudeAcpAgent {
5630
5803
  // server; anything else is configuration) instead of parsing the
5631
5804
  // tool-name prefix. The name is the config key as authored — untrusted
5632
5805
  // text, so it rides `_meta` rather than the title.
5633
- 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)) {
5634
5810
  presentation.toolCall._meta = {
5635
5811
  claudeCode: {
5636
- toolName,
5637
- ...(parentToolUseId ? { parentToolUseId } : {}),
5812
+ ...(airClient ? {} : { toolName }),
5813
+ ...(parentToolUseId && !airClient ? { parentToolUseId } : {}),
5638
5814
  ...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}),
5639
5815
  },
5640
5816
  };
@@ -5657,7 +5833,7 @@ export class ClaudeAcpAgent {
5657
5833
  ...presentation,
5658
5834
  options: permissionOptions,
5659
5835
  sessionId: permissionSessionId,
5660
- }, toolName, signal, parentToolUseId, sessionId);
5836
+ }, toolName, signal, parentToolUseId, sessionId, toolInput, previewContent);
5661
5837
  if (signal.aborted)
5662
5838
  throw new Error("Tool use aborted");
5663
5839
  const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
@@ -5748,7 +5924,7 @@ export class ClaudeAcpAgent {
5748
5924
  if (!questions) {
5749
5925
  return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
5750
5926
  }
5751
- const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
5927
+ const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, this.toolCallCapabilities.air.client);
5752
5928
  let response;
5753
5929
  try {
5754
5930
  response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal));
@@ -6125,7 +6301,7 @@ export class ClaudeAcpAgent {
6125
6301
  }
6126
6302
  const resumedModelHint = resumedSession
6127
6303
  ? resumedSession.model
6128
- : (await readResumedSession(params.sessionId, this.logger)).model;
6304
+ : await readResumedModel(params.sessionId, this.logger);
6129
6305
  const response = await this.createSession({
6130
6306
  cwd: params.cwd,
6131
6307
  mcpServers: params.mcpServers ?? [],
@@ -6202,7 +6378,7 @@ export class ClaudeAcpAgent {
6202
6378
  let resumedModelHint = creationOpts.resumedModelHint;
6203
6379
  if (creationOpts.resume !== undefined &&
6204
6380
  !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint")) {
6205
- resumedModelHint = (await readResumedSession(creationOpts.resume, this.logger)).model;
6381
+ resumedModelHint = await readResumedModel(creationOpts.resume, this.logger);
6206
6382
  timing.phase("resume-transcript");
6207
6383
  }
6208
6384
  const input = new Pushable();
@@ -6815,12 +6991,12 @@ export class ClaudeAcpAgent {
6815
6991
  }
6816
6992
  }
6817
6993
  }
6818
- /** Tell a capable client that a session died during a provider switch. The
6819
- * session is already removed, so the failure is published on the state it
6820
- * left behind, with the refusal reason when the error carries one. */
6994
+ /** Tell a capable client when a non-auth error ends a session during a provider switch. */
6821
6995
  reportSessionLostOnProviderUpdate(sessionId, session, error) {
6822
6996
  this.logger.error(`Session ${sessionId}: could not be recreated for the provider update: ${error}`);
6823
6997
  const isAuthRequired = error instanceof RequestError && error.code === AUTH_REQUIRED_CODE;
6998
+ if (isAuthRequired)
6999
+ return;
6824
7000
  const reason = error instanceof RequestError &&
6825
7001
  typeof error.data === "object" &&
6826
7002
  error.data !== null &&
@@ -6828,11 +7004,7 @@ export class ClaudeAcpAgent {
6828
7004
  typeof error.data.reason === "string"
6829
7005
  ? error.data.reason
6830
7006
  : undefined;
6831
- const details = reason === CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON
6832
- ? CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE
6833
- : error instanceof Error
6834
- ? error.message
6835
- : String(error);
7007
+ const details = error instanceof Error ? error.message : String(error);
6836
7008
  const controller = new SessionFailureController({
6837
7009
  sessionId,
6838
7010
  state: session.sessionFailureState,
@@ -6844,7 +7016,7 @@ export class ClaudeAcpAgent {
6844
7016
  logger: this.logger,
6845
7017
  });
6846
7018
  void controller
6847
- .publish(isAuthRequired ? "auth_required" : "internal_error", {
7019
+ .publish("internal_error", {
6848
7020
  sessionScoped: true,
6849
7021
  details,
6850
7022
  ...(reason ? { reason } : {}),
@@ -7458,164 +7630,9 @@ function isTaskTool(toolName) {
7458
7630
  function shouldEmitToolCall(toolName) {
7459
7631
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
7460
7632
  }
7461
- /** Build the Claude Code-specific metadata for a tool call. Shell (Bash and
7462
- * PowerShell) descriptions are kept out of ACP's standard `title`, which
7463
- * clients may use as the shell command preview, while still giving clients
7464
- * access to Claude's concise human-readable title. */
7465
- function claudeCodeMetaFromToolUse(toolUse, cwd) {
7466
- const description = (toolUse.name === "Bash" || toolUse.name === "PowerShell") &&
7467
- toolUse.input !== null &&
7468
- typeof toolUse.input === "object" &&
7469
- "description" in toolUse.input &&
7470
- typeof toolUse.input.description === "string"
7471
- ? toolUse.input.description
7472
- : undefined;
7473
- const skillName = toolUse.name === "Skill"
7474
- ? toolUse.input?.skill
7475
- : undefined;
7476
- const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
7477
- return {
7478
- toolName: toolUse.name,
7479
- ...(description ? { title: description } : {}),
7480
- ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
7481
- ...(skillName ? { skill: skillName } : {}),
7482
- ...(skillPath ? { skillPath } : {}),
7483
- };
7484
- }
7485
- /**
7486
- * Marks the Bash `tool_call_update` whose command detached into the background.
7487
- *
7488
- * A backgrounded Bash call returns as soon as the command is handed off, so the
7489
- * card reaches `completed` while the command itself runs on for minutes. ACP has
7490
- * no tool-call status for "still running elsewhere", so this marker is what lets
7491
- * a client render the card as backgrounded work instead of finished work. It
7492
- * rides the update the tool result already emits, so it costs no extra
7493
- * notification and cannot arrive out of order.
7494
- *
7495
- * The command's own lifecycle -- progress, completion, the stop control -- is
7496
- * published separately as an async task; this says only that the card has one.
7497
- * Hence the AIR namespace rather than `claudeCode`: to a client without the
7498
- * `asyncTasks` capability, which is never sent that lifecycle, the marker would
7499
- * promise a card state it has no way to ever resolve.
7500
- */
7501
- function backgroundedBashToolCall(notification, task, asyncTasksSupported) {
7502
- const update = notification.update;
7503
- const toolCallId = task ? nonBlankTaskField(task.toolCallId ?? task.tool_use_id) : undefined;
7504
- if (!asyncTasksSupported ||
7505
- !toolCallId ||
7506
- update.sessionUpdate !== "tool_call_update" ||
7507
- update.toolCallId !== toolCallId) {
7508
- return notification;
7509
- }
7510
- return {
7511
- ...notification,
7512
- update: {
7513
- ...update,
7514
- _meta: withAirMeta(update._meta, AIR_ASYNC_TASKS_CAPABILITY, { backgrounded: true }),
7515
- },
7516
- };
7517
- }
7518
- /** The task fields arrive as `unknown` off the wire; only non-blank strings carry a link. */
7519
- function nonBlankTaskField(value) {
7520
- if (typeof value !== "string")
7521
- return undefined;
7522
- const trimmed = value.trim();
7523
- return trimmed ? trimmed : undefined;
7524
- }
7525
- /** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
7526
- const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
7527
- /**
7528
- * Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
7529
- *
7530
- * The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
7531
- * actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
7532
- * for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
7533
- * directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
7534
- * on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
7535
- */
7536
- function resolveSkillPath(skillName, cwd) {
7537
- if (!cwd) {
7538
- return undefined;
7539
- }
7540
- const colon = skillName.indexOf(":");
7541
- const scope = colon < 0 ? undefined : skillName.slice(0, colon);
7542
- const name = colon < 0 ? skillName : skillName.slice(colon + 1);
7543
- if (!name) {
7544
- return undefined;
7545
- }
7546
- const candidates = [];
7547
- const addCandidates = (base) => {
7548
- for (const container of SKILL_CONTAINER_DIRS) {
7549
- candidates.push(path.join(base, container, name, "SKILL.md"));
7550
- }
7551
- };
7552
- if (scope) {
7553
- // A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
7554
- addCandidates(path.join(cwd, scope));
7555
- candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
7556
- }
7557
- addCandidates(cwd);
7558
- addCandidates(os.homedir());
7559
- return candidates.find((candidate) => existsSync(candidate));
7560
- }
7561
- /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
7562
- * notification for a tool_use. Shared by every site that surfaces a tool call:
7563
- * the streamed tool_use path (first encounter → tool_call, later encounter →
7564
- * refine) and the permission flow (`ensureToolCallEmitted`), so they can't
7565
- * drift. The initial `tool_call` carries `status: "pending"` and, for shell tools,
7566
- * the `terminal_info` _meta that the later `terminal_output`/`terminal_exit`
7567
- * updates key off of, and the programmatic tool `name` (ACP's tool-call-name
7568
- * RFD); a refining `tool_call_update` carries none of these. `name` is set
7569
- * once at first report — on a v1 update, omitting it means "unchanged", and
7570
- * the tool behind a `toolCallId` never changes. */
7571
- function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
7572
- if (refine) {
7573
- return {
7574
- _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
7575
- toolCallId: toolUse.id,
7576
- sessionUpdate: "tool_call_update",
7577
- rawInput,
7578
- ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
7579
- };
7580
- }
7581
- return {
7582
- _meta: {
7583
- claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
7584
- ...((toolUse.name === "Bash" || toolUse.name === "PowerShell") && supportsTerminalOutput
7585
- ? { terminal_info: { terminal_id: toolUse.id } }
7586
- : {}),
7587
- },
7588
- toolCallId: toolUse.id,
7589
- sessionUpdate: "tool_call",
7590
- name: toolUse.name,
7591
- rawInput,
7592
- status: "pending",
7593
- ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
7594
- };
7595
- }
7596
- /** Refine a pending tool call from the complete top-level fields recovered
7597
- * from its still-streaming input. Shares `toolInfoFromToolUse` with the
7598
- * consolidated path but never carries `content`: content built from partial
7599
- * input is misleading (an Edit missing its `new_string` renders as a pure
7600
- * deletion) or invalid (a Write diff without `content` lacks the required
7601
- * `newText`), and the consolidated message supplies it moments later. */
7602
- function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
7603
- // TodoWrite/Task* never surfaced a tool_call to refine (plan lane).
7604
- if (!shouldEmitToolCall(toolUse.name)) {
7605
- return undefined;
7606
- }
7607
- const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
7608
- return {
7609
- _meta: {
7610
- claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
7611
- },
7612
- toolCallId: toolUse.id,
7613
- sessionUpdate: "tool_call_update",
7614
- rawInput: input,
7615
- title,
7616
- kind,
7617
- ...(locations ? { locations } : {}),
7618
- };
7633
+ /** The tool-call field tracker of a session, created on first use. */
7634
+ function toolCallFieldsOf(session) {
7635
+ return (session.toolCallFields ??= new ToolCallFieldTracker());
7619
7636
  }
7620
7637
  /**
7621
7638
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
@@ -7624,8 +7641,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
7624
7641
  export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
7625
7642
  const taskState = options?.taskState ?? new Map();
7626
7643
  const registerHooks = options?.registerHooks !== false;
7627
- const supportsTerminalOutputDelta = options?.clientCapabilities?._meta?.["terminal_output_delta"] === true;
7628
- const supportsTerminalOutput = supportsTerminalOutputDelta || options?.clientCapabilities?._meta?.["terminal_output"] === true;
7644
+ const renderer = AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
7629
7645
  if (typeof content === "string") {
7630
7646
  if (content.length === 0) {
7631
7647
  return [];
@@ -7638,15 +7654,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7638
7654
  },
7639
7655
  };
7640
7656
  applyMessageId(update, options?.messageId);
7641
- if (options?.parentToolUseId) {
7642
- update._meta = {
7643
- ...update._meta,
7644
- claudeCode: {
7645
- ...(update._meta?.claudeCode || {}),
7646
- parentToolUseId: options.parentToolUseId,
7647
- },
7648
- };
7649
- }
7657
+ if (options?.parentToolUseId)
7658
+ stampParentToolUseId(update, options.parentToolUseId);
7650
7659
  return [{ sessionId, update }];
7651
7660
  }
7652
7661
  // `tool_use_result` is message-level and carries no tool_use_id of its own:
@@ -7665,6 +7674,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7665
7674
  // Only handle the first chunk for streaming; extend as needed for batching
7666
7675
  for (const chunk of content) {
7667
7676
  let update = null;
7677
+ // The tool call that this chunk finishes, if it is a tool result.
7678
+ let finishedToolCallId;
7668
7679
  switch (chunk.type) {
7669
7680
  case "text":
7670
7681
  case "text_delta": {
@@ -7733,38 +7744,27 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7733
7744
  // closing over the name keeps the diff working without depending on
7734
7745
  // (or pinning) the cache entry's lifetime.
7735
7746
  const toolName = chunk.name;
7747
+ const hookToolCallId = chunk.id;
7736
7748
  registerHookCallback(chunk.id, {
7737
- onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
7738
- // Both `Edit` and `Write` produce a structuredPatch in their
7739
- // PostToolUse tool_response. For Edit the diff replaces the
7740
- // optimistic content built at tool_use time. For Write the
7741
- // optimistic content (built from `input.content` alone with
7742
- // `oldText: null`) shows "creation" semantics regardless of
7743
- // whether the file existed; the structuredPatch from the
7744
- // hook lets us emit the real diff for `type: "update"`. The
7745
- // helper returns `{}` if the response shape isn't usable.
7746
- const editDiff = toolName === "Edit" || toolName === "Write"
7747
- ? toolUpdateFromDiffToolResponse(toolResponse)
7748
- : {};
7749
- const update = {
7750
- _meta: {
7751
- claudeCode: {
7752
- toolResponse,
7753
- toolName,
7754
- ...(options?.parentToolUseId
7755
- ? { parentToolUseId: options.parentToolUseId }
7756
- : {}),
7757
- },
7758
- },
7759
- toolCallId: toolUseId,
7760
- sessionUpdate: "tool_call_update",
7761
- ...editDiff,
7762
- };
7763
- await client.sessionUpdate({
7764
- sessionId,
7765
- update,
7766
- });
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 });
7767
7766
  },
7767
+ onRelease: () => options?.toolCallFields?.finishHook(hookToolCallId),
7768
7768
  }, sessionId);
7769
7769
  }
7770
7770
  let rawInput;
@@ -7774,6 +7774,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7774
7774
  catch {
7775
7775
  // ignore if we can't turn it to JSON
7776
7776
  }
7777
+ const toolUse = { id: chunk.id, name: chunk.name, input: rawInput };
7777
7778
  // Emit a `tool_call` only the first time this id surfaces to the
7778
7779
  // client; afterwards refine it with a `tool_call_update`. The first
7779
7780
  // surface may be this stream chunk OR an earlier permission request
@@ -7783,17 +7784,20 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7783
7784
  const emittedToolCalls = options?.emittedToolCalls;
7784
7785
  const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
7785
7786
  emittedToolCalls?.add(chunk.id);
7786
- if (alreadyEmitted) {
7787
- // Already surfaced (full assistant message after streaming, or a
7788
- // permission request emitted it first) — refine with a
7789
- // tool_call_update rather than emitting a duplicate tool_call.
7790
- update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd, true);
7791
- }
7792
- else {
7793
- // First surface (streaming content_block_start or replay) — send as
7794
- // tool_call (with terminal_info for Bash).
7795
- update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd);
7796
- }
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
+ });
7797
7801
  }
7798
7802
  break;
7799
7803
  }
@@ -7808,6 +7812,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7808
7812
  const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
7809
7813
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
7810
7814
  completeHookCallback(chunk.tool_use_id);
7815
+ finishedToolCallId = chunk.tool_use_id;
7811
7816
  // Why this is_error result carries harness prose instead of tool
7812
7817
  // output (user-rejected / interrupted / …), when the SDK said so.
7813
7818
  // Spread into the claudeCode meta of every update emitted below; the
@@ -7901,58 +7906,27 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7901
7906
  }
7902
7907
  }
7903
7908
  }
7904
- if (shouldEmitTaskPlan) {
7905
- update = {
7906
- sessionUpdate: "plan",
7907
- entries: taskStateToPlanEntries(taskState),
7908
- };
7909
- }
7909
+ const entries = shouldEmitTaskPlan
7910
+ ? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
7911
+ : undefined;
7912
+ if (entries)
7913
+ update = { sessionUpdate: "plan", entries };
7910
7914
  }
7911
7915
  else if (toolUse.name !== "TodoWrite") {
7912
- const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, toolUseResult, supportsTerminalOutputDelta);
7913
- const terminalOutput = toolMeta?.terminal_output_delta ?? toolMeta?.terminal_output;
7914
- const terminalOutputKey = toolMeta?.terminal_output_delta
7915
- ? "terminal_output_delta"
7916
- : "terminal_output";
7917
- // When terminal output is supported, send its payload as a
7918
- // separate notification to match codex-acp's streaming lifecycle:
7919
- // 1. tool_call → _meta.terminal_info (already sent above)
7920
- // 2. tool_call_update → terminal output (sent here)
7921
- // 3. tool_call_update → _meta.terminal_exit (sent below with status)
7922
- if (terminalOutput) {
7923
- output.push({
7924
- sessionId,
7925
- update: {
7926
- _meta: {
7927
- [terminalOutputKey]: terminalOutput,
7928
- ...(options?.parentToolUseId
7929
- ? { claudeCode: { parentToolUseId: options.parentToolUseId } }
7930
- : {}),
7931
- },
7932
- toolCallId: chunk.tool_use_id,
7933
- sessionUpdate: "tool_call_update",
7934
- },
7935
- });
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 });
7936
7928
  }
7937
- update = {
7938
- _meta: {
7939
- claudeCode: {
7940
- toolName: toolUse.name,
7941
- ...(nonExecution ?? {}),
7942
- },
7943
- ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
7944
- },
7945
- toolCallId: chunk.tool_use_id,
7946
- sessionUpdate: "tool_call_update",
7947
- status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
7948
- // The terminal output already carried the exact bytes in the preceding
7949
- // update. Repeating them as rawOutput wastes bandwidth and lets a
7950
- // client accidentally render the same output twice.
7951
- ...(terminalOutput
7952
- ? {}
7953
- : { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
7954
- ...toolUpdate,
7955
- };
7929
+ update = finalUpdate;
7956
7930
  }
7957
7931
  // The tool_use is fully resolved now — drop it so a long session doesn't
7958
7932
  // retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
@@ -7978,17 +7952,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7978
7952
  break;
7979
7953
  }
7980
7954
  if (update) {
7981
- if (options?.parentToolUseId) {
7982
- update._meta = {
7983
- ...update._meta,
7984
- claudeCode: {
7985
- ...(update._meta?.claudeCode || {}),
7986
- parentToolUseId: options.parentToolUseId,
7987
- },
7988
- };
7989
- }
7955
+ if (options?.parentToolUseId)
7956
+ stampParentToolUseId(update, options.parentToolUseId);
7990
7957
  applyMessageId(update, options?.messageId);
7991
- 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));
7992
7971
  }
7993
7972
  }
7994
7973
  return output;
@@ -8003,6 +7982,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8003
7982
  cwd: options?.cwd,
8004
7983
  taskState: options?.taskState,
8005
7984
  emittedToolCalls: options?.emittedToolCalls,
7985
+ toolCallFields: options?.toolCallFields,
8006
7986
  messageId: options?.messageId,
8007
7987
  };
8008
7988
  switch (event.type) {
@@ -8021,7 +8001,6 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8021
8001
  id: block.id,
8022
8002
  name: block.name,
8023
8003
  partialJson: "",
8024
- scannedTo: 0,
8025
8004
  inString: false,
8026
8005
  escaped: false,
8027
8006
  objectDepth: 0,
@@ -8030,15 +8009,19 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8030
8009
  emittedThroughComma: -1,
8031
8010
  });
8032
8011
  }
8033
- 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
+ });
8034
8018
  }
8035
8019
  case "content_block_delta": {
8036
8020
  if (event.delta.type === "input_json_delta") {
8037
8021
  const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
8038
8022
  if (!streamedInput)
8039
8023
  return [];
8040
- streamedInput.partialJson += event.delta.partial_json;
8041
- if (scanStreamedToolInput(streamedInput)) {
8024
+ if (scanStreamedToolInput(streamedInput, event.delta.partial_json)) {
8042
8025
  // Input complete: the consolidated assistant message replays the
8043
8026
  // block with its full input and refines the call there; emitting
8044
8027
  // here too would send a duplicate identical update.
@@ -8055,20 +8038,17 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
8055
8038
  const input = recoveredToolInput(streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma));
8056
8039
  if (!input)
8057
8040
  return [];
8058
- const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
8059
- const update = streamedInputRefinement(streamedInput, input, supportsTerminalOutput, options?.cwd);
8060
- if (!update)
8041
+ // TodoWrite and the Task* tools never surfaced a tool_call to refine.
8042
+ if (!shouldEmitToolCall(streamedInput.name))
8061
8043
  return [];
8062
- if (message.parent_tool_use_id) {
8063
- update._meta = {
8064
- ...update._meta,
8065
- claudeCode: {
8066
- ...(update._meta?.claudeCode || {}),
8067
- parentToolUseId: message.parent_tool_use_id,
8068
- },
8069
- };
8070
- }
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);
8071
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 [];
8072
8052
  return [{ sessionId, update }];
8073
8053
  }
8074
8054
  return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);