@agentclientprotocol/claude-agent-acp 0.68.0 → 0.70.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
@@ -12,6 +12,7 @@ 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
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";
15
16
  import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
16
17
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
17
18
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
@@ -577,6 +578,8 @@ export class ClaudeAcpAgent {
577
578
  gatewayAuthRequest;
578
579
  /** Set while ACP overrides the agent's native provider configuration. */
579
580
  providerConfig;
581
+ /** Serializes provider changes while every open query is recreated between turns. */
582
+ providerUpdate = null;
580
583
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
581
584
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
582
585
  * tests can shrink it. */
@@ -729,7 +732,7 @@ export class ClaudeAcpAgent {
729
732
  // steering extension contract: advertises the `_session/steering` request
730
733
  // so clients know they may inject a follow-up into a running turn.
731
734
  _meta: {
732
- ...airSessionFailureCapabilityMeta(),
735
+ ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
733
736
  steering: {
734
737
  supported: true,
735
738
  },
@@ -742,6 +745,8 @@ export class ClaudeAcpAgent {
742
745
  };
743
746
  }
744
747
  async newSession(params) {
748
+ if (this.providerUpdate)
749
+ await this.providerUpdate;
745
750
  const response = await this.createSession(params, {
746
751
  // Revisit these meta values once we support resume
747
752
  resume: params._meta?.claudeCode?.options?.resume,
@@ -753,6 +758,8 @@ export class ClaudeAcpAgent {
753
758
  return response;
754
759
  }
755
760
  async unstable_forkSession(params) {
761
+ if (this.providerUpdate)
762
+ await this.providerUpdate;
756
763
  const response = await this.createSession({
757
764
  cwd: params.cwd,
758
765
  mcpServers: params.mcpServers ?? [],
@@ -769,6 +776,8 @@ export class ClaudeAcpAgent {
769
776
  return response;
770
777
  }
771
778
  async resumeSession(params) {
779
+ if (this.providerUpdate)
780
+ await this.providerUpdate;
772
781
  const result = await this.getOrCreateSession(params);
773
782
  // Needs to happen after we return the session
774
783
  setTimeout(() => {
@@ -777,6 +786,8 @@ export class ClaudeAcpAgent {
777
786
  return result;
778
787
  }
779
788
  async loadSession(params) {
789
+ if (this.providerUpdate)
790
+ await this.providerUpdate;
780
791
  const result = await this.getOrCreateSession(params);
781
792
  await this.replaySessionHistory(params.sessionId);
782
793
  // Send available commands after replay so it doesn't interleave with history
@@ -845,6 +856,7 @@ export class ClaudeAcpAgent {
845
856
  }
846
857
  async unstable_listProviders(_params) {
847
858
  const config = this.providerConfig ?? this.defaultProviderConfig();
859
+ this.logger.log(`[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`);
848
860
  const provider = {
849
861
  providerId: PROVIDER_ID,
850
862
  supported: SUPPORTED_PROTOCOLS,
@@ -887,7 +899,8 @@ export class ClaudeAcpAgent {
887
899
  }
888
900
  config.vertex = { projectId: vertex.projectId, region: vertex.region };
889
901
  }
890
- this.providerConfig = config;
902
+ this.logger.log(`[providers/set] apiType=${config.apiType} baseUrl=${config.baseUrl} sessions=${Object.keys(this.sessions).length}`);
903
+ await this.enqueueProviderUpdate(config);
891
904
  return {};
892
905
  }
893
906
  /**
@@ -896,7 +909,8 @@ export class ClaudeAcpAgent {
896
909
  */
897
910
  async unstable_disableProvider(params) {
898
911
  if (params.providerId === PROVIDER_ID) {
899
- this.providerConfig = undefined;
912
+ this.logger.log(`[providers/disable] sessions=${Object.keys(this.sessions).length}`);
913
+ await this.enqueueProviderUpdate(undefined);
900
914
  }
901
915
  // Unknown provider: idempotent success.
902
916
  return {};
@@ -957,6 +971,8 @@ export class ClaudeAcpAgent {
957
971
  }
958
972
  }
959
973
  async prompt(params) {
974
+ if (this.providerUpdate)
975
+ await this.providerUpdate;
960
976
  const session = this.sessions[params.sessionId];
961
977
  if (!session) {
962
978
  throw new Error("Session not found");
@@ -977,6 +993,15 @@ export class ClaudeAcpAgent {
977
993
  // user message, so the consumer can't promote the turn from the echo.
978
994
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
979
995
  const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
996
+ const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
997
+ ? agentFileChangeReportRequestId(params._meta)
998
+ : undefined;
999
+ let fileChangeAudit;
1000
+ if (fileChangeReportRequestId &&
1001
+ !session.fileChangeReportRequestIds.has(fileChangeReportRequestId)) {
1002
+ session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
1003
+ fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
1004
+ }
980
1005
  // Each prompt is a Turn whose deferred the persistent consumer settles once
981
1006
  // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
982
1007
  // turn, pushes the user message onto the streaming input, makes sure the
@@ -984,13 +1009,24 @@ export class ClaudeAcpAgent {
984
1009
  const turn = {
985
1010
  promptUuid,
986
1011
  isLocalOnlyCommand,
1012
+ ...(fileChangeAudit ? { fileChangeAudit } : {}),
987
1013
  settled: false,
988
1014
  resolve: () => { },
989
1015
  reject: () => { },
990
1016
  };
1017
+ let completeTurn;
1018
+ turn.completion = new Promise((resolve) => {
1019
+ completeTurn = resolve;
1020
+ });
991
1021
  const response = new Promise((resolve, reject) => {
992
- turn.resolve = resolve;
993
- turn.reject = reject;
1022
+ turn.resolve = (result) => {
1023
+ resolve(result);
1024
+ completeTurn();
1025
+ };
1026
+ turn.reject = (error) => {
1027
+ reject(error);
1028
+ completeTurn();
1029
+ };
994
1030
  });
995
1031
  session.turnQueue ??= [];
996
1032
  session.turnQueue.push(turn);
@@ -1147,6 +1183,15 @@ export class ClaudeAcpAgent {
1147
1183
  await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
1148
1184
  return { outcome: "injected" };
1149
1185
  }
1186
+ /** Publish the audit terminal for every turn path that did not reach the
1187
+ * report tool. The support flips the turn state synchronously before its
1188
+ * transport await, so callers can stay fail-open and settle the ACP prompt
1189
+ * immediately without allowing a racing lifecycle path to publish twice. */
1190
+ finishFileChangeAudit(session, turn, reason) {
1191
+ if (!turn.fileChangeAudit || !session.fileChangeAuditSupport)
1192
+ return;
1193
+ void session.fileChangeAuditSupport.finishUnavailable(turn.fileChangeAudit, reason);
1194
+ }
1150
1195
  /** Lazily start the per-session consumer that drains the SDK query stream for
1151
1196
  * the session's whole life. Idempotent: only the first `prompt()` starts it. */
1152
1197
  ensureConsumer(session, sessionId) {
@@ -1228,6 +1273,14 @@ export class ClaudeAcpAgent {
1228
1273
  * as the turn's answer. */
1229
1274
  const sendUpdate = async (notification) => {
1230
1275
  const { update } = notification;
1276
+ if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
1277
+ (update.sessionUpdate === "agent_message_chunk" ||
1278
+ update.sessionUpdate === "agent_thought_chunk" ||
1279
+ update.sessionUpdate === "user_message_chunk" ||
1280
+ update.sessionUpdate === "tool_call" ||
1281
+ update.sessionUpdate === "tool_call_update")) {
1282
+ return;
1283
+ }
1231
1284
  if (update.sessionUpdate === "agent_message_chunk") {
1232
1285
  const claudeMeta = update._meta?.claudeCode;
1233
1286
  if (!claudeMeta?.parentToolUseId) {
@@ -1523,11 +1576,14 @@ export class ClaudeAcpAgent {
1523
1576
  };
1524
1577
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
1525
1578
  * backstop (the turn is over), and drop it from the queue. */
1526
- const settleActive = (result) => {
1579
+ const settleActive = (result, auditReason = result.stopReason === "cancelled"
1580
+ ? "cancelled"
1581
+ : "notReported") => {
1527
1582
  const turn = session.activeTurn;
1528
1583
  if (!turn || turn.settled) {
1529
1584
  return;
1530
1585
  }
1586
+ this.finishFileChangeAudit(session, turn, auditReason);
1531
1587
  // Captured before the settled flip below (isHeldOpen tests !settled).
1532
1588
  const wasHeld = isHeldOpen(turn);
1533
1589
  turn.settled = true;
@@ -1561,6 +1617,7 @@ export class ClaudeAcpAgent {
1561
1617
  this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
1562
1618
  return;
1563
1619
  }
1620
+ this.finishFileChangeAudit(session, turn, "providerError");
1564
1621
  turn.settled = true;
1565
1622
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
1566
1623
  session.activeTurn = null;
@@ -1595,7 +1652,7 @@ export class ClaudeAcpAgent {
1595
1652
  stopReason: "end_turn",
1596
1653
  usage: sessionUsage(session),
1597
1654
  _meta: sessionFailureMeta(failure),
1598
- });
1655
+ }, "providerError");
1599
1656
  };
1600
1657
  /** Reject every in-flight turn — used when the stream dies. */
1601
1658
  const failAllTurns = (error) => {
@@ -1607,6 +1664,7 @@ export class ClaudeAcpAgent {
1607
1664
  session.turnQueue = [];
1608
1665
  for (const turn of turns) {
1609
1666
  if (!turn.settled) {
1667
+ this.finishFileChangeAudit(session, turn, "providerError");
1610
1668
  const wasHeld = isHeldOpen(turn);
1611
1669
  turn.settled = true;
1612
1670
  if (wasHeld) {
@@ -1755,6 +1813,7 @@ export class ClaudeAcpAgent {
1755
1813
  // still here was enqueued afterward and was not part of the cancel.)
1756
1814
  for (const queued of [...(session.turnQueue ?? [])]) {
1757
1815
  if (!queued.settled) {
1816
+ this.finishFileChangeAudit(session, queued, "providerError");
1758
1817
  queued.settled = true;
1759
1818
  queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
1760
1819
  }
@@ -3417,6 +3476,7 @@ export class ClaudeAcpAgent {
3417
3476
  if (session.turnQueue) {
3418
3477
  for (const turn of session.turnQueue) {
3419
3478
  if (turn !== session.activeTurn && !turn.settled) {
3479
+ this.finishFileChangeAudit(session, turn, "cancelled");
3420
3480
  turn.settled = true;
3421
3481
  // Deliberately no `usage`: a queued turn never ran, so the session
3422
3482
  // accumulator (the active turn's tally) is not its spend.
@@ -3478,6 +3538,7 @@ export class ClaudeAcpAgent {
3478
3538
  {
3479
3539
  const active = session.activeTurn;
3480
3540
  if (isHeldOpen(active)) {
3541
+ this.finishFileChangeAudit(session, active, "cancelled");
3481
3542
  active.settled = true;
3482
3543
  // Mirror settleActive's invariants (it is consumer-scoped and
3483
3544
  // unreachable from here): disarm the backstop — none should be
@@ -3815,6 +3876,13 @@ export class ClaudeAcpAgent {
3815
3876
  })
3816
3877
  : undefined;
3817
3878
  let replayTurnId;
3879
+ // Stop-hook additionalContext is persisted as an internal user message.
3880
+ // Once that marker (or the internal tool itself) appears, suppress the
3881
+ // whole audit exchange until its tool result. This also hides a disobedient
3882
+ // model's separate prose message, while an ordinary next user prompt safely
3883
+ // ends an incomplete audit lane.
3884
+ let replayingFileChangeAudit = false;
3885
+ const replayFileChangeAuditToolUseIds = new Set();
3818
3886
  for (const message of messages) {
3819
3887
  if (message.type === "user" &&
3820
3888
  message.parent_tool_use_id === null &&
@@ -3864,6 +3932,57 @@ export class ClaudeAcpAgent {
3864
3932
  if (content === null)
3865
3933
  continue;
3866
3934
  }
3935
+ const auditBlocks = Array.isArray(content)
3936
+ ? content.filter((block) => typeof block === "object" && block !== null)
3937
+ : [];
3938
+ const hasFileChangeAuditMarker = (typeof content === "string" && containsFileChangeAuditMarker(content)) ||
3939
+ auditBlocks.some((block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text));
3940
+ const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) => (block.type === "tool_use" ||
3941
+ block.type === "server_tool_use" ||
3942
+ block.type === "mcp_tool_use") &&
3943
+ typeof block.name === "string" &&
3944
+ isFileChangeAuditTool(block.name) &&
3945
+ typeof block.id === "string"
3946
+ ? [block.id]
3947
+ : []);
3948
+ const replayMessageRole = message.message
3949
+ ?.role;
3950
+ if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
3951
+ replayingFileChangeAudit = true;
3952
+ for (const toolUseId of fileChangeAuditToolUseIds) {
3953
+ replayFileChangeAuditToolUseIds.add(toolUseId);
3954
+ }
3955
+ continue;
3956
+ }
3957
+ if (replayingFileChangeAudit) {
3958
+ const toolResultIds = auditBlocks.flatMap((block) => (block.type === "tool_result" || block.type === "mcp_tool_result") &&
3959
+ typeof block.tool_use_id === "string"
3960
+ ? [block.tool_use_id]
3961
+ : []);
3962
+ let completedReport = false;
3963
+ for (const toolUseId of toolResultIds) {
3964
+ if (replayFileChangeAuditToolUseIds.delete(toolUseId))
3965
+ completedReport = true;
3966
+ }
3967
+ if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
3968
+ replayingFileChangeAudit = false;
3969
+ continue;
3970
+ }
3971
+ // A denied attempt to call another tool is still part of the hidden
3972
+ // lane. Its result must not end replay suppression before the report.
3973
+ if (toolResultIds.length > 0) {
3974
+ continue;
3975
+ }
3976
+ // The next real user prompt is already represented by the client and
3977
+ // starts a new turn; do not let a missing audit result hide it or the
3978
+ // rest of the replay.
3979
+ if (replayMessageRole === "user") {
3980
+ replayingFileChangeAudit = false;
3981
+ }
3982
+ else {
3983
+ continue;
3984
+ }
3985
+ }
3867
3986
  for (const notification of toAcpNotifications(
3868
3987
  // @ts-expect-error - untyped in SDK but we handle all of these
3869
3988
  content,
@@ -3957,6 +4076,26 @@ export class ClaudeAcpAgent {
3957
4076
  message: "Session not found",
3958
4077
  };
3959
4078
  }
4079
+ const fileChangeAudit = session.activeTurn?.fileChangeAudit;
4080
+ if (isFileChangeAuditReportPhase(fileChangeAudit)) {
4081
+ // The hidden continuation is an audit-only lane: it may submit the
4082
+ // wrapper-owned report, but it must not run another command after the
4083
+ // user-visible answer has already completed.
4084
+ if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
4085
+ return { behavior: "allow", updatedInput: toolInput };
4086
+ }
4087
+ return {
4088
+ behavior: "deny",
4089
+ message: "Only the internal file-change report is allowed during the audit.",
4090
+ };
4091
+ }
4092
+ // The tool is intentionally unusable outside a negotiated audit turn.
4093
+ if (isFileChangeAuditTool(toolName)) {
4094
+ return {
4095
+ behavior: "deny",
4096
+ message: "No file-change report was requested for this turn.",
4097
+ };
4098
+ }
3960
4099
  // When the tool call originates inside a subagent, attribute the eagerly
3961
4100
  // emitted tool_call (and the permission request itself) to the Agent/Task
3962
4101
  // tool call that spawned the subagent, mirroring the streamed subagent
@@ -4654,6 +4793,30 @@ export class ClaudeAcpAgent {
4654
4793
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
4655
4794
  // the same Map that the streaming message handler will read from.
4656
4795
  const taskState = new Map();
4796
+ // Resolve every workspace root once. The hidden report tool uses this same
4797
+ // set for lexical path validation, and the SDK receives it below.
4798
+ const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
4799
+ const additionalDirectories = [
4800
+ ...(userProvidedOptions?.additionalDirectories ?? []),
4801
+ ...acpAdditionalDirectories,
4802
+ ];
4803
+ const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
4804
+ ? createFileChangeAuditSupport({
4805
+ cwd: params.cwd,
4806
+ additionalDirectories,
4807
+ getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
4808
+ publish: async (result) => {
4809
+ await this.client.sessionUpdate({
4810
+ sessionId,
4811
+ update: {
4812
+ sessionUpdate: "session_info_update",
4813
+ _meta: agentFileChangeReportMeta(result),
4814
+ },
4815
+ });
4816
+ },
4817
+ logError: (message) => this.logger.error(message),
4818
+ })
4819
+ : undefined;
4657
4820
  // The exact env the query will be created with. Built (and the provider
4658
4821
  // cache key derived from it, below) in one place so the key always
4659
4822
  // describes the backend this query actually talks to: `providers/set`,
@@ -4661,13 +4824,36 @@ export class ClaudeAcpAgent {
4661
4824
  // config concurrently, so re-resolving it after any of the awaits between
4662
4825
  // here and the session registration could disagree with the env baked
4663
4826
  // into the query.
4827
+ const resolvedProvider = this.resolveProviderConfig();
4828
+ const providerEnv = createEnvForProvider(resolvedProvider);
4829
+ const configuredSettings = userProvidedOptions?.settings ??
4830
+ (modelConfig
4831
+ ? {
4832
+ ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
4833
+ ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
4834
+ }
4835
+ : undefined);
4836
+ // Claude Code applies env from settings.json after the subprocess env. Put
4837
+ // an active ACP route in the programmatic settings tier too so user/project
4838
+ // settings cannot silently restore a different ANTHROPIC_BASE_URL.
4839
+ let settings = configuredSettings;
4840
+ if (resolvedProvider) {
4841
+ const baseSettings = typeof configuredSettings === "string"
4842
+ ? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
4843
+ : configuredSettings;
4844
+ settings = {
4845
+ ...baseSettings,
4846
+ apiKeyHelper: "",
4847
+ env: { ...baseSettings?.env, ...providerEnv },
4848
+ };
4849
+ }
4664
4850
  const env = {
4665
4851
  ...process.env,
4666
4852
  ...userProvidedOptions?.env,
4667
4853
  // Client-managed LLM routing: `providers/set` config wins, else the
4668
- // legacy gateway auth request. Baked into the query at creation, so it
4669
- // only affects sessions started after the change (matching the RFD).
4670
- ...createEnvForProvider(this.resolveProviderConfig()),
4854
+ // legacy gateway auth request. Routing is baked into the query at
4855
+ // creation; provider updates recreate loaded queries between turns.
4856
+ ...providerEnv,
4671
4857
  // Opt-in to session state events like when the agent is idle
4672
4858
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
4673
4859
  };
@@ -4676,28 +4862,25 @@ export class ClaudeAcpAgent {
4676
4862
  // SDK, so per-session `_meta` env routing and ambient process-env routing
4677
4863
  // are distinguished exactly as the CLI will see them.
4678
4864
  const providerCacheKey = providerCacheKeyFor(env);
4865
+ this.logger.log(`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`);
4679
4866
  const options = {
4680
4867
  systemPrompt,
4681
4868
  settingSources: ["user", "project", "local"],
4682
4869
  ...(thinking !== undefined && { thinking }),
4683
4870
  ...userProvidedOptions,
4684
- // CLAUDE_MODEL_CONFIG env var is a fallback for model
4685
- // configuration (e.g. Bedrock model ID overrides). When the caller
4686
- // provides settings via _meta, we intentionally ignore the env var —
4687
- // the caller is assumed to have full control over model configuration.
4688
- ...(!userProvidedOptions?.settings &&
4689
- modelConfig && {
4690
- settings: {
4691
- ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
4692
- ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
4693
- },
4694
- }),
4871
+ ...(settings && { settings }),
4695
4872
  env,
4696
4873
  // Override certain fields that must be controlled by ACP
4697
4874
  cwd: params.cwd,
4698
4875
  includePartialMessages: true,
4699
4876
  forwardSubagentText,
4700
- mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
4877
+ mcpServers: {
4878
+ ...(userProvidedOptions?.mcpServers || {}),
4879
+ ...mcpServers,
4880
+ ...(fileChangeAuditSupport
4881
+ ? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
4882
+ : {}),
4883
+ },
4701
4884
  // If we want bypassPermissions to be an option, we have to allow it here.
4702
4885
  // But it doesn't work in root mode, so we only activate it if it will work.
4703
4886
  allowDangerouslySkipPermissions: ALLOW_BYPASS,
@@ -4731,6 +4914,14 @@ export class ClaudeAcpAgent {
4731
4914
  tools,
4732
4915
  hooks: {
4733
4916
  ...userProvidedOptions?.hooks,
4917
+ ...(fileChangeAuditSupport
4918
+ ? {
4919
+ PreToolUse: [
4920
+ ...(userProvidedOptions?.hooks?.PreToolUse || []),
4921
+ { hooks: [fileChangeAuditSupport.preToolUseHook] },
4922
+ ],
4923
+ }
4924
+ : {}),
4734
4925
  PostToolUse: [
4735
4926
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
4736
4927
  {
@@ -4750,6 +4941,14 @@ export class ClaudeAcpAgent {
4750
4941
  ],
4751
4942
  },
4752
4943
  ],
4944
+ ...(fileChangeAuditSupport
4945
+ ? {
4946
+ Stop: [
4947
+ ...(userProvidedOptions?.hooks?.Stop || []),
4948
+ { hooks: [fileChangeAuditSupport.stopHook] },
4949
+ ],
4950
+ }
4951
+ : {}),
4753
4952
  TaskCreated: [
4754
4953
  ...(userProvidedOptions?.hooks?.TaskCreated || []),
4755
4954
  {
@@ -4780,11 +4979,7 @@ export class ClaudeAcpAgent {
4780
4979
  // legacy `_meta.additionalRoots` extension for clients that haven't been
4781
4980
  // updated yet. Either source is merged with directories supplied via
4782
4981
  // `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
4783
- const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
4784
- options.additionalDirectories = [
4785
- ...(userProvidedOptions?.additionalDirectories ?? []),
4786
- ...acpAdditionalDirectories,
4787
- ];
4982
+ options.additionalDirectories = additionalDirectories;
4788
4983
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
4789
4984
  // Set our own session id if not resuming an existing session.
4790
4985
  options.sessionId = sessionId;
@@ -4968,6 +5163,7 @@ export class ClaudeAcpAgent {
4968
5163
  cancelled: false,
4969
5164
  cwd: params.cwd,
4970
5165
  sessionFingerprint: computeSessionFingerprint(params),
5166
+ creationParams: params,
4971
5167
  settingsManager,
4972
5168
  accumulatedUsage: {
4973
5169
  inputTokens: 0,
@@ -4997,6 +5193,8 @@ export class ClaudeAcpAgent {
4997
5193
  owedTrailingIdles: 0,
4998
5194
  messageIdToUuid: new Map(),
4999
5195
  sessionFailureState: createSessionFailureState(),
5196
+ fileChangeReportRequestIds: new Set(),
5197
+ fileChangeAuditSupport,
5000
5198
  };
5001
5199
  return {
5002
5200
  sessionId,
@@ -5004,6 +5202,41 @@ export class ClaudeAcpAgent {
5004
5202
  configOptions,
5005
5203
  };
5006
5204
  }
5205
+ /**
5206
+ * Provider routing is baked into the environment of each SDK Query. Wait for
5207
+ * all submitted turns to settle, close every query, then resume each Claude
5208
+ * session with the same ID so subsequent turns inherit the new environment.
5209
+ */
5210
+ async enqueueProviderUpdate(config) {
5211
+ const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve();
5212
+ const update = previous.then(async () => {
5213
+ const sessions = Object.entries(this.sessions);
5214
+ const activeTurns = sessions.flatMap(([, session]) => (session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])));
5215
+ if (activeTurns.length > 0) {
5216
+ this.logger.log(`Waiting for ${activeTurns.length} active Claude turn(s) before provider update`);
5217
+ await Promise.all(activeTurns);
5218
+ }
5219
+ this.providerConfig = config;
5220
+ for (const [sessionId, session] of sessions) {
5221
+ if (this.sessions[sessionId] !== session || !session.creationParams) {
5222
+ continue;
5223
+ }
5224
+ this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
5225
+ this.closeQueryStream(session);
5226
+ delete this.sessions[sessionId];
5227
+ await this.createSession(session.creationParams, { resume: sessionId });
5228
+ }
5229
+ });
5230
+ this.providerUpdate = update;
5231
+ try {
5232
+ await update;
5233
+ }
5234
+ finally {
5235
+ if (this.providerUpdate === update) {
5236
+ this.providerUpdate = null;
5237
+ }
5238
+ }
5239
+ }
5007
5240
  }
5008
5241
  function shouldEmitRawMessage(config, message) {
5009
5242
  if (config === true)
@@ -5091,13 +5324,27 @@ function createEnvForProvider(config) {
5091
5324
  if (!config) {
5092
5325
  return {};
5093
5326
  }
5327
+ const resetRouting = {
5328
+ ANTHROPIC_BASE_URL: "",
5329
+ ANTHROPIC_BEDROCK_BASE_URL: "",
5330
+ ANTHROPIC_VERTEX_BASE_URL: "",
5331
+ CLAUDE_CODE_USE_BEDROCK: "0",
5332
+ CLAUDE_CODE_USE_VERTEX: "0",
5333
+ ANTHROPIC_VERTEX_PROJECT_ID: "",
5334
+ CLOUD_ML_REGION: "",
5335
+ AWS_REGION: "",
5336
+ ANTHROPIC_API_KEY: "",
5337
+ ANTHROPIC_AUTH_TOKEN: "",
5338
+ CLAUDE_CODE_OAUTH_TOKEN: "",
5339
+ };
5094
5340
  const customHeaders = Object.entries(config.headers)
5095
5341
  .map(([key, value]) => `${key}: ${value}`)
5096
5342
  .join("\n");
5097
5343
  if (config.apiType === "bedrock") {
5098
5344
  return {
5345
+ ...resetRouting,
5099
5346
  CLAUDE_CODE_USE_BEDROCK: "1",
5100
- AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
5347
+ AWS_BEARER_TOKEN_BEDROCK: "acp-proxy", // Bypass local AWS credential checks
5101
5348
  ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
5102
5349
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
5103
5350
  };
@@ -5106,6 +5353,7 @@ function createEnvForProvider(config) {
5106
5353
  // `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
5107
5354
  // validation; fall back to empty strings defensively.
5108
5355
  return {
5356
+ ...resetRouting,
5109
5357
  CLAUDE_CODE_USE_VERTEX: "1",
5110
5358
  ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
5111
5359
  ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
@@ -5114,9 +5362,10 @@ function createEnvForProvider(config) {
5114
5362
  };
5115
5363
  }
5116
5364
  return {
5365
+ ...resetRouting,
5117
5366
  ANTHROPIC_BASE_URL: config.baseUrl,
5118
5367
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
5119
- ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
5368
+ ANTHROPIC_AUTH_TOKEN: "acp-proxy", // Bypass local Claude login checks
5120
5369
  };
5121
5370
  }
5122
5371
  /**
@@ -5983,7 +6232,7 @@ function isTaskTool(toolName) {
5983
6232
  * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
5984
6233
  * resolved explicitly at tool_result time. */
5985
6234
  function shouldEmitToolCall(toolName) {
5986
- return toolName !== "TodoWrite" && !isTaskTool(toolName);
6235
+ return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
5987
6236
  }
5988
6237
  /** Build the Claude Code-specific metadata for a tool call. Bash descriptions
5989
6238
  * are kept out of ACP's standard `title`, which clients may use as the shell
@@ -6136,7 +6385,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6136
6385
  const registerHooks = options?.registerHooks !== false;
6137
6386
  const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
6138
6387
  if (typeof content === "string") {
6139
- if (content.length === 0) {
6388
+ if (content.length === 0 || containsFileChangeAuditMarker(content)) {
6140
6389
  return [];
6141
6390
  }
6142
6391
  const update = {
@@ -6170,6 +6419,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6170
6419
  // Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
6171
6420
  // messages need no single-block guard.
6172
6421
  const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
6422
+ // A report-phase assistant message may contain a short text preface and the
6423
+ // internal tool call in the same content array. Hide the whole message, not
6424
+ // only the tool block, so it stays absent on session replay as well as live.
6425
+ const containsFileChangeAuditToolUse = content.some((chunk) => (chunk.type === "tool_use" ||
6426
+ chunk.type === "server_tool_use" ||
6427
+ chunk.type === "mcp_tool_use") &&
6428
+ isFileChangeAuditTool(chunk.name));
6173
6429
  const output = [];
6174
6430
  // Only handle the first chunk for streaming; extend as needed for batching
6175
6431
  for (const chunk of content) {
@@ -6177,7 +6433,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6177
6433
  switch (chunk.type) {
6178
6434
  case "text":
6179
6435
  case "text_delta": {
6180
- if (chunk.text) {
6436
+ if (chunk.text &&
6437
+ !containsFileChangeAuditToolUse &&
6438
+ !containsFileChangeAuditMarker(chunk.text)) {
6181
6439
  update = {
6182
6440
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6183
6441
  content: {
@@ -6189,21 +6447,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6189
6447
  break;
6190
6448
  }
6191
6449
  case "image":
6192
- update = {
6193
- sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6194
- content: {
6195
- type: "image",
6196
- data: chunk.source.type === "base64" ? chunk.source.data : "",
6197
- mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
6198
- uri: chunk.source.type === "url" ? chunk.source.url : undefined,
6199
- },
6200
- };
6450
+ if (!containsFileChangeAuditToolUse)
6451
+ update = {
6452
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6453
+ content: {
6454
+ type: "image",
6455
+ data: chunk.source.type === "base64" ? chunk.source.data : "",
6456
+ mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
6457
+ uri: chunk.source.type === "url" ? chunk.source.url : undefined,
6458
+ },
6459
+ };
6201
6460
  break;
6202
6461
  case "thinking":
6203
6462
  case "thinking_delta": {
6204
6463
  // Recent models default `thinking.display` to "omitted", which streams
6205
6464
  // signature-only thinking blocks whose text is empty.
6206
- if (chunk.thinking) {
6465
+ if (chunk.thinking && !containsFileChangeAuditToolUse) {
6207
6466
  update = {
6208
6467
  sessionUpdate: "agent_thought_chunk",
6209
6468
  content: {
@@ -6219,7 +6478,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6219
6478
  case "mcp_tool_use": {
6220
6479
  const alreadyCached = chunk.id in toolUseCache;
6221
6480
  toolUseCache[chunk.id] = chunk;
6222
- if (chunk.name === "TodoWrite") {
6481
+ if (isFileChangeAuditTool(chunk.name)) {
6482
+ // Wrapper-owned audit protocol: never surface or register generic
6483
+ // PostToolUse callbacks for the internal tool.
6484
+ }
6485
+ else if (chunk.name === "TodoWrite") {
6223
6486
  // @ts-expect-error - sometimes input is empty object or undefined
6224
6487
  if (Array.isArray(chunk.input?.todos)) {
6225
6488
  update = {
@@ -6343,6 +6606,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6343
6606
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
6344
6607
  break;
6345
6608
  }
6609
+ if (isFileChangeAuditTool(toolUse.name)) {
6610
+ delete toolUseCache[chunk.tool_use_id];
6611
+ break;
6612
+ }
6346
6613
  // A permission request may have surfaced a plan-rendered (TodoWrite) or
6347
6614
  // suppressed (Task*) tool as a real tool_call so the request referenced
6348
6615
  // a tool call the client knows about (see `ensureToolCallEmitted`,
@@ -6624,7 +6891,7 @@ export async function runPromptWithCancellation(agent, params, signal) {
6624
6891
  signal.removeEventListener("abort", onAbort);
6625
6892
  }
6626
6893
  }
6627
- export function runAcp() {
6894
+ export function runAcp(logger) {
6628
6895
  const input = nodeToWebWritable(process.stdout);
6629
6896
  const output = nodeToWebReadable(process.stdin);
6630
6897
  const stream = ndJsonStream(input, output);
@@ -6657,7 +6924,7 @@ export function runAcp() {
6657
6924
  .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
6658
6925
  .onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
6659
6926
  .connect(stream);
6660
- agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
6927
+ agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
6661
6928
  return { connection, agent };
6662
6929
  }
6663
6930
  function commonPrefixLength(a, b) {
@@ -6752,6 +7019,7 @@ const PROVIDER_ROUTING_ENV_VARS = [
6752
7019
  "ANTHROPIC_CUSTOM_HEADERS",
6753
7020
  "ANTHROPIC_API_KEY",
6754
7021
  "ANTHROPIC_AUTH_TOKEN",
7022
+ "CLAUDE_CODE_OAUTH_TOKEN",
6755
7023
  ];
6756
7024
  /** Stable identifier for the LLM backend a session's query is created against,
6757
7025
  * used to scope {@link contextWindowCache} per backend. Positional `\0`-join