@agentclientprotocol/claude-agent-acp 0.77.1-preview.2 → 0.77.1-preview.4

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
@@ -27,9 +27,10 @@ import { decodeClaudePermissionResponse } from "./permissions/response.js";
27
27
  import { SettingsManager } from "./settings.js";
28
28
  import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
29
29
  import { billsClaudeSubscription, CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE, CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON, claudeLoginRequiredError, claudeSubscriptionNotSupportedError, holdsNonSubscriptionCredential, refuseClaudeSubscriptionTurn, shouldHideClaudeAuth, warnClaudeSubscriptionGuardDegraded, } from "./hide-claude-auth.js";
30
- import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
30
+ import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, createNativeFileChangeReporter, supportsAgentFileChangeReport, } from "./file-change-audit.js";
31
31
  import { ContextCompactionLifecycle, clientSupportsCompactionUpdates, contextCompactionMetadataFromBoundary, isCompactSummaryMessage, } from "./context-compaction.js";
32
- import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, unregisterHookCallback, } from "./tools.js";
32
+ import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromToolResult, unregisterHookCallback, } from "./tools.js";
33
+ import { toolUpdateFromDiffToolResponse } from "./diff.js";
33
34
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
34
35
  import { acceptedPlanToolResult, ExitPlanCoordinator, executionDiagnostic, exitPlanModeRawOutput, observeExitPlanToolResults, } from "./exit-plan.js";
35
36
  import { parseToolResultMeta } from "./tool-result-meta.js";
@@ -779,13 +780,13 @@ export class ClaudeAcpAgent {
779
780
  },
780
781
  settleCancelledTurn: (original, session, turn) => {
781
782
  disarmForceCancel(session);
782
- this.finishFileChangeAudit(session, turn, "cancelled");
783
+ session.fileChangeReporter?.finish(turn.fileChangeReport, "cancelled");
783
784
  turn.settled = true;
784
785
  turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) });
785
786
  },
786
787
  settleFailedTurn: (session, turn, error) => {
787
788
  disarmForceCancel(session);
788
- this.finishFileChangeAudit(session, turn, "providerError");
789
+ session.fileChangeReporter?.finish(turn.fileChangeReport, "providerError");
789
790
  turn.settled = true;
790
791
  turn.reject(error);
791
792
  },
