@agentclientprotocol/claude-agent-acp 0.67.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acp-agent.js CHANGED
@@ -11,11 +11,14 @@ import { promisify } from "node:util";
11
11
  import packageJson from "../package.json" with { type: "json" };
12
12
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
13
13
  import { SettingsManager } from "./settings.js";
14
+ import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
15
+ import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
14
16
  import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
15
17
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
16
18
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
17
19
  const execFileAsync = promisify(execFile);
18
20
  const MAX_TITLE_LENGTH = 256;
21
+ const MAX_INLINE_FAILURE_TITLE_LENGTH = 256;
19
22
  function sanitizeTitle(text) {
20
23
  // Replace newlines and collapse whitespace
21
24
  const sanitized = text
@@ -146,130 +149,13 @@ function computeSessionFingerprint(params) {
146
149
  const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
147
150
  return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
148
151
  }
149
- /**
150
- * The single provider ID this agent exposes via `providers/*`. Claude Code has
151
- * one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
152
- * is exactly one configurable provider.
153
- */
154
- const PROVIDER_ID = "main";
155
- /**
156
- * Protocols the `main` provider can be configured with. These mirror the
157
- * env-var mappings understood by {@link createEnvForProvider}.
158
- */
159
152
  const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
153
+ const PROVIDER_ID = "main";
154
+ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
160
155
  const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
161
- // This is a protocol namespace, not user-facing branding: `jetbrains` owns the
162
- // non-standard contract and `air` identifies the client defining its rendering
163
- // semantics, without affecting unrelated JetBrains ACP clients.
164
- const JETBRAINS_META_KEY = "jetbrains";
165
- const AIR_META_KEY = "air";
166
- const AIR_EXTENSION_VERSION_KEY = "version";
167
- const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
168
- const AIR_SESSION_FAILURE_KEY = "sessionFailure";
169
- const AIR_EXTENSION_VERSION = 1;
170
156
  function supportsSubagentTranscript(capabilities) {
171
157
  return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
172
158
  }
173
- const AIR_FAILURE_PRESENTATION = {
174
- // A non-fatal notice whose wording comes from the adapter or the SDK, so this message is only the
175
- // fallback for an advisory published without one.
176
- advisory: {
177
- message: "Claude reported a notice.",
178
- retryable: false,
179
- actions: [],
180
- },
181
- auth_required: {
182
- message: "Sign in to continue using Claude.",
183
- retryable: false,
184
- actions: ["login"],
185
- },
186
- bad_request: {
187
- message: "Claude could not process this request.",
188
- retryable: false,
189
- actions: ["new_turn"],
190
- },
191
- budget_exhausted: {
192
- message: "This Claude session reached its configured budget.",
193
- retryable: false,
194
- actions: ["new_session"],
195
- },
196
- context_exhausted: {
197
- message: "This Claude turn reached its configured limit.",
198
- retryable: false,
199
- actions: ["new_turn"],
200
- },
201
- internal_error: {
202
- message: "Claude Agent encountered an internal error.",
203
- retryable: true,
204
- actions: ["retry"],
205
- },
206
- overloaded: {
207
- message: "Claude is temporarily overloaded.",
208
- retryable: true,
209
- actions: ["retry"],
210
- },
211
- provider_error: {
212
- message: "The model provider reported an error.",
213
- retryable: true,
214
- actions: ["retry"],
215
- },
216
- quota_exhausted: {
217
- message: "The Claude account has no available quota.",
218
- retryable: false,
219
- actions: ["new_session"],
220
- },
221
- rate_limited: {
222
- message: "Claude is temporarily rate limited.",
223
- retryable: true,
224
- actions: ["retry"],
225
- },
226
- transport_lost: {
227
- message: "The connection to Claude was lost.",
228
- retryable: false,
229
- actions: ["new_session"],
230
- },
231
- worker_shutdown: {
232
- message: "The Claude worker is shutting down.",
233
- retryable: false,
234
- actions: ["new_session"],
235
- },
236
- };
237
- function supportsAirSessionFailures(capabilities) {
238
- const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY];
239
- const air = jetbrains?.[AIR_META_KEY];
240
- const version = air?.[AIR_EXTENSION_VERSION_KEY];
241
- const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
242
- return (typeof version === "number" &&
243
- Number.isFinite(version) &&
244
- Number.isInteger(version) &&
245
- version >= AIR_EXTENSION_VERSION &&
246
- Array.isArray(advertised) &&
247
- advertised.some((capability) => capability === AIR_SESSION_FAILURE_KEY));
248
- }
249
- function providerFailureCategory(errorKind) {
250
- switch (errorKind) {
251
- case "authentication_failed":
252
- case "oauth_org_not_allowed":
253
- return "auth_required";
254
- case "billing_error":
255
- return "quota_exhausted";
256
- case "rate_limit":
257
- return "rate_limited";
258
- case "overloaded":
259
- return "overloaded";
260
- case "invalid_request":
261
- case "model_not_found":
262
- return "bad_request";
263
- case "max_output_tokens":
264
- return "context_exhausted";
265
- case "server_error":
266
- case "unknown":
267
- case undefined:
268
- return "provider_error";
269
- default:
270
- return "provider_error";
271
- }
272
- }
273
159
  function parentToolUseIdOf(message) {
274
160
  if (!("parent_tool_use_id" in message))
275
161
  return null;
@@ -690,9 +576,7 @@ export class ClaudeAcpAgent {
690
576
  clientCapabilities;
691
577
  logger;
692
578
  gatewayAuthRequest;
693
- /** Client-managed LLM routing set via `providers/set`. Process-scoped and
694
- * never persisted to disk (see the Configurable LLM Providers RFD). When
695
- * set, it takes precedence over {@link gatewayAuthRequest}. */
579
+ /** Set while ACP overrides the agent's native provider configuration. */
696
580
  providerConfig;
697
581
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
698
582
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
@@ -846,12 +730,7 @@ export class ClaudeAcpAgent {
846
730
  // steering extension contract: advertises the `_session/steering` request
847
731
  // so clients know they may inject a follow-up into a running turn.
848
732
  _meta: {
849
- [JETBRAINS_META_KEY]: {
850
- [AIR_META_KEY]: {
851
- [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
852
- [AIR_EXTENSION_CAPABILITIES_KEY]: [AIR_SESSION_FAILURE_KEY],
853
- },
854
- },
733
+ ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
855
734
  steering: {
856
735
  supported: true,
857
736
  },
@@ -965,21 +844,13 @@ export class ClaudeAcpAgent {
965
844
  }
966
845
  throw new Error("Method not implemented.");
967
846
  }
968
- /**
969
- * `providers/list` — returns the single client-configurable custom gateway
970
- * provider (`main`). `current` carries only non-secret routing (never headers,
971
- * which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
972
- * display, and is `null` when the provider is not configured/disabled. The
973
- * provider is optional (`required: false`): while disabled/unconfigured the
974
- * agent falls back to its own default routing (normal Claude login).
975
- */
976
847
  async unstable_listProviders(_params) {
977
- const config = this.resolveProviderConfig();
848
+ const config = this.providerConfig ?? this.defaultProviderConfig();
978
849
  const provider = {
979
850
  providerId: PROVIDER_ID,
980
851
  supported: SUPPORTED_PROTOCOLS,
981
852
  required: false,
982
- current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
853
+ current: { apiType: config.apiType, baseUrl: config.baseUrl },
983
854
  };
984
855
  return { providers: [provider] };
985
856
  }
@@ -1021,30 +892,43 @@ export class ClaudeAcpAgent {
1021
892
  return {};
1022
893
  }
1023
894
  /**
1024
- * `providers/disable` — disabling the `main` provider clears any client-managed
1025
- * routing (both a `providers/set` config and the legacy gateway auth request),
1026
- * so the agent reverts to its own default routing and `providers/list` reports
1027
- * `current: null`. Disabling any other (unknown) ID is treated as a successful
1028
- * no-op per the RFD's idempotency rule.
895
+ * `providers/disable` ends ACP ownership of the single mutually exclusive
896
+ * backend slot and restores the agent's native routing state.
1029
897
  */
1030
898
  async unstable_disableProvider(params) {
1031
899
  if (params.providerId === PROVIDER_ID) {
1032
900
  this.providerConfig = undefined;
1033
- this.gatewayAuthRequest = undefined;
1034
901
  }
1035
902
  // Unknown provider: idempotent success.
1036
903
  return {};
1037
904
  }
1038
- /**
1039
- * Resolve the effective client-managed routing config. `providers/set` takes
1040
- * precedence; otherwise fall back to the legacy gateway auth request. Returns
1041
- * `null` when neither is configured.
1042
- */
1043
905
  resolveProviderConfig() {
1044
- if (this.providerConfig) {
1045
- return this.providerConfig;
906
+ return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest);
907
+ }
908
+ defaultProviderConfig() {
909
+ const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest);
910
+ if (gatewayConfig) {
911
+ return gatewayConfig;
1046
912
  }
1047
- return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
913
+ if (process.env.CLAUDE_CODE_USE_BEDROCK) {
914
+ return {
915
+ apiType: "bedrock",
916
+ baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
917
+ headers: {},
918
+ };
919
+ }
920
+ if (process.env.CLAUDE_CODE_USE_VERTEX) {
921
+ return {
922
+ apiType: "vertex",
923
+ baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com",
924
+ headers: {},
925
+ };
926
+ }
927
+ return {
928
+ apiType: "anthropic",
929
+ baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
930
+ headers: {},
931
+ };
1048
932
  }
1049
933
  async logout(_params) {
1050
934
  // Clear in-memory gateway credentials supplied via `authenticate` and any
@@ -1094,6 +978,15 @@ export class ClaudeAcpAgent {
1094
978
  // user message, so the consumer can't promote the turn from the echo.
1095
979
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
1096
980
  const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
981
+ const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
982
+ ? agentFileChangeReportRequestId(params._meta)
983
+ : undefined;
984
+ let fileChangeAudit;
985
+ if (fileChangeReportRequestId &&
986
+ !session.fileChangeReportRequestIds.has(fileChangeReportRequestId)) {
987
+ session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
988
+ fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
989
+ }
1097
990
  // Each prompt is a Turn whose deferred the persistent consumer settles once
1098
991
  // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
1099
992
  // turn, pushes the user message onto the streaming input, makes sure the
@@ -1101,6 +994,7 @@ export class ClaudeAcpAgent {
1101
994
  const turn = {
1102
995
  promptUuid,
1103
996
  isLocalOnlyCommand,
997
+ ...(fileChangeAudit ? { fileChangeAudit } : {}),
1104
998
  settled: false,
1105
999
  resolve: () => { },
1106
1000
  reject: () => { },
@@ -1264,6 +1158,15 @@ export class ClaudeAcpAgent {
1264
1158
  await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
1265
1159
  return { outcome: "injected" };
1266
1160
  }
1161
+ /** Publish the audit terminal for every turn path that did not reach the
1162
+ * report tool. The support flips the turn state synchronously before its
1163
+ * transport await, so callers can stay fail-open and settle the ACP prompt
1164
+ * immediately without allowing a racing lifecycle path to publish twice. */
1165
+ finishFileChangeAudit(session, turn, reason) {
1166
+ if (!turn.fileChangeAudit || !session.fileChangeAuditSupport)
1167
+ return;
1168
+ void session.fileChangeAuditSupport.finishUnavailable(turn.fileChangeAudit, reason);
1169
+ }
1267
1170
  /** Lazily start the per-session consumer that drains the SDK query stream for
1268
1171
  * the session's whole life. Idempotent: only the first `prompt()` starts it. */
1269
1172
  ensureConsumer(session, sessionId) {
@@ -1298,6 +1201,8 @@ export class ClaudeAcpAgent {
1298
1201
  // it here so the subsequent `RequestError.internalError` can forward it to
1299
1202
  // clients as structured `data`, sparing them from pattern-matching on text.
1300
1203
  let lastAssistantError;
1204
+ let lastAssistantWasUsageLimit = false;
1205
+ let lastAssistantFailureTitle;
1301
1206
  // When a streaming classifier refuses a turn, the assistant message carries
1302
1207
  // stop_reason "refusal" and structured stop_details. We capture the
1303
1208
  // human-readable explanation so the terminal `result` can surface it.
@@ -1343,6 +1248,14 @@ export class ClaudeAcpAgent {
1343
1248
  * as the turn's answer. */
1344
1249
  const sendUpdate = async (notification) => {
1345
1250
  const { update } = notification;
1251
+ if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
1252
+ (update.sessionUpdate === "agent_message_chunk" ||
1253
+ update.sessionUpdate === "agent_thought_chunk" ||
1254
+ update.sessionUpdate === "user_message_chunk" ||
1255
+ update.sessionUpdate === "tool_call" ||
1256
+ update.sessionUpdate === "tool_call_update")) {
1257
+ return;
1258
+ }
1346
1259
  if (update.sessionUpdate === "agent_message_chunk") {
1347
1260
  const claudeMeta = update._meta?.claudeCode;
1348
1261
  if (!claudeMeta?.parentToolUseId) {
@@ -1351,113 +1264,41 @@ export class ClaudeAcpAgent {
1351
1264
  }
1352
1265
  await this.client.sessionUpdate(notification);
1353
1266
  };
1354
- const isAdvisory = (failure) => failure.severity === "warning";
1355
- const failureRevisions = new Map();
1356
- const sessionFailureEpoch = randomUUID();
1357
- const activeSessionFailures = new Map();
1358
1267
  let pendingWorkerShutdown = false;
1359
1268
  const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
1360
- const sessionFailureMeta = (failure, phase) => {
1361
- const presentation = AIR_FAILURE_PRESENTATION[failure.category];
1362
- return {
1363
- [JETBRAINS_META_KEY]: {
1364
- [AIR_META_KEY]: {
1365
- [AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
1366
- [AIR_SESSION_FAILURE_KEY]: {
1367
- id: failure.id,
1368
- revision: failure.revision,
1369
- phase,
1370
- category: failure.category,
1371
- source: "claude",
1372
- safeMessage: failure.safeMessage ?? presentation.message,
1373
- retryable: presentation.retryable,
1374
- actions: presentation.actions,
1375
- ...(failure.turnId ? { turnId: failure.turnId } : {}),
1376
- ...(failure.severity ? { severity: failure.severity } : {}),
1377
- },
1378
- },
1379
- },
1380
- };
1381
- };
1382
- const emitSessionFailure = async (failure, phase) => {
1383
- if (!isCurrentConsumer()) {
1384
- this.logger.error(`Session ${params.sessionId}: ignored AIR session failure from a stale query consumer`);
1385
- return false;
1386
- }
1387
- try {
1388
- await sendUpdate({
1389
- sessionId: params.sessionId,
1390
- update: {
1391
- sessionUpdate: "session_info_update",
1392
- _meta: sessionFailureMeta(failure, phase),
1393
- },
1394
- });
1395
- return true;
1396
- }
1397
- catch (error) {
1398
- this.logger.error(`Session ${params.sessionId}: failed to publish AIR session failure: ${error}`);
1399
- return false;
1400
- }
1401
- };
1402
- const clearSessionFailure = async (shouldClear = () => true) => {
1403
- if (!supportsAirSessionFailures(this.clientCapabilities))
1404
- return;
1405
- for (const failure of [...activeSessionFailures.values()]) {
1406
- if (!shouldClear(failure))
1407
- continue;
1408
- const cleared = {
1409
- ...failure,
1410
- revision: failure.revision + 1,
1411
- };
1412
- if (await emitSessionFailure(cleared, "cleared")) {
1413
- failureRevisions.set(cleared.id, cleared.revision);
1414
- activeSessionFailures.delete(cleared.id);
1415
- }
1416
- }
1417
- };
1418
- const createSessionFailure = async (category, options = {}) => {
1419
- if (!supportsAirSessionFailures(this.clientCapabilities) || !isCurrentConsumer()) {
1420
- return undefined;
1421
- }
1422
- // An advisory is not a failure of the turn: it keeps its own id namespace and its own slot, so
1423
- // it neither clears an actionable error nor is cleared by one. Only the newest advisory
1424
- // matters, so they all share one id and bump its revision.
1425
- if (category === "advisory") {
1426
- const id = `${params.sessionId}:notice:${sessionFailureEpoch}`;
1427
- return {
1428
- id,
1429
- revision: (failureRevisions.get(id) ?? 0) + 1,
1430
- category,
1431
- severity: "warning",
1432
- ...(options.safeMessage ? { safeMessage: options.safeMessage } : {}),
1433
- };
1434
- }
1269
+ const sessionFailures = new SessionFailureController({
1270
+ sessionId: params.sessionId,
1271
+ state: session.sessionFailureState,
1272
+ capabilities: this.clientCapabilities,
1273
+ isCurrent: isCurrentConsumer,
1274
+ sendUpdate,
1275
+ logger: this.logger,
1276
+ });
1277
+ const createSessionFailure = async (kind, options = {}) => {
1435
1278
  const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1436
- const id = turnId
1437
- ? `${turnId}:error`
1438
- : `${params.sessionId}:session-error:${sessionFailureEpoch}`;
1439
- await clearSessionFailure((failure) => failure.id !== id && !isAdvisory(failure));
1440
- return {
1441
- id,
1442
- revision: (failureRevisions.get(id) ?? 0) + 1,
1443
- category,
1444
- ...(turnId ? { turnId } : {}),
1445
- };
1279
+ return sessionFailures.prepare(kind, {
1280
+ turnId,
1281
+ sessionScoped: options.turnScoped === false,
1282
+ title: options.title,
1283
+ details: options.details,
1284
+ severity: options.severity,
1285
+ });
1446
1286
  };
1447
- const publishSessionFailure = async (category, options = {}) => {
1448
- const failure = await createSessionFailure(category, options);
1449
- if (!failure)
1450
- return;
1451
- if (await emitSessionFailure(failure, "active")) {
1452
- failureRevisions.set(failure.id, failure.revision);
1453
- activeSessionFailures.set(failure.id, failure);
1454
- }
1287
+ const publishSessionFailure = async (kind, options = {}) => {
1288
+ const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1289
+ await sessionFailures.publish(kind, {
1290
+ turnId,
1291
+ sessionScoped: options.turnScoped === false,
1292
+ title: options.title,
1293
+ details: options.details,
1294
+ severity: options.severity,
1295
+ });
1455
1296
  };
1456
1297
  const clearFailuresFromEarlierTurns = async () => {
1457
1298
  const activeTurnId = session.activeTurn?.promptUuid;
1458
1299
  // Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
1459
1300
  // They are session-scoped and stay until superseded or dismissed by the user.
1460
- await clearSessionFailure((failure) => failure.turnId !== activeTurnId && !isAdvisory(failure));
1301
+ await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
1461
1302
  };
1462
1303
  const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
1463
1304
  const resetTurnScratch = () => {
@@ -1465,6 +1306,8 @@ export class ClaudeAcpAgent {
1465
1306
  lastAssistantUsage = null;
1466
1307
  lastAssistantModel = null;
1467
1308
  lastAssistantError = undefined;
1309
+ lastAssistantWasUsageLimit = false;
1310
+ lastAssistantFailureTitle = undefined;
1468
1311
  lastRefusalExplanation = null;
1469
1312
  compactionInProgress = false;
1470
1313
  // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
@@ -1708,11 +1551,14 @@ export class ClaudeAcpAgent {
1708
1551
  };
1709
1552
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
1710
1553
  * backstop (the turn is over), and drop it from the queue. */
1711
- const settleActive = (result) => {
1554
+ const settleActive = (result, auditReason = result.stopReason === "cancelled"
1555
+ ? "cancelled"
1556
+ : "notReported") => {
1712
1557
  const turn = session.activeTurn;
1713
1558
  if (!turn || turn.settled) {
1714
1559
  return;
1715
1560
  }
1561
+ this.finishFileChangeAudit(session, turn, auditReason);
1716
1562
  // Captured before the settled flip below (isHeldOpen tests !settled).
1717
1563
  const wasHeld = isHeldOpen(turn);
1718
1564
  turn.settled = true;
@@ -1746,6 +1592,7 @@ export class ClaudeAcpAgent {
1746
1592
  this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
1747
1593
  return;
1748
1594
  }
1595
+ this.finishFileChangeAudit(session, turn, "providerError");
1749
1596
  turn.settled = true;
1750
1597
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
1751
1598
  session.activeTurn = null;
@@ -1760,28 +1607,27 @@ export class ClaudeAcpAgent {
1760
1607
  /** Complete a negotiated terminal failure on the prompt response itself,
1761
1608
  * which is the canonical AIR carrier. Legacy clients keep the historical
1762
1609
  * JSON-RPC rejection path. */
1763
- const failActiveWithSessionFailure = async (category, error) => {
1610
+ const failActiveWithSessionFailure = async (kind, error, title) => {
1764
1611
  if (!supportsAirSessionFailures(this.clientCapabilities)) {
1765
1612
  failActive(error);
1766
1613
  return;
1767
1614
  }
1768
1615
  if (!session.activeTurn || session.activeTurn.settled) {
1769
- this.logger.error(`Session ${params.sessionId}: cannot attach ${category} to a prompt response because no active turn exists; publishing a session-scoped failure`);
1770
- await publishSessionFailure(category, { turnScoped: false });
1616
+ this.logger.error(`Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`);
1617
+ await publishSessionFailure(kind, { turnScoped: false, title });
1771
1618
  return;
1772
1619
  }
1773
- const failure = await createSessionFailure(category);
1620
+ const failure = await createSessionFailure(kind, { title });
1774
1621
  if (!failure) {
1775
1622
  failActive(error);
1776
1623
  return;
1777
1624
  }
1778
- failureRevisions.set(failure.id, failure.revision);
1779
- activeSessionFailures.set(failure.id, failure);
1625
+ sessionFailures.recordActive(failure);
1780
1626
  settleActive({
1781
1627
  stopReason: "end_turn",
1782
1628
  usage: sessionUsage(session),
1783
- _meta: sessionFailureMeta(failure, "active"),
1784
- });
1629
+ _meta: sessionFailureMeta(failure),
1630
+ }, "providerError");
1785
1631
  };
1786
1632
  /** Reject every in-flight turn — used when the stream dies. */
1787
1633
  const failAllTurns = (error) => {
@@ -1793,6 +1639,7 @@ export class ClaudeAcpAgent {
1793
1639
  session.turnQueue = [];
1794
1640
  for (const turn of turns) {
1795
1641
  if (!turn.settled) {
1642
+ this.finishFileChangeAudit(session, turn, "providerError");
1796
1643
  const wasHeld = isHeldOpen(turn);
1797
1644
  turn.settled = true;
1798
1645
  if (wasHeld) {
@@ -1941,6 +1788,7 @@ export class ClaudeAcpAgent {
1941
1788
  // still here was enqueued afterward and was not part of the cancel.)
1942
1789
  for (const queued of [...(session.turnQueue ?? [])]) {
1943
1790
  if (!queued.settled) {
1791
+ this.finishFileChangeAudit(session, queued, "providerError");
1944
1792
  queued.settled = true;
1945
1793
  queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
1946
1794
  }
@@ -2278,7 +2126,7 @@ export class ClaudeAcpAgent {
2278
2126
  // the next prompt; only a timer could tell those apart.
2279
2127
  this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
2280
2128
  `for the active turn; failing the in-flight prompt (issue #825)`);
2281
- await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
2129
+ await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
2282
2130
  }
2283
2131
  // The SDK generates the session title in a background task and
2284
2132
  // persists it to the session file; `idle` is the turn-over
@@ -2505,10 +2353,21 @@ export class ClaudeAcpAgent {
2505
2353
  }
2506
2354
  case "plugin_install":
2507
2355
  case "notification":
2508
- case "api_retry":
2509
2356
  case "thinking_tokens":
2510
2357
  // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
2511
2358
  break;
2359
+ case "api_retry": {
2360
+ const title = message.error_status === null
2361
+ ? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
2362
+ : `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
2363
+ await publishSessionFailure(message.error_status === null
2364
+ ? "transport_lost"
2365
+ : providerFailureCategory(message.error), {
2366
+ title,
2367
+ severity: "warning",
2368
+ });
2369
+ break;
2370
+ }
2512
2371
  case "model_refusal_fallback": {
2513
2372
  // The SDK retried a refused turn on the fallback model and made
2514
2373
  // the swap persistent for the session. Without a notice the
@@ -2534,21 +2393,28 @@ export class ClaudeAcpAgent {
2534
2393
  const category = message.api_refusal_category
2535
2394
  ? ` (${message.api_refusal_category})`
2536
2395
  : "";
2537
- const explanation = message.api_refusal_explanation
2538
- ? `\n\n${message.api_refusal_explanation}`
2539
- : "";
2540
2396
  const outcome = persistent
2541
2397
  ? `The session will continue on ${message.fallback_model}.`
2542
2398
  : local
2543
2399
  ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
2544
2400
  : `The session stays on ${message.original_model}.`;
2545
- const fallbackNotice = `${message.original_model} declined this request${category}; ` +
2546
- `retried with ${message.fallback_model}. ${outcome}${explanation}`;
2401
+ const fallbackSummary = `${message.original_model} declined this request${category}; ` +
2402
+ `retried with ${message.fallback_model}. ${outcome}`;
2403
+ const explanation = message.api_refusal_explanation || undefined;
2404
+ const fallbackNotice = explanation
2405
+ ? `${fallbackSummary}\n\n${explanation}`
2406
+ : fallbackSummary;
2547
2407
  // A silent model swap is a session-level advisory, not something the model said.
2548
2408
  // Clients that negotiated typed records get it as one; the rest keep the bold-label
2549
2409
  // transcript line, which was the only way to flag it before.
2550
2410
  if (supportsAirSessionFailures(this.clientCapabilities)) {
2551
- await publishSessionFailure("advisory", { safeMessage: fallbackNotice });
2411
+ const useDetails = explanation !== undefined &&
2412
+ fallbackSummary.length + 2 + explanation.length >
2413
+ MAX_INLINE_FAILURE_TITLE_LENGTH;
2414
+ await publishSessionFailure("advisory", {
2415
+ title: useDetails ? fallbackSummary : fallbackNotice,
2416
+ ...(useDetails ? { details: explanation } : {}),
2417
+ });
2552
2418
  }
2553
2419
  else {
2554
2420
  await sendUpdate({
@@ -2872,13 +2738,15 @@ export class ClaudeAcpAgent {
2872
2738
  settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
2873
2739
  break;
2874
2740
  }
2875
- if (!message.is_error) {
2876
- await clearSessionFailure();
2741
+ if (!message.is_error && lastAssistantModel !== null) {
2742
+ const activeTurnId = session.activeTurn?.promptUuid;
2743
+ await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
2744
+ (failure.severity === "warning" && failure.turnId === activeTurnId));
2877
2745
  }
2878
2746
  switch (message.subtype) {
2879
2747
  case "success": {
2880
2748
  if (message.result.includes("Please run /login")) {
2881
- await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
2749
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
2882
2750
  break;
2883
2751
  }
2884
2752
  if (message.stop_reason === "max_tokens") {
@@ -2886,7 +2754,7 @@ export class ClaudeAcpAgent {
2886
2754
  break;
2887
2755
  }
2888
2756
  if (message.is_error) {
2889
- await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError), internalErrorForClient(errorKindData(lastAssistantError), message.result));
2757
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), lastAssistantFailureTitle ?? message.result);
2890
2758
  break;
2891
2759
  }
2892
2760
  // The result text is forwarded in two cases. Local-only
@@ -2922,7 +2790,7 @@ export class ClaudeAcpAgent {
2922
2790
  break;
2923
2791
  }
2924
2792
  if (message.is_error) {
2925
- await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2793
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype));
2926
2794
  break;
2927
2795
  }
2928
2796
  stopReason = "end_turn";
@@ -2930,21 +2798,21 @@ export class ClaudeAcpAgent {
2930
2798
  }
2931
2799
  case "error_max_budget_usd":
2932
2800
  if (message.is_error) {
2933
- await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2801
+ await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2934
2802
  break;
2935
2803
  }
2936
2804
  stopReason = "max_turn_requests";
2937
2805
  break;
2938
2806
  case "error_max_turns":
2939
2807
  if (message.is_error) {
2940
- await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2808
+ await failActiveWithSessionFailure("context_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2941
2809
  break;
2942
2810
  }
2943
2811
  stopReason = "max_turn_requests";
2944
2812
  break;
2945
2813
  case "error_max_structured_output_retries":
2946
2814
  if (message.is_error) {
2947
- await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2815
+ await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2948
2816
  break;
2949
2817
  }
2950
2818
  stopReason = "max_turn_requests";
@@ -3218,6 +3086,10 @@ export class ClaudeAcpAgent {
3218
3086
  if (message.type === "assistant" && message.parent_tool_use_id === null) {
3219
3087
  lastAssistantUsage = snapshotFromUsage(message.message.usage);
3220
3088
  lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
3089
+ lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
3090
+ if (message.error || lastAssistantWasUsageLimit) {
3091
+ lastAssistantFailureTitle = assistantMessageText(message.message);
3092
+ }
3221
3093
  if (message.message.model && message.message.model !== "<synthetic>") {
3222
3094
  lastAssistantModel = message.message.model;
3223
3095
  }
@@ -3276,7 +3148,18 @@ export class ClaudeAcpAgent {
3276
3148
  break;
3277
3149
  }
3278
3150
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3279
- await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
3151
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), assistantMessageText(message.message));
3152
+ break;
3153
+ }
3154
+ // AIR receives this provider condition on the terminal prompt
3155
+ // response as a typed failure whose title is the exact assistant
3156
+ // error text captured above. Do not duplicate that text as an
3157
+ // ordinary assistant message; legacy clients retain the historical
3158
+ // transcript behavior.
3159
+ if (message.type === "assistant" &&
3160
+ message.parent_tool_use_id === null &&
3161
+ (message.error || isSyntheticUsageLimitMessage(message.message)) &&
3162
+ supportsAirSessionFailures(this.clientCapabilities)) {
3280
3163
  break;
3281
3164
  }
3282
3165
  let content;
@@ -3452,7 +3335,7 @@ export class ClaudeAcpAgent {
3452
3335
  break;
3453
3336
  case "auth_status":
3454
3337
  if (!message.isAuthenticating && message.error === undefined) {
3455
- await clearSessionFailure((failure) => failure.category === "auth_required");
3338
+ await sessionFailures.clear((failure) => failure.kind === "auth_required");
3456
3339
  }
3457
3340
  break;
3458
3341
  default:
@@ -3568,6 +3451,7 @@ export class ClaudeAcpAgent {
3568
3451
  if (session.turnQueue) {
3569
3452
  for (const turn of session.turnQueue) {
3570
3453
  if (turn !== session.activeTurn && !turn.settled) {
3454
+ this.finishFileChangeAudit(session, turn, "cancelled");
3571
3455
  turn.settled = true;
3572
3456
  // Deliberately no `usage`: a queued turn never ran, so the session
3573
3457
  // accumulator (the active turn's tally) is not its spend.
@@ -3629,6 +3513,7 @@ export class ClaudeAcpAgent {
3629
3513
  {
3630
3514
  const active = session.activeTurn;
3631
3515
  if (isHeldOpen(active)) {
3516
+ this.finishFileChangeAudit(session, active, "cancelled");
3632
3517
  active.settled = true;
3633
3518
  // Mirror settleActive's invariants (it is consumer-scoped and
3634
3519
  // unreachable from here): disarm the backstop — none should be
@@ -3951,9 +3836,35 @@ export class ClaudeAcpAgent {
3951
3836
  async replaySessionHistory(sessionId) {
3952
3837
  const toolUseCache = {};
3953
3838
  const messages = await getSessionMessages(sessionId);
3954
- const forwardSubagentText = this.sessions[sessionId]?.forwardSubagentText ??
3955
- supportsSubagentTranscript(this.clientCapabilities);
3839
+ const session = this.sessions[sessionId];
3840
+ const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
3841
+ const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
3842
+ const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
3843
+ const sessionFailures = session && supportsTypedFailures
3844
+ ? new SessionFailureController({
3845
+ sessionId,
3846
+ state: session.sessionFailureState,
3847
+ capabilities: this.clientCapabilities,
3848
+ isCurrent: () => this.sessions[sessionId] === session,
3849
+ sendUpdate: (notification) => this.client.sessionUpdate(notification),
3850
+ logger: this.logger,
3851
+ })
3852
+ : undefined;
3853
+ let replayTurnId;
3854
+ // Stop-hook additionalContext is persisted as an internal user message.
3855
+ // Once that marker (or the internal tool itself) appears, suppress the
3856
+ // whole audit exchange until its tool result. This also hides a disobedient
3857
+ // model's separate prose message, while an ordinary next user prompt safely
3858
+ // ends an incomplete audit lane.
3859
+ let replayingFileChangeAudit = false;
3860
+ const replayFileChangeAuditToolUseIds = new Set();
3956
3861
  for (const message of messages) {
3862
+ if (message.type === "user" &&
3863
+ message.parent_tool_use_id === null &&
3864
+ typeof message.uuid === "string" &&
3865
+ message.uuid.length > 0) {
3866
+ replayTurnId = message.uuid;
3867
+ }
3957
3868
  // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
3958
3869
  // observe live (resumed/loaded sessions), so rewind/resume can translate
3959
3870
  // a client-supplied id without an extra getSessionMessages read. Not read
@@ -3969,6 +3880,21 @@ export class ClaudeAcpAgent {
3969
3880
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3970
3881
  continue;
3971
3882
  }
3883
+ // Capable clients saw every synthetic usage-limit message as a typed
3884
+ // failure live, so replay it at the same transcript position. The
3885
+ // preceding persisted user uuid is the live prompt uuid and therefore
3886
+ // recreates the same incident identity. Only the latest limit not
3887
+ // followed by a real model answer remains active internally.
3888
+ if (sessionFailures &&
3889
+ message.type === "assistant" &&
3890
+ message.parent_tool_use_id === null &&
3891
+ isSyntheticUsageLimitMessage(message.message)) {
3892
+ const title = assistantMessageText(message.message);
3893
+ if (title) {
3894
+ await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
3895
+ }
3896
+ continue;
3897
+ }
3972
3898
  // @ts-expect-error - untyped in SDK but we handle all of these
3973
3899
  let content = message.message.content;
3974
3900
  const parentToolUseId = parentToolUseIdOf(message);
@@ -3981,6 +3907,57 @@ export class ClaudeAcpAgent {
3981
3907
  if (content === null)
3982
3908
  continue;
3983
3909
  }
3910
+ const auditBlocks = Array.isArray(content)
3911
+ ? content.filter((block) => typeof block === "object" && block !== null)
3912
+ : [];
3913
+ const hasFileChangeAuditMarker = (typeof content === "string" && containsFileChangeAuditMarker(content)) ||
3914
+ auditBlocks.some((block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text));
3915
+ const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) => (block.type === "tool_use" ||
3916
+ block.type === "server_tool_use" ||
3917
+ block.type === "mcp_tool_use") &&
3918
+ typeof block.name === "string" &&
3919
+ isFileChangeAuditTool(block.name) &&
3920
+ typeof block.id === "string"
3921
+ ? [block.id]
3922
+ : []);
3923
+ const replayMessageRole = message.message
3924
+ ?.role;
3925
+ if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
3926
+ replayingFileChangeAudit = true;
3927
+ for (const toolUseId of fileChangeAuditToolUseIds) {
3928
+ replayFileChangeAuditToolUseIds.add(toolUseId);
3929
+ }
3930
+ continue;
3931
+ }
3932
+ if (replayingFileChangeAudit) {
3933
+ const toolResultIds = auditBlocks.flatMap((block) => (block.type === "tool_result" || block.type === "mcp_tool_result") &&
3934
+ typeof block.tool_use_id === "string"
3935
+ ? [block.tool_use_id]
3936
+ : []);
3937
+ let completedReport = false;
3938
+ for (const toolUseId of toolResultIds) {
3939
+ if (replayFileChangeAuditToolUseIds.delete(toolUseId))
3940
+ completedReport = true;
3941
+ }
3942
+ if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
3943
+ replayingFileChangeAudit = false;
3944
+ continue;
3945
+ }
3946
+ // A denied attempt to call another tool is still part of the hidden
3947
+ // lane. Its result must not end replay suppression before the report.
3948
+ if (toolResultIds.length > 0) {
3949
+ continue;
3950
+ }
3951
+ // The next real user prompt is already represented by the client and
3952
+ // starts a new turn; do not let a missing audit result hide it or the
3953
+ // rest of the replay.
3954
+ if (replayMessageRole === "user") {
3955
+ replayingFileChangeAudit = false;
3956
+ }
3957
+ else {
3958
+ continue;
3959
+ }
3960
+ }
3984
3961
  for (const notification of toAcpNotifications(
3985
3962
  // @ts-expect-error - untyped in SDK but we handle all of these
3986
3963
  content,
@@ -4074,6 +4051,26 @@ export class ClaudeAcpAgent {
4074
4051
  message: "Session not found",
4075
4052
  };
4076
4053
  }
4054
+ const fileChangeAudit = session.activeTurn?.fileChangeAudit;
4055
+ if (isFileChangeAuditReportPhase(fileChangeAudit)) {
4056
+ // The hidden continuation is an audit-only lane: it may submit the
4057
+ // wrapper-owned report, but it must not run another command after the
4058
+ // user-visible answer has already completed.
4059
+ if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
4060
+ return { behavior: "allow", updatedInput: toolInput };
4061
+ }
4062
+ return {
4063
+ behavior: "deny",
4064
+ message: "Only the internal file-change report is allowed during the audit.",
4065
+ };
4066
+ }
4067
+ // The tool is intentionally unusable outside a negotiated audit turn.
4068
+ if (isFileChangeAuditTool(toolName)) {
4069
+ return {
4070
+ behavior: "deny",
4071
+ message: "No file-change report was requested for this turn.",
4072
+ };
4073
+ }
4077
4074
  // When the tool call originates inside a subagent, attribute the eagerly
4078
4075
  // emitted tool_call (and the permission request itself) to the Agent/Task
4079
4076
  // tool call that spawned the subagent, mirroring the streamed subagent
@@ -4771,6 +4768,30 @@ export class ClaudeAcpAgent {
4771
4768
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
4772
4769
  // the same Map that the streaming message handler will read from.
4773
4770
  const taskState = new Map();
4771
+ // Resolve every workspace root once. The hidden report tool uses this same
4772
+ // set for lexical path validation, and the SDK receives it below.
4773
+ const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
4774
+ const additionalDirectories = [
4775
+ ...(userProvidedOptions?.additionalDirectories ?? []),
4776
+ ...acpAdditionalDirectories,
4777
+ ];
4778
+ const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
4779
+ ? createFileChangeAuditSupport({
4780
+ cwd: params.cwd,
4781
+ additionalDirectories,
4782
+ getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
4783
+ publish: async (result) => {
4784
+ await this.client.sessionUpdate({
4785
+ sessionId,
4786
+ update: {
4787
+ sessionUpdate: "session_info_update",
4788
+ _meta: agentFileChangeReportMeta(result),
4789
+ },
4790
+ });
4791
+ },
4792
+ logError: (message) => this.logger.error(message),
4793
+ })
4794
+ : undefined;
4774
4795
  // The exact env the query will be created with. Built (and the provider
4775
4796
  // cache key derived from it, below) in one place so the key always
4776
4797
  // describes the backend this query actually talks to: `providers/set`,
@@ -4814,7 +4835,13 @@ export class ClaudeAcpAgent {
4814
4835
  cwd: params.cwd,
4815
4836
  includePartialMessages: true,
4816
4837
  forwardSubagentText,
4817
- mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
4838
+ mcpServers: {
4839
+ ...(userProvidedOptions?.mcpServers || {}),
4840
+ ...mcpServers,
4841
+ ...(fileChangeAuditSupport
4842
+ ? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
4843
+ : {}),
4844
+ },
4818
4845
  // If we want bypassPermissions to be an option, we have to allow it here.
4819
4846
  // But it doesn't work in root mode, so we only activate it if it will work.
4820
4847
  allowDangerouslySkipPermissions: ALLOW_BYPASS,
@@ -4848,6 +4875,14 @@ export class ClaudeAcpAgent {
4848
4875
  tools,
4849
4876
  hooks: {
4850
4877
  ...userProvidedOptions?.hooks,
4878
+ ...(fileChangeAuditSupport
4879
+ ? {
4880
+ PreToolUse: [
4881
+ ...(userProvidedOptions?.hooks?.PreToolUse || []),
4882
+ { hooks: [fileChangeAuditSupport.preToolUseHook] },
4883
+ ],
4884
+ }
4885
+ : {}),
4851
4886
  PostToolUse: [
4852
4887
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
4853
4888
  {
@@ -4867,6 +4902,14 @@ export class ClaudeAcpAgent {
4867
4902
  ],
4868
4903
  },
4869
4904
  ],
4905
+ ...(fileChangeAuditSupport
4906
+ ? {
4907
+ Stop: [
4908
+ ...(userProvidedOptions?.hooks?.Stop || []),
4909
+ { hooks: [fileChangeAuditSupport.stopHook] },
4910
+ ],
4911
+ }
4912
+ : {}),
4870
4913
  TaskCreated: [
4871
4914
  ...(userProvidedOptions?.hooks?.TaskCreated || []),
4872
4915
  {
@@ -4897,11 +4940,7 @@ export class ClaudeAcpAgent {
4897
4940
  // legacy `_meta.additionalRoots` extension for clients that haven't been
4898
4941
  // updated yet. Either source is merged with directories supplied via
4899
4942
  // `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
4900
- const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
4901
- options.additionalDirectories = [
4902
- ...(userProvidedOptions?.additionalDirectories ?? []),
4903
- ...acpAdditionalDirectories,
4904
- ];
4943
+ options.additionalDirectories = additionalDirectories;
4905
4944
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
4906
4945
  // Set our own session id if not resuming an existing session.
4907
4946
  options.sessionId = sessionId;
@@ -5113,6 +5152,9 @@ export class ClaudeAcpAgent {
5113
5152
  emittedAssistantText: false,
5114
5153
  owedTrailingIdles: 0,
5115
5154
  messageIdToUuid: new Map(),
5155
+ sessionFailureState: createSessionFailureState(),
5156
+ fileChangeReportRequestIds: new Set(),
5157
+ fileChangeAuditSupport,
5116
5158
  };
5117
5159
  return {
5118
5160
  sessionId,
@@ -6099,7 +6141,7 @@ function isTaskTool(toolName) {
6099
6141
  * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
6100
6142
  * resolved explicitly at tool_result time. */
6101
6143
  function shouldEmitToolCall(toolName) {
6102
- return toolName !== "TodoWrite" && !isTaskTool(toolName);
6144
+ return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
6103
6145
  }
6104
6146
  /** Build the Claude Code-specific metadata for a tool call. Bash descriptions
6105
6147
  * are kept out of ACP's standard `title`, which clients may use as the shell
@@ -6252,7 +6294,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6252
6294
  const registerHooks = options?.registerHooks !== false;
6253
6295
  const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
6254
6296
  if (typeof content === "string") {
6255
- if (content.length === 0) {
6297
+ if (content.length === 0 || containsFileChangeAuditMarker(content)) {
6256
6298
  return [];
6257
6299
  }
6258
6300
  const update = {
@@ -6286,6 +6328,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6286
6328
  // Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
6287
6329
  // messages need no single-block guard.
6288
6330
  const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
6331
+ // A report-phase assistant message may contain a short text preface and the
6332
+ // internal tool call in the same content array. Hide the whole message, not
6333
+ // only the tool block, so it stays absent on session replay as well as live.
6334
+ const containsFileChangeAuditToolUse = content.some((chunk) => (chunk.type === "tool_use" ||
6335
+ chunk.type === "server_tool_use" ||
6336
+ chunk.type === "mcp_tool_use") &&
6337
+ isFileChangeAuditTool(chunk.name));
6289
6338
  const output = [];
6290
6339
  // Only handle the first chunk for streaming; extend as needed for batching
6291
6340
  for (const chunk of content) {
@@ -6293,7 +6342,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6293
6342
  switch (chunk.type) {
6294
6343
  case "text":
6295
6344
  case "text_delta": {
6296
- if (chunk.text) {
6345
+ if (chunk.text &&
6346
+ !containsFileChangeAuditToolUse &&
6347
+ !containsFileChangeAuditMarker(chunk.text)) {
6297
6348
  update = {
6298
6349
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6299
6350
  content: {
@@ -6305,21 +6356,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6305
6356
  break;
6306
6357
  }
6307
6358
  case "image":
6308
- update = {
6309
- sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6310
- content: {
6311
- type: "image",
6312
- data: chunk.source.type === "base64" ? chunk.source.data : "",
6313
- mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
6314
- uri: chunk.source.type === "url" ? chunk.source.url : undefined,
6315
- },
6316
- };
6359
+ if (!containsFileChangeAuditToolUse)
6360
+ update = {
6361
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6362
+ content: {
6363
+ type: "image",
6364
+ data: chunk.source.type === "base64" ? chunk.source.data : "",
6365
+ mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
6366
+ uri: chunk.source.type === "url" ? chunk.source.url : undefined,
6367
+ },
6368
+ };
6317
6369
  break;
6318
6370
  case "thinking":
6319
6371
  case "thinking_delta": {
6320
6372
  // Recent models default `thinking.display` to "omitted", which streams
6321
6373
  // signature-only thinking blocks whose text is empty.
6322
- if (chunk.thinking) {
6374
+ if (chunk.thinking && !containsFileChangeAuditToolUse) {
6323
6375
  update = {
6324
6376
  sessionUpdate: "agent_thought_chunk",
6325
6377
  content: {
@@ -6335,7 +6387,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6335
6387
  case "mcp_tool_use": {
6336
6388
  const alreadyCached = chunk.id in toolUseCache;
6337
6389
  toolUseCache[chunk.id] = chunk;
6338
- if (chunk.name === "TodoWrite") {
6390
+ if (isFileChangeAuditTool(chunk.name)) {
6391
+ // Wrapper-owned audit protocol: never surface or register generic
6392
+ // PostToolUse callbacks for the internal tool.
6393
+ }
6394
+ else if (chunk.name === "TodoWrite") {
6339
6395
  // @ts-expect-error - sometimes input is empty object or undefined
6340
6396
  if (Array.isArray(chunk.input?.todos)) {
6341
6397
  update = {
@@ -6459,6 +6515,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6459
6515
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
6460
6516
  break;
6461
6517
  }
6518
+ if (isFileChangeAuditTool(toolUse.name)) {
6519
+ delete toolUseCache[chunk.tool_use_id];
6520
+ break;
6521
+ }
6462
6522
  // A permission request may have surfaced a plan-rendered (TodoWrite) or
6463
6523
  // suppressed (Task*) tool as a real tool_call so the request referenced
6464
6524
  // a tool call the client knows about (see `ensureToolCallEmitted`,