@@ -1420,15 +1421,7 @@ export class ClaudeAcpAgent {
1420
1421
  // user message, so the consumer can't promote the turn from the echo.
1421
1422
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
1422
1423
  const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
1423
- const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
1424
- ? agentFileChangeReportRequestId(params._meta)
1425
- : undefined;
1426
- let fileChangeAudit;
1427
- if (fileChangeReportRequestId &&
1428
- !session.fileChangeReportRequestIds.has(fileChangeReportRequestId)) {
1429
- session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
1430
- fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
1431
- }
1424
+ const fileChangeReport = session.fileChangeReporter?.request(params._meta);
1432
1425
  const isUsageCommand = params.prompt.length === 1 &&
1433
1426
  params.prompt[0]?.type === "text" &&
1434
1427
  isUsageCommandText(params.prompt[0].text);
@@ -1443,7 +1436,7 @@ export class ClaudeAcpAgent {
1443
1436
  isLocalOnlyCommand,
1444
1437
  ...(isUsageCommand ? { isUsageCommand: true } : {}),
1445
1438
  ...(usageMarkdownAbort ? { usageMarkdownAbort } : {}),
1446
- ...(fileChangeAudit ? { fileChangeAudit } : {}),
1439
+ ...(fileChangeReport ? { fileChangeReport } : {}),
1447
1440
  settled: false,
1448
1441
  resolve: () => { },
1449
1442
  reject: () => { },
@@ -1803,15 +1796,6 @@ export class ClaudeAcpAgent {
1803
1796
  throw error;
1804
1797
  }
1805
1798
  }
1806
- /** Publish the audit terminal for every turn path that did not reach the
1807
- * report tool. The support flips the turn state synchronously before its
1808
- * transport await, so callers can stay fail-open and settle the ACP prompt
1809
- * immediately without allowing a racing lifecycle path to publish twice. */
1810
- finishFileChangeAudit(session, turn, reason) {
1811
- if (!turn.fileChangeAudit || !session.fileChangeAuditSupport)
1812
- return;
1813
- void session.fileChangeAuditSupport.finishUnavailable(turn.fileChangeAudit, reason);
1814
- }
1815
1799
  /** Lazily start the per-session consumer that drains the SDK query stream for
1816
1800
  * the session's whole life. Idempotent: only the first `prompt()` starts it. */
1817
1801
  ensureConsumer(session, sessionId) {
@@ -1915,16 +1899,6 @@ export class ClaudeAcpAgent {
1915
1899
  }
1916
1900
  return;
1917
1901
  }
1918
- if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
1919
- (update.sessionUpdate === "agent_message_chunk" ||
1920
- update.sessionUpdate === "agent_thought_chunk" ||
1921
- update.sessionUpdate === "user_message_chunk" ||
1922
- update.sessionUpdate === "tool_call" ||
1923
- update.sessionUpdate === "tool_call_update" ||
1924
- update.sessionUpdate === "compaction_update" ||
1925
- update.sessionUpdate === "compaction_summary_chunk")) {
1926
- return;
1927
- }
1928
1902
  if (update.sessionUpdate === "agent_message_chunk") {
1929
1903
  if (!claudeMeta?.parentToolUseId &&
1930
1904
  update.content.type === "text" &&
@@ -2093,7 +2067,7 @@ export class ClaudeAcpAgent {
2093
2067
  * a stamp naming an orphaned command consumes the result outright, and a
2094
2068
  * stamp naming anything else positively refutes "this is a dead turn's
2095
2069
  * result", so the dup-over-loss one-skip must not eat it. */
2096
- const ensureActiveTurn = (resultUserMessageUuid) => {
2070
+ const ensureActiveTurn = async (resultUserMessageUuid) => {
2097
2071
  if (session.activeTurn) {
2098
2072
  if (!isHeldOpen(session.activeTurn)) {
2099
2073
  return;
@@ -2115,7 +2089,7 @@ export class ClaudeAcpAgent {
2115
2089
  // promoted command's own delivery decision is not judged against the
2116
2090
  // held turn's followup text (issue #453) — the caller snapshots the
2117
2091
  // flag AFTER this runs.
2118
- settleActive(session.activeTurn.deferredSettle);
2092
+ await settleActive(session.activeTurn.deferredSettle);
2119
2093
  }
2120
2094
  // Orphan accounting runs BEFORE the head check: an orphan's echo-less
2121
2095
  // result can arrive with an EMPTY queue (the common post-cancel
@@ -2276,10 +2250,10 @@ export class ClaudeAcpAgent {
2276
2250
  /** Settle the active turn's stored deferred outcome once none of its
2277
2251
  * spawned subagents is live. The single drain rule shared by the
2278
2252
  * followup-result and idle settle sites, so the two lanes can't drift. */
2279
- const settleDeferredIfDrained = () => {
2253
+ const settleDeferredIfDrained = async () => {
2280
2254
  const turn = session.activeTurn;
2281
2255
  if (isHeldOpen(turn) && !turnAwaitingSubagents(turn)) {
2282
- settleActive(turn.deferredSettle);
2256
+ await settleActive(turn.deferredSettle);
2283
2257
  }
2284
2258
  };
2285
2259
  /** Settle the active turn with `outcome` now — unless subagents it
@@ -2288,7 +2262,7 @@ export class ClaudeAcpAgent {
2288
2262
  * turn that can have spawned subagents must route through here: a site
2289
2263
  * calling settleActive directly bypasses the hold and re-opens the
2290
2264
  * out-of-turn permission deadlock (issue #866) through its lane. */
2291
- const settleOrDefer = (outcome) => {
2265
+ const settleOrDefer = async (outcome) => {
2292
2266
  // No result ends a steered turn: the steer aborted the cycle this result
2293
2267
  // may belong to, and the steered one is still to come. Record the outcome
2294
2268
  // for the idle lane (see Turn.steeredEchoes); later cycles overwrite it,
@@ -2303,19 +2277,29 @@ export class ClaudeAcpAgent {
2303
2277
  session.activeTurn.deferredSettle = outcome;
2304
2278
  }
2305
2279
  else {
2306
- settleActive(outcome);
2280
+ await settleActive(outcome);
2307
2281
  }
2308
2282
  };
2309
- /** Settle the active turn's deferred exactly once, disarm the force-cancel
2310
- * backstop (the turn is over), and drop it from the queue. */
2311
- const settleActive = (result, auditReason = result.stopReason === "cancelled"
2283
+ /** At the actual turn boundary, preview its checkpoint, settle the active
2284
+ * turn exactly once, disarm the force-cancel backstop, and drop it from
2285
+ * the queue. Cancellation and provider failures skip checkpoint I/O. */
2286
+ const settleActive = async (result, reportReason = result.stopReason === "cancelled"
2312
2287
  ? "cancelled"
2313
2288
  : "notReported") => {
2314
2289
  const turn = session.activeTurn;
2315
- if (!turn || turn.settled) {
2290
+ if (!turn || turn.settled || turn.settling) {
2316
2291
  return;
2317
2292
  }
2318
- this.finishFileChangeAudit(session, turn, auditReason);
2293
+ turn.settling = true;
2294
+ turn.settlingOutcome = result;
2295
+ if (reportReason === "notReported") {
2296
+ await session.fileChangeReporter?.report(turn, session.query);
2297
+ // cancel() can settle a held turn while the bounded checkpoint preview
2298
+ // is in flight. Its cancellation outcome wins; never settle twice.
2299
+ if (turn.settled || session.activeTurn !== turn)
2300
+ return;
2301
+ }
2302
+ session.fileChangeReporter?.finish(turn.fileChangeReport, reportReason);
2319
2303
  // Captured before the settled flip below (isHeldOpen tests !settled).
2320
2304
  const wasHeld = isHeldOpen(turn);
2321
2305
  turn.settled = true;
@@ -2350,7 +2334,7 @@ export class ClaudeAcpAgent {
2350
2334
  this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
2351
2335
  return;
2352
2336
  }
2353
- this.finishFileChangeAudit(session, turn, "providerError");
2337
+ session.fileChangeReporter?.finish(turn.fileChangeReport, "providerError");
2354
2338
  turn.settled = true;
2355
2339
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
2356
2340
  session.activeTurn = null;
@@ -2413,7 +2397,7 @@ export class ClaudeAcpAgent {
2413
2397
  return;
2414
2398
  }
2415
2399
  sessionFailures.recordActive(failure);
2416
- settleActive(turnOutcome(session, "end_turn", sessionFailureMeta(failure)), "providerError");
2400
+ await settleActive(turnOutcome(session, "end_turn", sessionFailureMeta(failure)), "providerError");
2417
2401
  };
2418
2402
  /** Reject every in-flight turn — used when the stream dies. */
2419
2403
  const failAllTurns = (error) => {
@@ -2425,7 +2409,7 @@ export class ClaudeAcpAgent {
2425
2409
  session.turnQueue = [];
2426
2410
  for (const turn of turns) {
2427
2411
  if (!turn.settled) {
2428
- this.finishFileChangeAudit(session, turn, "providerError");
2412
+ session.fileChangeReporter?.finish(turn.fileChangeReport, "providerError");
2429
2413
  const wasHeld = isHeldOpen(turn);
2430
2414
  turn.settled = true;
2431
2415
  if (wasHeld) {
@@ -2512,7 +2496,7 @@ export class ClaudeAcpAgent {
2512
2496
  this.trackOrphanCommand(session, active.promptUuid, "started");
2513
2497
  }
2514
2498
  }
2515
- settleActive(turnOutcome(session, "cancelled"));
2499
+ await settleActive(turnOutcome(session, "cancelled"));
2516
2500
  // The cancelled turn's result may never come (that's why the
2517
2501
  // backstop fired) — close its delivery stretch here so partial
2518
2502
  // streamed text can't suppress the next turn's issue-#453 fallback.
@@ -2567,7 +2551,7 @@ export class ClaudeAcpAgent {
2567
2551
  // it), but the stored one is the authoritative source.
2568
2552
  const inFlight = session.activeTurn;
2569
2553
  await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "at end of stream");
2570
- settleActive(session.cancelled
2554
+ await settleActive(session.cancelled
2571
2555
  ? turnOutcome(session, "cancelled")
2572
2556
  : (inFlight?.deferredSettle ?? turnOutcome(session, stopReason)));
2573
2557
  // Queued turns the SDK never started never ran, so reject them rather
@@ -2577,7 +2561,7 @@ export class ClaudeAcpAgent {
2577
2561
  // still here was enqueued afterward and was not part of the cancel.)
2578
2562
  for (const queued of [...(session.turnQueue ?? [])]) {
2579
2563
  if (!queued.settled) {
2580
- this.finishFileChangeAudit(session, queued, "providerError");
2564
+ session.fileChangeReporter?.finish(queued.fileChangeReport, "providerError");
2581
2565
  queued.settled = true;
2582
2566
  queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
2583
2567
  }
@@ -2849,7 +2833,7 @@ export class ClaudeAcpAgent {
2849
2833
  // when the cancel pre-empted the result (wedge/force-cancel).
2850
2834
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
2851
2835
  await compaction.reset();
2852
- settleActive(turnOutcome(session, "cancelled"));
2836
+ await settleActive(turnOutcome(session, "cancelled"));
2853
2837
  // An interrupt can pre-empt the turn's result entirely
2854
2838
  // (nothing ran the result-case `finally`), so close the
2855
2839
  // delivery stretch here: idle is the SDK's authoritative
@@ -2875,7 +2859,7 @@ export class ClaudeAcpAgent {
2875
2859
  if (session.owedTrailingIdles > 0) {
2876
2860
  session.owedTrailingIdles--;
2877
2861
  }
2878
- settleDeferredIfDrained();
2862
+ await settleDeferredIfDrained();
2879
2863
  }
2880
2864
  else if (session.owedTrailingIdles > 0) {
2881
2865
  // Absorb a settled turn's trailing idle. Also covers a
@@ -2907,7 +2891,7 @@ export class ClaudeAcpAgent {
2907
2891
  steered.deferredSettle = steered.steeredSettle;
2908
2892
  steered.steeredEchoes = undefined;
2909
2893
  steered.steeredSettle = undefined;
2910
- settleDeferredIfDrained();
2894
+ await settleDeferredIfDrained();
2911
2895
  }
2912
2896
  }
2913
2897
  else if (!session.cancelled &&
@@ -3390,7 +3374,7 @@ export class ClaudeAcpAgent {
3390
3374
  // the map in that case).
3391
3375
  if (!isAutonomousResult) {
3392
3376
  recordResultForOrphanCommands();
3393
- ensureActiveTurn(message.user_message_uuid);
3377
+ await ensureActiveTurn(message.user_message_uuid);
3394
3378
  // Once the submitted goal command has produced its own result,
3395
3379
  // no older runtime update can still precede it in the ordered
3396
3380
  // SDK stream. Stop suppressing updates even when this runtime
@@ -3560,7 +3544,7 @@ export class ClaudeAcpAgent {
3560
3544
  // failActive a live turn (the held one, or the user's next
3561
3545
  // prompt) whose own result recorded a different outcome.
3562
3546
  if (isAutonomousResult) {
3563
- settleDeferredIfDrained();
3547
+ await settleDeferredIfDrained();
3564
3548
  // With no turn in flight OR QUEUED (also after the settle
3565
3549
  // above), the stretch holds only autonomous prose — close
3566
3550
  // it, so a replayed next prompt isn't silently suppressed by
@@ -3635,7 +3619,7 @@ export class ClaudeAcpAgent {
3635
3619
  return;
3636
3620
  }
3637
3621
  stopReason = "cancelled";
3638
- settleOrDefer(turnOutcome(session, "cancelled"));
3622
+ await settleOrDefer(turnOutcome(session, "cancelled"));
3639
3623
  break;
3640
3624
  }
3641
3625
  if (pendingExitPlanModeInterruption) {
@@ -3667,7 +3651,7 @@ export class ClaudeAcpAgent {
3667
3651
  // settling it out from under them would strand their output
3668
3652
  // and permission requests out-of-turn (issue #866's deadlock,
3669
3653
  // through the refusal lane).
3670
- settleOrDefer(turnOutcome(session, "refusal"));
3654
+ await settleOrDefer(turnOutcome(session, "refusal"));
3671
3655
  break;
3672
3656
  }
3673
3657
  // A priority:'now' steer can make the SDK terminate the
@@ -3678,7 +3662,7 @@ export class ClaudeAcpAgent {
3678
3662
  if (message.is_error &&
3679
3663
  isSteering(session.activeTurn) &&
3680
3664
  session.activeTurn.steeredEchoes.size > 0) {
3681
- settleOrDefer(turnOutcome(session, "end_turn"));
3665
+ await settleOrDefer(turnOutcome(session, "end_turn"));
3682
3666
  break;
3683
3667
  }
3684
3668
  if (!message.is_error && lastAssistantModel !== null) {
@@ -3816,7 +3800,7 @@ export class ClaudeAcpAgent {
3816
3800
  await compaction.reset();
3817
3801
  }
3818
3802
  if (!session.cancelled) {
3819
- settleOrDefer(turnOutcome(session, stopReason));
3803
+ await settleOrDefer(turnOutcome(session, stopReason));
3820
3804
  }
3821
3805
  }
3822
3806
  finally {
@@ -4010,7 +3994,7 @@ export class ClaudeAcpAgent {
4010
3994
  session.owedTrailingIdles++;
4011
3995
  // Before activateTurn resets the accumulator, so the
4012
3996
  // usage still belongs to the cancelled turn.
4013
- settleActive(turnOutcome(session, "cancelled"));
3997
+ await settleActive(turnOutcome(session, "cancelled"));
4014
3998
  }
4015
3999
  else if (isHeldOpen(session.activeTurn)) {
4016
4000
  // A turn held open for its background subagents (see
@@ -4020,7 +4004,7 @@ export class ClaudeAcpAgent {
4020
4004
  // subagent, but it must not rewrite the stop reason
4021
4005
  // either. Its trailing-idle debt stands and is absorbed
4022
4006
  // when the drain idle eventually arrives.
4023
- settleActive(session.activeTurn.deferredSettle);
4007
+ await settleActive(session.activeTurn.deferredSettle);
4024
4008
  }
4025
4009
  else if (isSteering(session.activeTurn) &&
4026
4010
  session.activeTurn.steeredSettle !== undefined) {
@@ -4036,10 +4020,10 @@ export class ClaudeAcpAgent {
4036
4020
  // turn and false-fails it (issue #825). Harmless if it
4037
4021
  // never comes: the debt absorbs one future idle.
4038
4022
  session.owedTrailingIdles++;
4039
- settleActive(session.activeTurn.steeredSettle);
4023
+ await settleActive(session.activeTurn.steeredSettle);
4040
4024
  }
4041
4025
  else {
4042
- settleActive(turnOutcome(session, "end_turn"));
4026
+ await settleActive(turnOutcome(session, "end_turn"));
4043
4027
  }
4044
4028
  }
4045
4029
  // Unlike the no-result teardown lanes, this hand-off must
@@ -4512,7 +4496,7 @@ export class ClaudeAcpAgent {
4512
4496
  if (session.turnQueue) {
4513
4497
  for (const turn of session.turnQueue) {
4514
4498
  if (turn !== session.activeTurn && !turn.settled) {
4515
- this.finishFileChangeAudit(session, turn, "cancelled");
4499
+ session.fileChangeReporter?.finish(turn.fileChangeReport, "cancelled");
4516
4500
  turn.settled = true;
4517
4501
  // Deliberately no `usage`: a queued turn never ran, so the session
4518
4502
  // accumulator (the active turn's tally) is not its spend.
@@ -4583,20 +4567,18 @@ export class ClaudeAcpAgent {
4583
4567
  session.pendingEmptyInterruptionDiagnosticCommands.add(turn.promptUuid);
4584
4568
  }
4585
4569
  }
4586
- // A deferred active turn (see Turn.deferredSettle) already has its
4587
- // result — it is only held open for its background subagents, which the
4588
- // interrupt below tears down. Settle it "cancelled" NOW: during the hold
4589
- // the session is typically already in state idle (the CLI's trailer
4590
- // fired at the result), so the interrupt may produce no fresh idle for
4591
- // the consumer's cancelled-settle path to run on, and the cancel would
4592
- // otherwise stall until the force-cancel backstop. Any outstanding
4593
- // trailer debt is absorbed by the idle handler when its idle does come.
4594
- // The turn's own usage snapshot is reported per the cancelled-usage
4595
- // contract (issue #844).
4570
+ // A deferred active turn (see Turn.deferredSettle) already has its result
4571
+ // and is only held open for subagents. A settling turn likewise has its
4572
+ // result but is awaiting the bounded checkpoint preview. Settle either one
4573
+ // "cancelled" NOW: the consumer cannot process the interrupt's trailing
4574
+ // idle while blocked in that preview, and a held turn may already be idle.
4575
+ // The captured outcome preserves usage and metadata (issue #844), while
4576
+ // reporter state makes a late checkpoint response harmless.
4596
4577
  {
4597
4578
  const active = session.activeTurn;
4598
- if (isHeldOpen(active)) {
4599
- this.finishFileChangeAudit(session, active, "cancelled");
4579
+ const pendingOutcome = active?.deferredSettle ?? active?.settlingOutcome;
4580
+ if (active && pendingOutcome && (isHeldOpen(active) || active.settling)) {
4581
+ session.fileChangeReporter?.finish(active.fileChangeReport, "cancelled");
4600
4582
  active.settled = true;
4601
4583
  // Mirror settleActive's invariants (it is consumer-scoped and
4602
4584
  // unreachable from here): disarm the backstop — none should be
@@ -4634,8 +4616,8 @@ export class ClaudeAcpAgent {
4634
4616
  // from it here.
4635
4617
  active.resolve({
4636
4618
  stopReason: "cancelled",
4637
- usage: active.deferredSettle.usage,
4638
- ...(active.deferredSettle._meta ? { _meta: active.deferredSettle._meta } : {}),
4619
+ usage: pendingOutcome.usage,
4620
+ ...(pendingOutcome._meta ? { _meta: pendingOutcome._meta } : {}),
4639
4621
  });
4640
4622
  }
4641
4623
  }
@@ -4917,13 +4899,6 @@ export class ClaudeAcpAgent {
4917
4899
  })
4918
4900
  : undefined;
4919
4901
  let replayTurnId;
4920
- // Stop-hook additionalContext is persisted as an internal user message.
4921
- // Once that marker (or the internal tool itself) appears, suppress the
4922
- // whole audit exchange until its tool result. This also hides a disobedient
4923
- // model's separate prose message, while an ordinary next user prompt safely
4924
- // ends an incomplete audit lane.
4925
- let replayingFileChangeAudit = false;
4926
- const replayFileChangeAuditToolUseIds = new Set();
4927
4902
  const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
4928
4903
  const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
4929
4904
  const replayTerminalStates = new Map();
@@ -5075,57 +5050,6 @@ export class ClaudeAcpAgent {
5075
5050
  if (content === null)
5076
5051
  continue;
5077
5052
  }
5078
- const auditBlocks = Array.isArray(content)
5079
- ? content.filter((block) => typeof block === "object" && block !== null)
5080
- : [];
5081
- const hasFileChangeAuditMarker = (typeof content === "string" && containsFileChangeAuditMarker(content)) ||
5082
- auditBlocks.some((block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text));
5083
- const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) => (block.type === "tool_use" ||
5084
- block.type === "server_tool_use" ||
5085
- block.type === "mcp_tool_use") &&
5086
- typeof block.name === "string" &&
5087
- isFileChangeAuditTool(block.name) &&
5088
- typeof block.id === "string"
5089
- ? [block.id]
5090
- : []);
5091
- const replayMessageRole = message.message
5092
- ?.role;
5093
- if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
5094
- replayingFileChangeAudit = true;
5095
- for (const toolUseId of fileChangeAuditToolUseIds) {
5096
- replayFileChangeAuditToolUseIds.add(toolUseId);
5097
- }
5098
- continue;
5099
- }
5100
- if (replayingFileChangeAudit) {
5101
- const toolResultIds = auditBlocks.flatMap((block) => (block.type === "tool_result" || block.type === "mcp_tool_result") &&
5102
- typeof block.tool_use_id === "string"
5103
- ? [block.tool_use_id]
5104
- : []);
5105
- let completedReport = false;
5106
- for (const toolUseId of toolResultIds) {
5107
- if (replayFileChangeAuditToolUseIds.delete(toolUseId))
5108
- completedReport = true;
5109
- }
5110
- if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
5111
- replayingFileChangeAudit = false;
5112
- continue;
5113
- }
5114
- // A denied attempt to call another tool is still part of the hidden
5115
- // lane. Its result must not end replay suppression before the report.
5116
- if (toolResultIds.length > 0) {
5117
- continue;
5118
- }
5119
- // The next real user prompt is already represented by the client and
5120
- // starts a new turn; do not let a missing audit result hide it or the
5121
- // rest of the replay.
5122
- if (replayMessageRole === "user") {
5123
- replayingFileChangeAudit = false;
5124
- }
5125
- else {
5126
- continue;
5127
- }
5128
- }
5129
5053
  // Claude persists the retained summary as a user message framed with
5130
5054
  // continuation instructions for the model. Clients on the compaction
5131
5055
  // contract get it materialized as the completed compaction entity at
@@ -5308,26 +5232,6 @@ export class ClaudeAcpAgent {
5308
5232
  message: "Session not found",
5309
5233
  };
5310
5234
  }
5311
- const fileChangeAudit = session.activeTurn?.fileChangeAudit;
5312
- if (isFileChangeAuditReportPhase(fileChangeAudit)) {
5313
- // The hidden continuation is an audit-only lane: it may submit the
5314
- // wrapper-owned report, but it must not run another command after the
5315
- // user-visible answer has already completed.
5316
- if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
5317
- return { behavior: "allow", updatedInput: toolInput };
5318
- }
5319
- return {
5320
- behavior: "deny",
5321
- message: "Only the internal file-change report is allowed during the audit.",
5322
- };
5323
- }
5324
- // The tool is intentionally unusable outside a negotiated audit turn.
5325
- if (isFileChangeAuditTool(toolName)) {
5326
- return {
5327
- behavior: "deny",
5328
- message: "No file-change report was requested for this turn.",
5329
- };
5330
- }
5331
5235
  // When the tool call originates inside a subagent, attribute the eagerly
5332
5236
  // emitted tool_call (and the permission request itself) to the Agent/Task
5333
5237
  // tool call that spawned the subagent, mirroring the streamed subagent
@@ -6042,18 +5946,17 @@ export class ClaudeAcpAgent {
6042
5946
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
6043
5947
  // the same Map that the streaming message handler will read from.
6044
5948
  const taskState = new Map();
6045
- // Resolve every workspace root once. The hidden report tool uses this same
6046
- // set for lexical path validation, and the SDK receives it below.
5949
+ // Resolve every workspace root once. The native checkpoint report uses
5950
+ // this same set for lexical path validation, and the SDK receives it below.
6047
5951
  const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
6048
5952
  const additionalDirectories = [
6049
5953
  ...(userProvidedOptions?.additionalDirectories ?? []),
6050
5954
  ...acpAdditionalDirectories,
6051
5955
  ];
6052
- const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
6053
- ? createFileChangeAuditSupport({
5956
+ const fileChangeReporter = supportsAgentFileChangeReport(this.clientCapabilities)
5957
+ ? createNativeFileChangeReporter({
6054
5958
  cwd: params.cwd,
6055
5959
  additionalDirectories,
6056
- getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
6057
5960
  publish: async (result) => {
6058
5961
  await this.client.sessionUpdate({
6059
5962
  sessionId,
@@ -6118,6 +6021,10 @@ export class ClaudeAcpAgent {
6118
6021
  settingSources: ["user", "project", "local"],
6119
6022
  ...(thinking !== undefined && { thinking }),
6120
6023
  ...userProvidedOptions,
6024
+ // Claude Code uses the same checkpoint store for /rewind. Enable it only
6025
+ // for clients that negotiated per-turn file-change reports; this avoids
6026
+ // snapshot I/O for every other session.
6027
+ ...(fileChangeReporter ? { enableFileCheckpointing: true } : {}),
6121
6028
  ...(settings && { settings }),
6122
6029
  env,
6123
6030
  // Override certain fields that must be controlled by ACP
@@ -6127,9 +6034,6 @@ export class ClaudeAcpAgent {
6127
6034
  mcpServers: {
6128
6035
  ...(userProvidedOptions?.mcpServers || {}),
6129
6036
  ...mcpServers,
6130
- ...(fileChangeAuditSupport
6131
- ? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
6132
- : {}),
6133
6037
  },
6134
6038
  allowDangerouslySkipPermissions: allowBypass,
6135
6039
  permissionMode: initialPermissionMode,
@@ -6162,14 +6066,6 @@ export class ClaudeAcpAgent {
6162
6066
  tools,
6163
6067
  hooks: {
6164
6068
  ...userProvidedOptions?.hooks,
6165
- ...(fileChangeAuditSupport
6166
- ? {
6167
- PreToolUse: [
6168
- ...(userProvidedOptions?.hooks?.PreToolUse || []),
6169
- { hooks: [fileChangeAuditSupport.preToolUseHook] },
6170
- ],
6171
- }
6172
- : {}),
6173
6069
  PostToolUse: [
6174
6070
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
6175
6071
  {
@@ -6240,14 +6136,6 @@ export class ClaudeAcpAgent {
6240
6136
  ],
6241
6137
  },
6242
6138
  ],
6243
- ...(fileChangeAuditSupport
6244
- ? {
6245
- Stop: [
6246
- ...(userProvidedOptions?.hooks?.Stop || []),
6247
- { hooks: [fileChangeAuditSupport.stopHook] },
6248
- ],
6249
- }
6250
- : {}),
6251
6139
  TaskCreated: [
6252
6140
  ...(userProvidedOptions?.hooks?.TaskCreated || []),
6253
6141
  {
@@ -6503,8 +6391,7 @@ export class ClaudeAcpAgent {
6503
6391
  sessionFailureState: createSessionFailureState(),
6504
6392
  claudeSubscriptionGuard,
6505
6393
  accountKind: fromAccountInfo(initializationResult.account)?.kind,
6506
- fileChangeReportRequestIds: new Set(),
6507
- fileChangeAuditSupport,
6394
+ fileChangeReporter,
6508
6395
  };
6509
6396
  timing.phase("register");
6510
6397
  return {
@@ -7184,7 +7071,7 @@ function isTaskTool(toolName) {
7184
7071
  * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
7185
7072
  * resolved explicitly at tool_result time. */
7186
7073
  function shouldEmitToolCall(toolName) {
7187
- return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
7074
+ return toolName !== "TodoWrite" && !isTaskTool(toolName);
7188
7075
  }
7189
7076
  /** Build the Claude Code-specific metadata for a tool call. Bash descriptions
7190
7077
  * are kept out of ACP's standard `title`, which clients may use as the shell
@@ -7354,7 +7241,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7354
7241
  const registerHooks = options?.registerHooks !== false;
7355
7242
  const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
7356
7243
  if (typeof content === "string") {
7357
- if (content.length === 0 || containsFileChangeAuditMarker(content)) {
7244
+ if (content.length === 0) {
7358
7245
  return [];
7359
7246
  }
7360
7247
  const update = {
@@ -7388,13 +7275,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7388
7275
  // Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
7389
7276
  // messages need no single-block guard.
7390
7277
  const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
7391
- // A report-phase assistant message may contain a short text preface and the
7392
- // internal tool call in the same content array. Hide the whole message, not
7393
- // only the tool block, so it stays absent on session replay as well as live.
7394
- const containsFileChangeAuditToolUse = content.some((chunk) => (chunk.type === "tool_use" ||
7395
- chunk.type === "server_tool_use" ||
7396
- chunk.type === "mcp_tool_use") &&
7397
- isFileChangeAuditTool(chunk.name));
7398
7278
  const output = [];
7399
7279
  // Only handle the first chunk for streaming; extend as needed for batching
7400
7280
  for (const chunk of content) {
@@ -7402,9 +7282,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7402
7282
  switch (chunk.type) {
7403
7283
  case "text":
7404
7284
  case "text_delta": {
7405
- if (chunk.text &&
7406
- !containsFileChangeAuditToolUse &&
7407
- !containsFileChangeAuditMarker(chunk.text)) {
7285
+ if (chunk.text) {
7408
7286
  update = {
7409
7287
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
7410
7288
  content: {
@@ -7416,22 +7294,21 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7416
7294
  break;
7417
7295
  }
7418
7296
  case "image":
7419
- if (!containsFileChangeAuditToolUse)
7420
- update = {
7421
- sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
7422
- content: {
7423
- type: "image",
7424
- data: chunk.source.type === "base64" ? chunk.source.data : "",
7425
- mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
7426
- uri: chunk.source.type === "url" ? chunk.source.url : undefined,
7427
- },
7428
- };
7297
+ update = {
7298
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
7299
+ content: {
7300
+ type: "image",
7301
+ data: chunk.source.type === "base64" ? chunk.source.data : "",
7302
+ mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
7303
+ uri: chunk.source.type === "url" ? chunk.source.url : undefined,
7304
+ },
7305
+ };
7429
7306
  break;
7430
7307
  case "thinking":
7431
7308
  case "thinking_delta": {
7432
7309
  // Recent models default `thinking.display` to "omitted", which streams
7433
7310
  // signature-only thinking blocks whose text is empty.
7434
- if (chunk.thinking && !containsFileChangeAuditToolUse) {
7311
+ if (chunk.thinking) {
7435
7312
  update = {
7436
7313
  sessionUpdate: "agent_thought_chunk",
7437
7314
  content: {
@@ -7447,11 +7324,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7447
7324
  case "mcp_tool_use": {
7448
7325
  const alreadyCached = chunk.id in toolUseCache;
7449
7326
  toolUseCache[chunk.id] = chunk;
7450
- if (isFileChangeAuditTool(chunk.name)) {
7451
- // Wrapper-owned audit protocol: never surface or register generic
7452
- // PostToolUse callbacks for the internal tool.
7453
- }
7454
- else if (chunk.name === "TodoWrite") {
7327
+ if (chunk.name === "TodoWrite") {
7455
7328
  // @ts-expect-error - sometimes input is empty object or undefined
7456
7329
  if (Array.isArray(chunk.input?.todos)) {
7457
7330
  update = {
@@ -7579,10 +7452,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
7579
7452
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
7580
7453
  break;
7581
7454
  }
7582
- if (isFileChangeAuditTool(toolUse.name)) {
7583
- delete toolUseCache[chunk.tool_use_id];
7584
- break;
7585
- }
7586
7455
  // A permission request may have surfaced a plan-rendered (TodoWrite) or
7587
7456
  // suppressed (Task*) tool as a real tool_call so the request referenced
7588
7457
  // a tool call the client knows about (see `ensureToolCallEmitted`,