@lucascouts/claude-agent-acp-plus 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/acp-agent.d.ts +107 -95
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +1050 -818
  4. package/dist/clear-context-coordinator.d.ts +58 -0
  5. package/dist/clear-context-coordinator.d.ts.map +1 -0
  6. package/dist/clear-context-coordinator.js +79 -0
  7. package/dist/exit-plan.d.ts +42 -0
  8. package/dist/exit-plan.d.ts.map +1 -0
  9. package/dist/exit-plan.js +154 -0
  10. package/dist/file-change-audit.d.ts +56 -0
  11. package/dist/file-change-audit.d.ts.map +1 -0
  12. package/dist/file-change-audit.js +379 -0
  13. package/dist/index.js +24 -1
  14. package/dist/permissions/effects.d.ts +19 -0
  15. package/dist/permissions/effects.d.ts.map +1 -0
  16. package/dist/permissions/effects.js +166 -0
  17. package/dist/permissions/modes.d.ts +8 -0
  18. package/dist/permissions/modes.d.ts.map +1 -0
  19. package/dist/permissions/modes.js +41 -0
  20. package/dist/permissions/normalization.d.ts +6 -0
  21. package/dist/permissions/normalization.d.ts.map +1 -0
  22. package/dist/permissions/normalization.js +100 -0
  23. package/dist/permissions/options/filesystem.d.ts +9 -0
  24. package/dist/permissions/options/filesystem.d.ts.map +1 -0
  25. package/dist/permissions/options/filesystem.js +124 -0
  26. package/dist/permissions/options/shared.d.ts +39 -0
  27. package/dist/permissions/options/shared.d.ts.map +1 -0
  28. package/dist/permissions/options/shared.js +64 -0
  29. package/dist/permissions/options/shell.d.ts +5 -0
  30. package/dist/permissions/options/shell.d.ts.map +1 -0
  31. package/dist/permissions/options/shell.js +100 -0
  32. package/dist/permissions/options/tools.d.ts +11 -0
  33. package/dist/permissions/options/tools.d.ts.map +1 -0
  34. package/dist/permissions/options/tools.js +135 -0
  35. package/dist/permissions/options.d.ts +5 -0
  36. package/dist/permissions/options.d.ts.map +1 -0
  37. package/dist/permissions/options.js +60 -0
  38. package/dist/permissions/presentation.d.ts +15 -0
  39. package/dist/permissions/presentation.d.ts.map +1 -0
  40. package/dist/permissions/presentation.js +83 -0
  41. package/dist/permissions/response.d.ts +11 -0
  42. package/dist/permissions/response.d.ts.map +1 -0
  43. package/dist/permissions/response.js +19 -0
  44. package/dist/session-config-ids.d.ts +5 -0
  45. package/dist/session-config-ids.d.ts.map +1 -0
  46. package/dist/session-config-ids.js +4 -0
  47. package/dist/session-failure-extension.d.ts +104 -0
  48. package/dist/session-failure-extension.d.ts.map +1 -0
  49. package/dist/session-failure-extension.js +352 -0
  50. package/dist/session-mode.d.ts +66 -0
  51. package/dist/session-mode.d.ts.map +1 -0
  52. package/dist/session-mode.js +246 -0
  53. package/dist/session-titles.d.ts +77 -0
  54. package/dist/session-titles.d.ts.map +1 -0
  55. package/dist/session-titles.js +199 -0
  56. package/dist/tool-result-meta.d.ts +8 -0
  57. package/dist/tool-result-meta.d.ts.map +1 -0
  58. package/dist/tool-result-meta.js +19 -0
  59. package/dist/tools.d.ts +4 -6
  60. package/dist/tools.d.ts.map +1 -1
  61. package/dist/tools.js +129 -24
  62. package/package.json +9 -9
package/dist/acp-agent.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
- import { deleteSession, getSessionInfo, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
2
+ import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
4
+ import { sanitizeTitle, SessionTitles } from "./session-titles.js";
4
5
  import { execFile } from "node:child_process";
5
6
  import { randomUUID } from "node:crypto";
7
+ import { existsSync } from "node:fs";
6
8
  import * as fs from "node:fs/promises";
7
9
  import * as os from "node:os";
8
10
  import * as path from "node:path";
@@ -14,22 +16,23 @@ import { filterDeprecatedModels } from "./model-deprecation.js";
14
16
  import { SettingsManager } from "./settings.js";
15
17
  import { createThinkingConfigOption, effectiveThinkingConfig, resolveThinkingSelection, THINKING_CONFIG_ID, } from "./thinking-option.js";
16
18
  import { handleRewindCommand, parseRewindInvocation } from "./rewind-command.js";
17
- import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
19
+ import { ALLOW_BYPASS, resolvePermissionMode } from "./permissions/modes.js";
20
+ import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js";
21
+ import { buildClaudePermissionOptions } from "./permissions/options.js";
22
+ import { buildClaudePermissionPresentation } from "./permissions/presentation.js";
23
+ import { decodeClaudePermissionResponse } from "./permissions/response.js";
24
+ import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
25
+ import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
26
+ import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
18
27
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
28
+ import { acceptedPlanToolResult, ExitPlanCoordinator, executionDiagnostic, exitPlanModeRawOutput, observeExitPlanToolResults, } from "./exit-plan.js";
29
+ import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
30
+ import { parseToolResultMeta } from "./tool-result-meta.js";
31
+ export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
32
+ import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
19
33
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
20
34
  const execFileAsync = promisify(execFile);
21
- const MAX_TITLE_LENGTH = 256;
22
- function sanitizeTitle(text) {
23
- // Replace newlines and collapse whitespace
24
- const sanitized = text
25
- .replace(/[\r\n]+/g, " ")
26
- .replace(/\s+/g, " ")
27
- .trim();
28
- if (sanitized.length <= MAX_TITLE_LENGTH) {
29
- return sanitized;
30
- }
31
- return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
32
- }
35
+ const MAX_INLINE_FAILURE_TITLE_LENGTH = 256;
33
36
  const ZERO_USAGE = Object.freeze({
34
37
  input_tokens: 0,
35
38
  output_tokens: 0,
@@ -114,11 +117,13 @@ function parseSteerRequest(params) {
114
117
  * result is the turn's real terminal.
115
118
  *
116
119
  * Deliberately fail-OPEN: an unknown future kind defaults to the user
117
- * lane. Misrouting a USER result into the autonomous lane hangs the
118
- * prompt un-detectably (the result is skipped, its trailing idle absorbed
119
- * as owed, so the #825 detector can't fire); misrouting an autonomous
120
- * result into the user lane is the bounded misattribution class this set
121
- * exists to reduce. */
120
+ * lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
121
+ * attribute this" marker, which gets the same safe default. Misrouting a
122
+ * USER result into the autonomous lane hangs the prompt un-detectably
123
+ * (the result is skipped, its trailing idle absorbed as owed, so the
124
+ * #825 detector can't fire); misrouting an autonomous result into the
125
+ * user lane is the bounded misattribution class this set exists to
126
+ * reduce. */
122
127
  const AUTONOMOUS_RESULT_ORIGINS = new Set([
123
128
  "task-notification",
124
129
  "peer",
@@ -157,17 +162,9 @@ function computeSessionFingerprint(params) {
157
162
  const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
158
163
  return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
159
164
  }
160
- /**
161
- * The single provider ID this agent exposes via `providers/*`. Claude Code has
162
- * one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
163
- * is exactly one configurable provider.
164
- */
165
- const PROVIDER_ID = "main";
166
- /**
167
- * Protocols the `main` provider can be configured with. These mirror the
168
- * env-var mappings understood by {@link createEnvForProvider}.
169
- */
170
165
  const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
166
+ const PROVIDER_ID = "main";
167
+ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
171
168
  const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
172
169
  function supportsSubagentTranscript(capabilities) {
173
170
  return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
@@ -296,9 +293,6 @@ function shouldHideClaudeAuth() {
296
293
  * query stream has already ended (ran to `done` or died). The stream is not
297
294
  * revivable, so the only recovery is a fresh session. */
298
295
  const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session.";
299
- // Bypass Permissions doesn't work if we are a root/sudo user
300
- const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
301
- const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
302
296
  // Slash commands that the SDK handles locally without replaying the user
303
297
  // message and without invoking the model.
304
298
  const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
@@ -408,189 +402,6 @@ export function isSyntheticLoginMessage(apiMessage) {
408
402
  typeof block.text === "string" &&
409
403
  block.text.includes("Please run /login"));
410
404
  }
411
- const PERMISSION_MODE_ALIASES = {
412
- auto: "auto",
413
- default: "default",
414
- // Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts
415
- // `"defaultMode": "manual"` in settings.json; honor the same alias here.
416
- manual: "default",
417
- acceptedits: "acceptEdits",
418
- dontask: "dontAsk",
419
- plan: "plan",
420
- bypasspermissions: "bypassPermissions",
421
- bypass: "bypassPermissions",
422
- };
423
- export function resolvePermissionMode(defaultMode, logger = console) {
424
- if (defaultMode === undefined) {
425
- return "default";
426
- }
427
- if (typeof defaultMode !== "string") {
428
- logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
429
- return "default";
430
- }
431
- const normalized = defaultMode.trim().toLowerCase();
432
- if (normalized === "") {
433
- logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
434
- return "default";
435
- }
436
- const mapped = PERMISSION_MODE_ALIASES[normalized];
437
- if (!mapped) {
438
- logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
439
- return "default";
440
- }
441
- if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
442
- logger.error("Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.");
443
- return "default";
444
- }
445
- return mapped;
446
- }
447
- /**
448
- * Builds the label for the "Always Allow" permission option so the user can see
449
- * the exact scope they are committing to. Uses the SDK-provided suggestions
450
- * when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
451
- * tool so "Always Allow" is never a blank check without disclosure.
452
- *
453
- * Fork-only layer. Upstream #930 moved the scope disclosure out of the option
454
- * label and into `_meta.permission.changes`, leaving the label as a fixed
455
- * "Always Allow". No shipping client reads that metadata yet — Zed in
456
- * particular has no reference to it — so on the label alone the user would be
457
- * granting a rule they cannot see. This restores the disclosure in the label
458
- * WITHOUT dropping the upstream metadata: both travel on the same option, and
459
- * the label can be retired once a client renders the structured form.
460
- */
461
- export function describeAlwaysAllow(suggestions, toolName) {
462
- if (!suggestions || suggestions.length === 0) {
463
- return `Always Allow all ${toolName}`;
464
- }
465
- const ruleLabels = [];
466
- const directories = [];
467
- for (const update of suggestions) {
468
- if (update.type === "addRules" && update.behavior === "allow") {
469
- for (const rule of update.rules) {
470
- ruleLabels.push(rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`);
471
- }
472
- }
473
- else if (update.type === "addDirectories") {
474
- directories.push(...update.directories);
475
- }
476
- }
477
- const parts = [];
478
- if (ruleLabels.length > 0) {
479
- parts.push(ruleLabels.join(", "));
480
- }
481
- if (directories.length > 0) {
482
- parts.push(`access to ${directories.join(", ")}`);
483
- }
484
- if (parts.length === 0) {
485
- return `Always Allow all ${toolName}`;
486
- }
487
- return `Always Allow ${parts.join(" and ")}`;
488
- }
489
- function permissionLifetime(destination) {
490
- switch (destination) {
491
- case "session":
492
- return { scope: "session" };
493
- case "cliArg":
494
- return { scope: "process", storage: "cli_argument" };
495
- case "userSettings":
496
- return { scope: "persistent", storage: "user" };
497
- case "projectSettings":
498
- return { scope: "persistent", storage: "project" };
499
- case "localSettings":
500
- return { scope: "persistent", storage: "project_local" };
501
- default:
502
- return { scope: "unknown" };
503
- }
504
- }
505
- function permissionMetadataForAlwaysAllow(suggestions, toolName) {
506
- const effectiveSuggestions = suggestions && suggestions.length > 0
507
- ? suggestions
508
- : [
509
- {
510
- type: "addRules",
511
- rules: [{ toolName }],
512
- behavior: "allow",
513
- destination: "session",
514
- },
515
- ];
516
- const changes = [];
517
- for (const update of effectiveSuggestions) {
518
- switch (update.type) {
519
- case "addRules":
520
- case "removeRules":
521
- case "replaceRules": {
522
- const operation = update.type === "addRules" ? "add" : update.type === "removeRules" ? "remove" : "replace";
523
- const targets = update.rules.map((rule) => ({
524
- type: "tool",
525
- toolName: rule.toolName,
526
- ...(rule.ruleContent
527
- ? {
528
- matcher: {
529
- type: "provider_rule",
530
- provider: "claudeCode",
531
- value: rule.ruleContent,
532
- },
533
- }
534
- : {}),
535
- }));
536
- const renderedRules = update.rules
537
- .map((rule) => rule.ruleContent
538
- ? `${rule.toolName} calls matching ${rule.ruleContent}`
539
- : `all ${rule.toolName} calls`)
540
- .join(", ");
541
- const verb = operation === "add"
542
- ? update.behavior === "allow"
543
- ? "Allow"
544
- : update.behavior === "deny"
545
- ? "Deny"
546
- : "Ask before"
547
- : operation === "remove"
548
- ? `Remove ${update.behavior} rules for`
549
- : `Replace ${update.behavior} rules with`;
550
- changes.push({
551
- type: "policy_rule",
552
- operation,
553
- ruleBehavior: update.behavior,
554
- description: `${verb} ${renderedRules}`,
555
- lifetime: permissionLifetime(update.destination),
556
- targets,
557
- });
558
- break;
559
- }
560
- case "addDirectories":
561
- case "removeDirectories": {
562
- const operation = update.type === "addDirectories" ? "add" : "remove";
563
- changes.push({
564
- type: "policy_rule",
565
- operation,
566
- ruleBehavior: "allow",
567
- description: operation === "add"
568
- ? `Allow filesystem access under ${update.directories.join(", ")}`
569
- : `Remove additional filesystem access under ${update.directories.join(", ")}`,
570
- lifetime: permissionLifetime(update.destination),
571
- targets: update.directories.map((path) => ({
572
- type: "filesystem",
573
- matcher: { type: "directory", path },
574
- })),
575
- });
576
- break;
577
- }
578
- case "setMode":
579
- changes.push({
580
- type: "permission_mode",
581
- operation: "set",
582
- provider: "claudeCode",
583
- mode: update.mode,
584
- description: `Set Claude Code permission mode to ${update.mode}`,
585
- lifetime: permissionLifetime(update.destination),
586
- });
587
- break;
588
- default:
589
- break;
590
- }
591
- }
592
- return { version: 1, changes };
593
- }
594
405
  /**
595
406
  * Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
596
407
  * exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
@@ -628,16 +439,38 @@ class ClientConnection {
628
439
  return this.ctx.notify(method, params);
629
440
  }
630
441
  }
442
+ function raceWithAbort(operation, signal) {
443
+ return new Promise((resolve, reject) => {
444
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
445
+ const onAbort = () => {
446
+ cleanup();
447
+ reject(new Error("Tool use aborted"));
448
+ };
449
+ signal.addEventListener("abort", onAbort, { once: true });
450
+ if (signal.aborted) {
451
+ onAbort();
452
+ }
453
+ void operation.then((value) => {
454
+ cleanup();
455
+ resolve(value);
456
+ }, (error) => {
457
+ cleanup();
458
+ reject(error);
459
+ });
460
+ });
461
+ }
631
462
  export class ClaudeAcpAgent {
632
463
  sessions;
633
464
  client;
634
465
  clientCapabilities;
635
466
  logger;
467
+ sessionModes;
636
468
  gatewayAuthRequest;
637
- /** Client-managed LLM routing set via `providers/set`. Process-scoped and
638
- * never persisted to disk (see the Configurable LLM Providers RFD). When
639
- * set, it takes precedence over {@link gatewayAuthRequest}. */
469
+ /** Set while ACP overrides the agent's native provider configuration. */
640
470
  providerConfig;
471
+ /** Serializes provider changes while every open query is recreated between turns. */
472
+ providerUpdate = null;
473
+ exitPlan;
641
474
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
642
475
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
643
476
  * tests can shrink it. */
@@ -646,6 +479,48 @@ export class ClaudeAcpAgent {
646
479
  this.sessions = {};
647
480
  this.client = client;
648
481
  this.logger = logger ?? console;
482
+ this.exitPlan = new ExitPlanCoordinator({
483
+ currentSession: (id) => this.sessions[id],
484
+ closeQueryStream: (session) => this.closeQueryStream(session),
485
+ restartSession: async (params, options) => {
486
+ await this.createSession(params, options);
487
+ const session = this.sessions[options.publicSessionId];
488
+ if (!session)
489
+ throw new Error("Fresh Claude context was not created");
490
+ return session;
491
+ },
492
+ applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
493
+ sessionUpdate: (notification) => this.client.sessionUpdate(notification),
494
+ ensureConsumer: (session, id) => this.ensureConsumer(session, id),
495
+ logError: (message, error) => this.logger.error(message, error),
496
+ destroyReplacement: (id, session) => {
497
+ disarmForceCancel(session);
498
+ session.cancelController?.abort();
499
+ this.closeQueryStream(session);
500
+ session.abortController.abort();
501
+ if (this.sessions[id] === session)
502
+ delete this.sessions[id];
503
+ },
504
+ settleCancelledTurn: (original, session, turn) => {
505
+ disarmForceCancel(session);
506
+ this.finishFileChangeAudit(session, turn, "cancelled");
507
+ turn.settled = true;
508
+ turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) });
509
+ },
510
+ settleFailedTurn: (session, turn, error) => {
511
+ disarmForceCancel(session);
512
+ this.finishFileChangeAudit(session, turn, "providerError");
513
+ turn.settled = true;
514
+ turn.reject(error);
515
+ },
516
+ });
517
+ this.sessionModes = new SessionModeManager({
518
+ getSession: (sessionId) => this.sessions[sessionId],
519
+ sessionEndedMessage: SESSION_ENDED_MESSAGE,
520
+ updateConfigOption: (sessionId, configId, value) => this.updateConfigOption(sessionId, configId, value),
521
+ sessionUpdate: (params) => this.client.sessionUpdate(params),
522
+ logError: (...args) => this.logger.error(...args),
523
+ });
649
524
  }
650
525
  async initialize(request) {
651
526
  this.clientCapabilities = request.clientCapabilities;
@@ -790,6 +665,7 @@ export class ClaudeAcpAgent {
790
665
  // steering extension contract: advertises the `_session/steering` request
791
666
  // so clients know they may inject a follow-up into a running turn.
792
667
  _meta: {
668
+ ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
793
669
  steering: {
794
670
  supported: true,
795
671
  },
@@ -802,6 +678,8 @@ export class ClaudeAcpAgent {
802
678
  };
803
679
  }
804
680
  async newSession(params) {
681
+ if (this.providerUpdate)
682
+ await this.providerUpdate;
805
683
  const response = await this.createSession(params, {
806
684
  // Revisit these meta values once we support resume
807
685
  resume: params._meta?.claudeCode?.options?.resume,
@@ -813,6 +691,8 @@ export class ClaudeAcpAgent {
813
691
  return response;
814
692
  }
815
693
  async unstable_forkSession(params) {
694
+ if (this.providerUpdate)
695
+ await this.providerUpdate;
816
696
  const response = await this.createSession({
817
697
  cwd: params.cwd,
818
698
  mcpServers: params.mcpServers ?? [],
@@ -829,6 +709,8 @@ export class ClaudeAcpAgent {
829
709
  return response;
830
710
  }
831
711
  async resumeSession(params) {
712
+ if (this.providerUpdate)
713
+ await this.providerUpdate;
832
714
  const result = await this.getOrCreateSession(params);
833
715
  // Needs to happen after we return the session
834
716
  setTimeout(() => {
@@ -837,6 +719,8 @@ export class ClaudeAcpAgent {
837
719
  return result;
838
720
  }
839
721
  async loadSession(params) {
722
+ if (this.providerUpdate)
723
+ await this.providerUpdate;
840
724
  const result = await this.getOrCreateSession(params);
841
725
  await this.replaySessionHistory(params.sessionId);
842
726
  // Send available commands after replay so it doesn't interleave with history
@@ -862,40 +746,6 @@ export class ClaudeAcpAgent {
862
746
  sessions,
863
747
  };
864
748
  }
865
- /** Read the SDK-maintained title for a session and, if it changed since the
866
- * last time we looked, notify the client with a `session_info_update`. The
867
- * SDK has no push event for the title it auto-generates in the background, so
868
- * we pull it at turn-end. A missing session file or read error is non-fatal:
869
- * the title is best-effort and another turn will retry. */
870
- async maybeUpdateSessionTitle(sessionId, session) {
871
- let info;
872
- try {
873
- info = await getSessionInfo(sessionId, { dir: session.cwd });
874
- }
875
- catch (error) {
876
- this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`);
877
- return;
878
- }
879
- // `customTitle` is a user-set `/rename`; `summary` is the auto-generated
880
- // title (or first prompt). Prefer the explicit title when present.
881
- const rawTitle = info?.customTitle ?? info?.summary;
882
- if (!rawTitle) {
883
- return;
884
- }
885
- const title = sanitizeTitle(rawTitle);
886
- if (title === session.lastTitle) {
887
- return;
888
- }
889
- session.lastTitle = title;
890
- await this.client.sessionUpdate({
891
- sessionId,
892
- update: {
893
- sessionUpdate: "session_info_update",
894
- title,
895
- updatedAt: new Date(info.lastModified).toISOString(),
896
- },
897
- });
898
- }
899
749
  async authenticate(_params) {
900
750
  if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
901
751
  this.gatewayAuthRequest = _params;
@@ -903,21 +753,14 @@ export class ClaudeAcpAgent {
903
753
  }
904
754
  throw new Error("Method not implemented.");
905
755
  }
906
- /**
907
- * `providers/list` — returns the single client-configurable custom gateway
908
- * provider (`main`). `current` carries only non-secret routing (never headers,
909
- * which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
910
- * display, and is `null` when the provider is not configured/disabled. The
911
- * provider is optional (`required: false`): while disabled/unconfigured the
912
- * agent falls back to its own default routing (normal Claude login).
913
- */
914
756
  async unstable_listProviders(_params) {
915
- const config = this.resolveProviderConfig();
757
+ const config = this.providerConfig ?? this.defaultProviderConfig();
758
+ this.logger.log(`[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`);
916
759
  const provider = {
917
760
  providerId: PROVIDER_ID,
918
761
  supported: SUPPORTED_PROTOCOLS,
919
762
  required: false,
920
- current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
763
+ current: { apiType: config.apiType, baseUrl: config.baseUrl },
921
764
  };
922
765
  return { providers: [provider] };
923
766
  }
@@ -955,34 +798,49 @@ export class ClaudeAcpAgent {
955
798
  }
956
799
  config.vertex = { projectId: vertex.projectId, region: vertex.region };
957
800
  }
958
- this.providerConfig = config;
801
+ this.logger.log(`[providers/set] apiType=${config.apiType} baseUrl=${config.baseUrl} sessions=${Object.keys(this.sessions).length}`);
802
+ await this.enqueueProviderUpdate(config);
959
803
  return {};
960
804
  }
961
805
  /**
962
- * `providers/disable` — disabling the `main` provider clears any client-managed
963
- * routing (both a `providers/set` config and the legacy gateway auth request),
964
- * so the agent reverts to its own default routing and `providers/list` reports
965
- * `current: null`. Disabling any other (unknown) ID is treated as a successful
966
- * no-op per the RFD's idempotency rule.
806
+ * `providers/disable` ends ACP ownership of the single mutually exclusive
807
+ * backend slot and restores the agent's native routing state.
967
808
  */
968
809
  async unstable_disableProvider(params) {
969
810
  if (params.providerId === PROVIDER_ID) {
970
- this.providerConfig = undefined;
971
- this.gatewayAuthRequest = undefined;
811
+ this.logger.log(`[providers/disable] sessions=${Object.keys(this.sessions).length}`);
812
+ await this.enqueueProviderUpdate(undefined);
972
813
  }
973
814
  // Unknown provider: idempotent success.
974
815
  return {};
975
816
  }
976
- /**
977
- * Resolve the effective client-managed routing config. `providers/set` takes
978
- * precedence; otherwise fall back to the legacy gateway auth request. Returns
979
- * `null` when neither is configured.
980
- */
981
817
  resolveProviderConfig() {
982
- if (this.providerConfig) {
983
- return this.providerConfig;
818
+ return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest);
819
+ }
820
+ defaultProviderConfig() {
821
+ const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest);
822
+ if (gatewayConfig) {
823
+ return gatewayConfig;
824
+ }
825
+ if (process.env.CLAUDE_CODE_USE_BEDROCK) {
826
+ return {
827
+ apiType: "bedrock",
828
+ baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
829
+ headers: {},
830
+ };
831
+ }
832
+ if (process.env.CLAUDE_CODE_USE_VERTEX) {
833
+ return {
834
+ apiType: "vertex",
835
+ baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com",
836
+ headers: {},
837
+ };
984
838
  }
985
- return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
839
+ return {
840
+ apiType: "anthropic",
841
+ baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
842
+ headers: {},
843
+ };
986
844
  }
987
845
  async logout(_params) {
988
846
  // Clear in-memory gateway credentials supplied via `authenticate` and any
@@ -1012,6 +870,8 @@ export class ClaudeAcpAgent {
1012
870
  }
1013
871
  }
1014
872
  async prompt(params) {
873
+ if (this.providerUpdate)
874
+ await this.providerUpdate;
1015
875
  const session = this.sessions[params.sessionId];
1016
876
  if (!session) {
1017
877
  throw new Error("Session not found");
@@ -1093,6 +953,12 @@ export class ClaudeAcpAgent {
1093
953
  }
1094
954
  return { stopReason: "end_turn" };
1095
955
  }
956
+ if (session.autoModeFallbackWarningPending) {
957
+ await this.sessionModes.publishFallbackWarning(params.sessionId, session);
958
+ }
959
+ if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
960
+ await this.publishTaskPlan(params.sessionId, session.taskState);
961
+ }
1096
962
  // Lazy Thinking application (R1.3): a pending config change is applied by
1097
963
  // recreating the SDK query with session resume BEFORE this turn is
1098
964
  // enqueued — never mid-turn, so only when no other prompt is in flight. A
@@ -1126,6 +992,16 @@ export class ClaudeAcpAgent {
1126
992
  // user message, so the consumer can't promote the turn from the echo.
1127
993
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
1128
994
  const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
995
+ const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
996
+ ? agentFileChangeReportRequestId(params._meta)
997
+ : undefined;
998
+ let fileChangeAudit;
999
+ if (fileChangeReportRequestId &&
1000
+ !session.fileChangeReportRequestIds.has(fileChangeReportRequestId)) {
1001
+ session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
1002
+ fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
1003
+ }
1004
+ session.titles.onPrompt(params.prompt);
1129
1005
  // Each prompt is a Turn whose deferred the persistent consumer settles once
1130
1006
  // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
1131
1007
  // turn, pushes the user message onto the streaming input, makes sure the
@@ -1133,13 +1009,24 @@ export class ClaudeAcpAgent {
1133
1009
  const turn = {
1134
1010
  promptUuid,
1135
1011
  isLocalOnlyCommand,
1012
+ ...(fileChangeAudit ? { fileChangeAudit } : {}),
1136
1013
  settled: false,
1137
1014
  resolve: () => { },
1138
1015
  reject: () => { },
1139
1016
  };
1017
+ let completeTurn;
1018
+ turn.completion = new Promise((resolve) => {
1019
+ completeTurn = resolve;
1020
+ });
1140
1021
  const response = new Promise((resolve, reject) => {
1141
- turn.resolve = resolve;
1142
- turn.reject = reject;
1022
+ turn.resolve = (result) => {
1023
+ resolve(result);
1024
+ completeTurn();
1025
+ };
1026
+ turn.reject = (error) => {
1027
+ reject(error);
1028
+ completeTurn();
1029
+ };
1143
1030
  });
1144
1031
  session.turnQueue ??= [];
1145
1032
  session.turnQueue.push(turn);
@@ -1174,6 +1061,15 @@ export class ClaudeAcpAgent {
1174
1061
  },
1175
1062
  });
1176
1063
  }
1064
+ async publishTaskPlan(sessionId, taskState) {
1065
+ await this.client.sessionUpdate({
1066
+ sessionId,
1067
+ update: {
1068
+ sessionUpdate: "plan",
1069
+ entries: taskStateToPlanEntries(taskState),
1070
+ },
1071
+ });
1072
+ }
1177
1073
  async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
1178
1074
  const goalUpdate = goalUpdateFromPrompt(prompt);
1179
1075
  if (goalUpdate !== undefined) {
@@ -1287,6 +1183,15 @@ export class ClaudeAcpAgent {
1287
1183
  await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
1288
1184
  return { outcome: "injected" };
1289
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
+ }
1290
1195
  /** Lazily start the per-session consumer that drains the SDK query stream for
1291
1196
  * the session's whole life. Idempotent: only the first `prompt()` starts it. */
1292
1197
  ensureConsumer(session, sessionId) {
@@ -1321,6 +1226,8 @@ export class ClaudeAcpAgent {
1321
1226
  // it here so the subsequent `RequestError.internalError` can forward it to
1322
1227
  // clients as structured `data`, sparing them from pattern-matching on text.
1323
1228
  let lastAssistantError;
1229
+ let lastAssistantWasUsageLimit = false;
1230
+ let lastAssistantFailureTitle;
1324
1231
  // When a streaming classifier refuses a turn, the assistant message carries
1325
1232
  // stop_reason "refusal" and structured stop_details. We capture the
1326
1233
  // human-readable explanation so the terminal `result` can surface it.
@@ -1366,19 +1273,67 @@ export class ClaudeAcpAgent {
1366
1273
  * as the turn's answer. */
1367
1274
  const sendUpdate = async (notification) => {
1368
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
+ }
1369
1284
  if (update.sessionUpdate === "agent_message_chunk") {
1370
1285
  const claudeMeta = update._meta?.claudeCode;
1371
1286
  if (!claudeMeta?.parentToolUseId) {
1372
1287
  session.emittedAssistantText = true;
1288
+ session.titles.onAssistantText(update.content);
1373
1289
  }
1374
1290
  }
1375
1291
  await this.client.sessionUpdate(notification);
1376
1292
  };
1293
+ let pendingWorkerShutdown = false;
1294
+ const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
1295
+ const sessionFailures = new SessionFailureController({
1296
+ sessionId: params.sessionId,
1297
+ state: session.sessionFailureState,
1298
+ capabilities: this.clientCapabilities,
1299
+ isCurrent: isCurrentConsumer,
1300
+ sendUpdate,
1301
+ logger: this.logger,
1302
+ });
1303
+ const createSessionFailure = async (kind, options = {}) => {
1304
+ const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1305
+ return sessionFailures.prepare(kind, {
1306
+ turnId,
1307
+ sessionScoped: options.turnScoped === false,
1308
+ title: options.title,
1309
+ details: options.details,
1310
+ severity: options.severity,
1311
+ });
1312
+ };
1313
+ const publishSessionFailure = async (kind, options = {}) => {
1314
+ const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1315
+ await sessionFailures.publish(kind, {
1316
+ turnId,
1317
+ sessionScoped: options.turnScoped === false,
1318
+ title: options.title,
1319
+ details: options.details,
1320
+ severity: options.severity,
1321
+ });
1322
+ };
1323
+ const clearFailuresFromEarlierTurns = async () => {
1324
+ const activeTurnId = session.activeTurn?.promptUuid;
1325
+ // Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
1326
+ // They are session-scoped and stay until superseded or dismissed by the user.
1327
+ await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
1328
+ };
1329
+ const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
1377
1330
  const resetTurnScratch = () => {
1378
1331
  lastAssistantTotalUsage = null;
1379
1332
  lastAssistantUsage = null;
1380
1333
  lastAssistantModel = null;
1381
1334
  lastAssistantError = undefined;
1335
+ lastAssistantWasUsageLimit = false;
1336
+ lastAssistantFailureTitle = undefined;
1382
1337
  lastRefusalExplanation = null;
1383
1338
  compactionInProgress = false;
1384
1339
  // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
@@ -1390,12 +1345,14 @@ export class ClaudeAcpAgent {
1390
1345
  // cleared when each consolidated message consumes it. #785 stopped
1391
1346
  // resetting the streamed-content tracking here but left this line.
1392
1347
  stopReason = "end_turn";
1393
- session.accumulatedUsage = {
1348
+ session.accumulatedUsage = session.activeTurn?.carriedUsage ?? {
1394
1349
  inputTokens: 0,
1395
1350
  outputTokens: 0,
1396
1351
  cachedReadTokens: 0,
1397
1352
  cachedWriteTokens: 0,
1398
1353
  };
1354
+ if (session.activeTurn)
1355
+ session.activeTurn.carriedUsage = undefined;
1399
1356
  };
1400
1357
  /** Promote a queued turn to active: it becomes the one output is attributed
1401
1358
  * to, and its scratch starts fresh. Clears the cancelled flag so a turn
@@ -1622,11 +1579,14 @@ export class ClaudeAcpAgent {
1622
1579
  };
1623
1580
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
1624
1581
  * backstop (the turn is over), and drop it from the queue. */
1625
- const settleActive = (result) => {
1582
+ const settleActive = (result, auditReason = result.stopReason === "cancelled"
1583
+ ? "cancelled"
1584
+ : "notReported") => {
1626
1585
  const turn = session.activeTurn;
1627
1586
  if (!turn || turn.settled) {
1628
1587
  return;
1629
1588
  }
1589
+ this.finishFileChangeAudit(session, turn, auditReason);
1630
1590
  // Captured before the settled flip below (isHeldOpen tests !settled).
1631
1591
  const wasHeld = isHeldOpen(turn);
1632
1592
  turn.settled = true;
@@ -1657,8 +1617,10 @@ export class ClaudeAcpAgent {
1657
1617
  disarmForceCancel(session);
1658
1618
  const turn = session.activeTurn;
1659
1619
  if (!turn || turn.settled) {
1620
+ this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
1660
1621
  return;
1661
1622
  }
1623
+ this.finishFileChangeAudit(session, turn, "providerError");
1662
1624
  turn.settled = true;
1663
1625
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
1664
1626
  session.activeTurn = null;
@@ -1670,6 +1632,31 @@ export class ClaudeAcpAgent {
1670
1632
  session.emittedAssistantText = false;
1671
1633
  turn.reject(error);
1672
1634
  };
1635
+ /** Complete a negotiated terminal failure on the prompt response itself,
1636
+ * which is the canonical AIR carrier. Legacy clients keep the historical
1637
+ * JSON-RPC rejection path. */
1638
+ const failActiveWithSessionFailure = async (kind, error, title) => {
1639
+ if (!supportsAirSessionFailures(this.clientCapabilities)) {
1640
+ failActive(error);
1641
+ return;
1642
+ }
1643
+ if (!session.activeTurn || session.activeTurn.settled) {
1644
+ this.logger.error(`Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`);
1645
+ await publishSessionFailure(kind, { turnScoped: false, title });
1646
+ return;
1647
+ }
1648
+ const failure = await createSessionFailure(kind, { title });
1649
+ if (!failure) {
1650
+ failActive(error);
1651
+ return;
1652
+ }
1653
+ sessionFailures.recordActive(failure);
1654
+ settleActive({
1655
+ stopReason: "end_turn",
1656
+ usage: sessionUsage(session),
1657
+ _meta: sessionFailureMeta(failure),
1658
+ }, "providerError");
1659
+ };
1673
1660
  /** Reject every in-flight turn — used when the stream dies. */
1674
1661
  const failAllTurns = (error) => {
1675
1662
  disarmForceCancel(session);
@@ -1680,6 +1667,7 @@ export class ClaudeAcpAgent {
1680
1667
  session.turnQueue = [];
1681
1668
  for (const turn of turns) {
1682
1669
  if (!turn.settled) {
1670
+ this.finishFileChangeAudit(session, turn, "providerError");
1683
1671
  const wasHeld = isHeldOpen(turn);
1684
1672
  turn.settled = true;
1685
1673
  if (wasHeld) {
@@ -1752,7 +1740,9 @@ export class ClaudeAcpAgent {
1752
1740
  // spent would never drain (it would swallow an unrelated later
1753
1741
  // echo-less result instead).
1754
1742
  const active = session.activeTurn;
1755
- if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
1743
+ if (active.commandFinished === "completed" ||
1744
+ active.commandFinished === "discarded" ||
1745
+ active.commandFinished === "refused") {
1756
1746
  // Finished SDK-side; any result already passed. Nothing to
1757
1747
  // track.
1758
1748
  }
@@ -1812,6 +1802,24 @@ export class ClaudeAcpAgent {
1812
1802
  if (session.query !== myQuery) {
1813
1803
  return;
1814
1804
  }
1805
+ if (pendingWorkerShutdown) {
1806
+ pendingWorkerShutdown = false;
1807
+ if (session.activeTurn) {
1808
+ if (!isHeldOpen(session.activeTurn)) {
1809
+ await failActiveWithSessionFailure("worker_shutdown", internalErrorForClient({ errorKind: "worker_shutdown" }));
1810
+ }
1811
+ else {
1812
+ // The held turn already has its authoritative terminal outcome,
1813
+ // but EOF permanently closes the non-revivable Query. Preserve
1814
+ // the turn result below and report the independent session-health
1815
+ // failure without a turnId so AIR can offer a new session.
1816
+ await publishSessionFailure("worker_shutdown", { turnScoped: false });
1817
+ }
1818
+ }
1819
+ else {
1820
+ await publishSessionFailure("worker_shutdown", { turnScoped: false });
1821
+ }
1822
+ }
1815
1823
  // The stream ended. Settle the in-flight turns FIRST, then release the
1816
1824
  // stream resources — same order as the error paths (failAllTurns before
1817
1825
  // closeQueryStream). Settling is the user-facing contract; resource
@@ -1835,6 +1843,7 @@ export class ClaudeAcpAgent {
1835
1843
  // still here was enqueued afterward and was not part of the cancel.)
1836
1844
  for (const queued of [...(session.turnQueue ?? [])]) {
1837
1845
  if (!queued.settled) {
1846
+ this.finishFileChangeAudit(session, queued, "providerError");
1838
1847
  queued.settled = true;
1839
1848
  queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
1840
1849
  }
@@ -1856,7 +1865,7 @@ export class ClaudeAcpAgent {
1856
1865
  }
1857
1866
  // CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
1858
1867
  // uuid-stamped queued command (queued/started/completed/cancelled/
1859
- // discarded) as `command_lifecycle` frames — 2-3 per prompt, since
1868
+ // discarded/refused) as `command_lifecycle` frames — 2-3 per prompt, since
1860
1869
  // prompt() stamps a uuid on every message. The frame is @internal and
1861
1870
  // absent from the SDKMessage union, so handle it BEFORE the exhaustive
1862
1871
  // switch: it must not reach `unreachable`'s error log, and a `case`
@@ -1891,6 +1900,7 @@ export class ClaudeAcpAgent {
1891
1900
  }
1892
1901
  case "completed":
1893
1902
  case "discarded":
1903
+ case "refused":
1894
1904
  case "cancelled": {
1895
1905
  // Terminal frames. Latch the fate on a still-queued turn so a
1896
1906
  // later cancel() doesn't seed an orphan entry for a command
@@ -1924,6 +1934,9 @@ export class ClaudeAcpAgent {
1924
1934
  // command folded into another turn whose result is attributed
1925
1935
  // elsewhere — either way no echo-less result remains to skip.
1926
1936
  // "discarded" = session ended with it still queued; no result.
1937
+ // "refused" (2.1.238+) = a cross-session peer message declined
1938
+ // by receive-side policy before dispatch; never a prompt-lane
1939
+ // command of ours, and no result will ever come.
1927
1940
  session.orphanCommands?.delete(frame.command_uuid);
1928
1941
  break;
1929
1942
  }
@@ -1963,6 +1976,23 @@ export class ClaudeAcpAgent {
1963
1976
  // updated Fast mode state; reconcile it with what we seeded at
1964
1977
  // session creation.
1965
1978
  await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
1979
+ // Terminal-bound slash commands (absent when none, and on
1980
+ // older CLIs). The session/new advertisement runs before any
1981
+ // init frame can be observed, so the first latch (or a
1982
+ // genuine change) re-publishes the now-filtered list.
1983
+ if (message.terminal_slash_commands &&
1984
+ JSON.stringify(message.terminal_slash_commands) !==
1985
+ JSON.stringify(session.terminalSlashCommands)) {
1986
+ session.terminalSlashCommands = message.terminal_slash_commands;
1987
+ try {
1988
+ await this.sendAvailableCommandsUpdate(message.session_id);
1989
+ }
1990
+ catch (error) {
1991
+ // Advisory reconcile only — the client keeps its current
1992
+ // (unfiltered) list; never fail the turn over it.
1993
+ this.logger.error(`Failed to re-advertise slash commands: ${error}`);
1994
+ }
1995
+ }
1966
1996
  break;
1967
1997
  case "status": {
1968
1998
  // These banners count as delivered text (via sendUpdate), so
@@ -2030,6 +2060,7 @@ export class ClaudeAcpAgent {
2030
2060
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
2031
2061
  lastAssistantUsage = null;
2032
2062
  lastAssistantTotalUsage = usedTokens ?? 0;
2063
+ session.contextUsedTokens = usedTokens ?? 0;
2033
2064
  await sendUpdate({
2034
2065
  sessionId: message.session_id,
2035
2066
  update: {
@@ -2155,13 +2186,11 @@ export class ClaudeAcpAgent {
2155
2186
  // the next prompt; only a timer could tell those apart.
2156
2187
  this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
2157
2188
  `for the active turn; failing the in-flight prompt (issue #825)`);
2158
- failActive(RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
2189
+ await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
2159
2190
  }
2160
- // The SDK generates the session title in a background task and
2161
- // persists it to the session file; `idle` is the turn-over
2162
- // signal, so it's the point at which a new title may have
2163
- // landed. Push it to the client if it changed.
2164
- await this.maybeUpdateSessionTitle(params.sessionId, session);
2191
+ // Turn-over is when a title may have landed or become
2192
+ // generatable; see SessionTitles.onTurnEnd.
2193
+ await session.titles.onTurnEnd(session);
2165
2194
  }
2166
2195
  break;
2167
2196
  }
@@ -2213,7 +2242,7 @@ export class ClaudeAcpAgent {
2213
2242
  sessionId: message.session_id,
2214
2243
  update: {
2215
2244
  sessionUpdate: "available_commands_update",
2216
- availableCommands: getAvailableSlashCommands(message.commands),
2245
+ availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
2217
2246
  },
2218
2247
  });
2219
2248
  break;
@@ -2355,9 +2384,14 @@ export class ClaudeAcpAgent {
2355
2384
  }
2356
2385
  break;
2357
2386
  case "worker_shutting_down":
2358
- // A Remote Control worker announced a graceful teardown. This is a
2359
- // live-tail signal for remote clients to explain why a session went
2360
- // away; it's not meaningful for a local stdio ACP session.
2387
+ // Defer until stream end. The announcement is durable and may be
2388
+ // replayed before later frames, but those frames do not prove that
2389
+ // a new worker epoch began: they can be buffered output from the
2390
+ // shutting-down worker. Keep the signal armed until the transport
2391
+ // actually ends. Deliberately do not add a quiet-period timer: the
2392
+ // iterator has no replay/live boundary, so a timeout could publish
2393
+ // while a slow replay is still in flight.
2394
+ pendingWorkerShutdown = true;
2361
2395
  break;
2362
2396
  case "elicitation_complete": {
2363
2397
  // A url-mode MCP elicitation finished server-side. Let the client
@@ -2377,10 +2411,21 @@ export class ClaudeAcpAgent {
2377
2411
  }
2378
2412
  case "plugin_install":
2379
2413
  case "notification":
2380
- case "api_retry":
2381
2414
  case "thinking_tokens":
2382
2415
  // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
2383
2416
  break;
2417
+ case "api_retry": {
2418
+ const title = message.error_status === null
2419
+ ? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
2420
+ : `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
2421
+ await publishSessionFailure(message.error_status === null
2422
+ ? "transport_lost"
2423
+ : providerFailureCategory(message.error), {
2424
+ title,
2425
+ severity: "warning",
2426
+ });
2427
+ break;
2428
+ }
2384
2429
  case "model_refusal_fallback": {
2385
2430
  // The SDK retried a refused turn on the fallback model and made
2386
2431
  // the swap persistent for the session. Without a notice the
@@ -2394,26 +2439,53 @@ export class ClaudeAcpAgent {
2394
2439
  // CLIs, where "revert" marked a turn-only fallback — for that
2395
2440
  // direction the session stays on the original model, so skip
2396
2441
  // the persistent-swap claim and the state sync.
2397
- const persistent = message.direction !== "revert";
2442
+ //
2443
+ // `scope` (CLI 2.1.232+) marks WHERE the fallback happened:
2444
+ // "local" means a subagent / side-question / background fork
2445
+ // response fell back and the session model is unchanged, so
2446
+ // syncing the picker would advertise a model the session
2447
+ // isn't running. Absent scope means an older CLI, where every
2448
+ // retry was a session-level swap — treat as "session".
2449
+ const local = message.scope === "local";
2450
+ const persistent = message.direction !== "revert" && !local;
2398
2451
  const category = message.api_refusal_category
2399
2452
  ? ` (${message.api_refusal_category})`
2400
2453
  : "";
2401
- const explanation = message.api_refusal_explanation
2402
- ? `\n\n${message.api_refusal_explanation}`
2403
- : "";
2404
2454
  const outcome = persistent
2405
2455
  ? `The session will continue on ${message.fallback_model}.`
2406
- : `The session stays on ${message.original_model}.`;
2407
- await sendUpdate({
2408
- sessionId: message.session_id,
2409
- update: {
2410
- sessionUpdate: "agent_message_chunk",
2411
- content: {
2412
- type: "text",
2413
- text: `**Model fallback:** ${message.original_model} declined this request${category}; retried with ${message.fallback_model}. ${outcome}${explanation}`,
2456
+ : local
2457
+ ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
2458
+ : `The session stays on ${message.original_model}.`;
2459
+ const fallbackSummary = `${message.original_model} declined this request${category}; ` +
2460
+ `retried with ${message.fallback_model}. ${outcome}`;
2461
+ const explanation = message.api_refusal_explanation || undefined;
2462
+ const fallbackNotice = explanation
2463
+ ? `${fallbackSummary}\n\n${explanation}`
2464
+ : fallbackSummary;
2465
+ // A silent model swap is a session-level advisory, not something the model said.
2466
+ // Clients that negotiated typed records get it as one; the rest keep the bold-label
2467
+ // transcript line, which was the only way to flag it before.
2468
+ if (supportsAirSessionFailures(this.clientCapabilities)) {
2469
+ const useDetails = explanation !== undefined &&
2470
+ fallbackSummary.length + 2 + explanation.length >
2471
+ MAX_INLINE_FAILURE_TITLE_LENGTH;
2472
+ await publishSessionFailure("advisory", {
2473
+ title: useDetails ? fallbackSummary : fallbackNotice,
2474
+ ...(useDetails ? { details: explanation } : {}),
2475
+ });
2476
+ }
2477
+ else {
2478
+ await sendUpdate({
2479
+ sessionId: message.session_id,
2480
+ update: {
2481
+ sessionUpdate: "agent_message_chunk",
2482
+ content: {
2483
+ type: "text",
2484
+ text: `**Model fallback:** ${fallbackNotice}`,
2485
+ },
2414
2486
  },
2415
- },
2416
- });
2487
+ });
2488
+ }
2417
2489
  if (persistent) {
2418
2490
  await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
2419
2491
  }
@@ -2498,6 +2570,8 @@ export class ClaudeAcpAgent {
2498
2570
  // user-turn lifecycle (stop reason, settles, failActive,
2499
2571
  // slash-command output forwarding), though their cost is real.
2500
2572
  const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
2573
+ const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption;
2574
+ const pendingExitPlanContextReset = session.pendingExitPlanContextReset;
2501
2575
  try {
2502
2576
  // Reconcile the Fast mode toggle with the SDK's reported state.
2503
2577
  // Gated to user-driven turns like every other side effect below;
@@ -2650,7 +2724,10 @@ export class ClaudeAcpAgent {
2650
2724
  });
2651
2725
  }
2652
2726
  if (session.cancelled) {
2727
+ session.pendingExitPlanModeInterruption = undefined;
2728
+ session.pendingExitPlanContextReset = undefined;
2653
2729
  if (!isAutonomousResult) {
2730
+ await clearFailuresFromEarlierTurns();
2654
2731
  stopReason = "cancelled";
2655
2732
  }
2656
2733
  break;
@@ -2686,6 +2763,37 @@ export class ClaudeAcpAgent {
2686
2763
  }
2687
2764
  break;
2688
2765
  }
2766
+ await clearFailuresFromEarlierTurns();
2767
+ // `interrupt: true` is required to make "No, keep planning"
2768
+ // terminate the ACP turn. Claude represents that intentional
2769
+ // interrupt as an error-shaped diagnostic, so translate only a
2770
+ // diagnostic causally paired with the recorded ExitPlanMode
2771
+ // permission response.
2772
+ const diagnostic = executionDiagnostic(message);
2773
+ if (pendingExitPlanModeInterruption &&
2774
+ pendingExitPlanModeInterruption.toolResultSeen &&
2775
+ message.is_error &&
2776
+ diagnostic &&
2777
+ /(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
2778
+ /(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic)) {
2779
+ session.pendingExitPlanModeInterruption = undefined;
2780
+ if (pendingExitPlanContextReset &&
2781
+ pendingExitPlanContextReset.toolUseId ===
2782
+ pendingExitPlanModeInterruption.toolUseId) {
2783
+ await this.exitPlan.restart(params.sessionId, session, pendingExitPlanContextReset);
2784
+ return;
2785
+ }
2786
+ stopReason = "cancelled";
2787
+ settleOrDefer({ stopReason: "cancelled", usage: sessionUsage(session) });
2788
+ break;
2789
+ }
2790
+ if (pendingExitPlanModeInterruption) {
2791
+ // This result ended the interrupted cycle without the exact
2792
+ // correlated cancellation shape. Never carry its marker into
2793
+ // a later turn, whether or not the tool result was observed.
2794
+ session.pendingExitPlanModeInterruption = undefined;
2795
+ session.pendingExitPlanContextReset = undefined;
2796
+ }
2689
2797
  // A refusal can arrive on any result subtype (and may even set
2690
2798
  // is_error), so handle it before the subtype switch — otherwise the
2691
2799
  // is_error throw below would surface it as an internal error. The
@@ -2722,10 +2830,15 @@ export class ClaudeAcpAgent {
2722
2830
  settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
2723
2831
  break;
2724
2832
  }
2833
+ if (!message.is_error && lastAssistantModel !== null) {
2834
+ const activeTurnId = session.activeTurn?.promptUuid;
2835
+ await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
2836
+ (failure.severity === "warning" && failure.turnId === activeTurnId));
2837
+ }
2725
2838
  switch (message.subtype) {
2726
2839
  case "success": {
2727
2840
  if (message.result.includes("Please run /login")) {
2728
- failActive(RequestError.authRequired());
2841
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
2729
2842
  break;
2730
2843
  }
2731
2844
  if (message.stop_reason === "max_tokens") {
@@ -2733,7 +2846,7 @@ export class ClaudeAcpAgent {
2733
2846
  break;
2734
2847
  }
2735
2848
  if (message.is_error) {
2736
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2849
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), lastAssistantFailureTitle ?? message.result);
2737
2850
  break;
2738
2851
  }
2739
2852
  // The result text is forwarded in two cases. Local-only
@@ -2769,17 +2882,29 @@ export class ClaudeAcpAgent {
2769
2882
  break;
2770
2883
  }
2771
2884
  if (message.is_error) {
2772
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2885
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype));
2773
2886
  break;
2774
2887
  }
2775
2888
  stopReason = "end_turn";
2776
2889
  break;
2777
2890
  }
2778
2891
  case "error_max_budget_usd":
2892
+ if (message.is_error) {
2893
+ await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2894
+ break;
2895
+ }
2896
+ stopReason = "max_turn_requests";
2897
+ break;
2779
2898
  case "error_max_turns":
2899
+ if (message.is_error) {
2900
+ await failActiveWithSessionFailure("context_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2901
+ break;
2902
+ }
2903
+ stopReason = "max_turn_requests";
2904
+ break;
2780
2905
  case "error_max_structured_output_retries":
2781
2906
  if (message.is_error) {
2782
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2907
+ await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2783
2908
  break;
2784
2909
  }
2785
2910
  stopReason = "max_turn_requests";
@@ -2917,6 +3042,7 @@ export class ClaudeAcpAgent {
2917
3042
  const nextUsage = totalTokens(lastAssistantUsage);
2918
3043
  if (nextUsage !== lastAssistantTotalUsage) {
2919
3044
  lastAssistantTotalUsage = nextUsage;
3045
+ session.contextUsedTokens = nextUsage;
2920
3046
  await sendUpdate({
2921
3047
  sessionId: params.sessionId,
2922
3048
  update: {
@@ -3053,6 +3179,11 @@ export class ClaudeAcpAgent {
3053
3179
  if (message.type === "assistant" && message.parent_tool_use_id === null) {
3054
3180
  lastAssistantUsage = snapshotFromUsage(message.message.usage);
3055
3181
  lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
3182
+ session.contextUsedTokens = lastAssistantTotalUsage;
3183
+ lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
3184
+ if (message.error || lastAssistantWasUsageLimit) {
3185
+ lastAssistantFailureTitle = assistantMessageText(message.message);
3186
+ }
3056
3187
  if (message.message.model && message.message.model !== "<synthetic>") {
3057
3188
  lastAssistantModel = message.message.model;
3058
3189
  }
@@ -3111,7 +3242,18 @@ export class ClaudeAcpAgent {
3111
3242
  break;
3112
3243
  }
3113
3244
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3114
- failActive(RequestError.authRequired());
3245
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), assistantMessageText(message.message));
3246
+ break;
3247
+ }
3248
+ // AIR receives this provider condition on the terminal prompt
3249
+ // response as a typed failure whose title is the exact assistant
3250
+ // error text captured above. Do not duplicate that text as an
3251
+ // ordinary assistant message; legacy clients retain the historical
3252
+ // transcript behavior.
3253
+ if (message.type === "assistant" &&
3254
+ message.parent_tool_use_id === null &&
3255
+ (message.error || isSyntheticUsageLimitMessage(message.message)) &&
3256
+ supportsAirSessionFailures(this.clientCapabilities)) {
3115
3257
  break;
3116
3258
  }
3117
3259
  let content;
@@ -3188,6 +3330,7 @@ export class ClaudeAcpAgent {
3188
3330
  else {
3189
3331
  content = message.message.content;
3190
3332
  }
3333
+ const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
3191
3334
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
3192
3335
  clientCapabilities: this.clientCapabilities,
3193
3336
  parentToolUseId: message.parent_tool_use_id,
@@ -3206,7 +3349,7 @@ export class ClaudeAcpAgent {
3206
3349
  // filtered out of `content` above; blocks that do pass through
3207
3350
  // (e.g. a subagent image) carry the stamped parentToolUseId
3208
3351
  // meta and are excluded there.
3209
- await sendUpdate(notification);
3352
+ await sendUpdate(acceptedPlanToolResult(notification, acceptedPlanToolUseId));
3210
3353
  }
3211
3354
  break;
3212
3355
  }
@@ -3273,13 +3416,26 @@ export class ClaudeAcpAgent {
3273
3416
  }
3274
3417
  break;
3275
3418
  }
3276
- // `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
3277
- // flows) is safe to drop: turn lifecycle here is driven by
3278
- // results/idle, and the client owns its own transcript view.
3419
+ case "conversation_reset": {
3420
+ // The SDK has switched to a fresh conversation, whose Task* IDs
3421
+ // and task store are independent of the previous transcript.
3422
+ // Clear both the in-memory snapshot and the client's visible plan
3423
+ // before any follow-up prompt can republish stale tasks.
3424
+ session.taskState.clear();
3425
+ await this.publishTaskPlan(params.sessionId, session.taskState);
3426
+ // A reset mounts a fresh transcript (`new_conversation_id`), so our
3427
+ // cached title no longer describes the session: drop it and
3428
+ // re-evaluate at the next turn-end.
3429
+ session.titles.reset();
3430
+ break;
3431
+ }
3279
3432
  case "tool_use_summary":
3280
- case "auth_status":
3281
3433
  case "prompt_suggestion":
3282
- case "conversation_reset":
3434
+ break;
3435
+ case "auth_status":
3436
+ if (!message.isAuthenticating && message.error === undefined) {
3437
+ await sessionFailures.clear((failure) => failure.kind === "auth_required");
3438
+ }
3283
3439
  break;
3284
3440
  default:
3285
3441
  unreachable(message, this.logger);
@@ -3308,6 +3464,19 @@ export class ClaudeAcpAgent {
3308
3464
  message.includes("process exited with") ||
3309
3465
  message.includes("process terminated by signal") ||
3310
3466
  message.includes("Failed to write to process stdin"));
3467
+ if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
3468
+ if (!isHeldOpen(session.activeTurn)) {
3469
+ await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
3470
+ }
3471
+ else {
3472
+ // The held turn keeps its recorded PromptResponse outcome, while the
3473
+ // exhausted Query makes the session independently unrecoverable.
3474
+ await publishSessionFailure("transport_lost", { turnScoped: false });
3475
+ }
3476
+ }
3477
+ else {
3478
+ await publishSessionFailure("transport_lost", { turnScoped: false });
3479
+ }
3311
3480
  // Either way the query iterator is finished and the consumer is exiting,
3312
3481
  // so release its resources via closeQueryStream (idempotent). A process
3313
3482
  // death is unrecoverable, so additionally evict the session so the client
@@ -3321,7 +3490,9 @@ export class ClaudeAcpAgent {
3321
3490
  }
3322
3491
  else {
3323
3492
  this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
3324
- failAllTurns(error);
3493
+ failAllTurns(supportsAirSessionFailures(this.clientCapabilities)
3494
+ ? internalErrorForClient({ errorKind: "transport_lost" })
3495
+ : error);
3325
3496
  this.closeQueryStream(session);
3326
3497
  }
3327
3498
  }
@@ -3351,6 +3522,7 @@ export class ClaudeAcpAgent {
3351
3522
  }
3352
3523
  }
3353
3524
  async cancel(params) {
3525
+ this.exitPlan.cancel(params.sessionId);
3354
3526
  const session = this.sessions[params.sessionId];
3355
3527
  if (!session) {
3356
3528
  return;
@@ -3364,6 +3536,9 @@ export class ClaudeAcpAgent {
3364
3536
  if (session.queryRecreateInFlight) {
3365
3537
  await session.queryRecreateInFlight;
3366
3538
  }
3539
+ session.cancelled = true;
3540
+ session.pendingExitPlanModeInterruption = undefined;
3541
+ session.pendingExitPlanContextReset = undefined;
3367
3542
  // The stream already ended (see closeQueryStream): every in-flight turn was
3368
3543
  // settled when it closed, and there is no live query to interrupt. Calling
3369
3544
  // query.interrupt() on a finished iterator could reject and surface from
@@ -3371,7 +3546,6 @@ export class ClaudeAcpAgent {
3371
3546
  if (session.queryClosed) {
3372
3547
  return;
3373
3548
  }
3374
- session.cancelled = true;
3375
3549
  // A priority steer may still be queued in the SDK when cancellation
3376
3550
  // settles its owning turn. Its later echo matches no live turn, and its
3377
3551
  // result must be skipped rather than promoted onto the next prompt.
@@ -3395,6 +3569,7 @@ export class ClaudeAcpAgent {
3395
3569
  if (session.turnQueue) {
3396
3570
  for (const turn of session.turnQueue) {
3397
3571
  if (turn !== session.activeTurn && !turn.settled) {
3572
+ this.finishFileChangeAudit(session, turn, "cancelled");
3398
3573
  turn.settled = true;
3399
3574
  // Deliberately no `usage`: a queued turn never ran, so the session
3400
3575
  // accumulator (the active turn's tally) is not its spend.
@@ -3414,7 +3589,9 @@ export class ClaudeAcpAgent {
3414
3589
  // never see lifecycle frames, so commandStarted/commandFinished stay
3415
3590
  // unset and every turn takes the plain-seed path below).
3416
3591
  for (const turn of orphanedTurns) {
3417
- if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
3592
+ if (turn.commandFinished === "completed" ||
3593
+ turn.commandFinished === "discarded" ||
3594
+ turn.commandFinished === "refused") {
3418
3595
  // The command already finished SDK-side and its terminal frame was
3419
3596
  // consumed while the turn sat queued — nothing is left to skip, and
3420
3597
  // a seeded entry would never drain.
@@ -3456,6 +3633,7 @@ export class ClaudeAcpAgent {
3456
3633
  {
3457
3634
  const active = session.activeTurn;
3458
3635
  if (isHeldOpen(active)) {
3636
+ this.finishFileChangeAudit(session, active, "cancelled");
3459
3637
  active.settled = true;
3460
3638
  // Mirror settleActive's invariants (it is consumer-scoped and
3461
3639
  // unreachable from here): disarm the backstop — none should be
@@ -3639,27 +3817,19 @@ export class ClaudeAcpAgent {
3639
3817
  return {};
3640
3818
  }
3641
3819
  async setSessionMode(params) {
3642
- const session = this.sessions[params.sessionId];
3643
- if (!session) {
3644
- throw new Error("Session not found");
3645
- }
3646
3820
  // A lazy Thinking recreate may be swapping the session's query right now;
3647
3821
  // join it (mirroring prompt()) so `setPermissionMode` below lands on the
3648
3822
  // replacement query after cutover instead of an about-to-close one — and
3649
3823
  // so the mode isn't silently dropped from a query built from the
3650
3824
  // pre-await state snapshot. Safe: `recreateSessionQuery` never rejects.
3651
- if (session.queryRecreateInFlight) {
3825
+ // The session-not-found and SESSION_ENDED guards the fork inlined here now
3826
+ // live in `SessionModeManager.requireOpenSession`, which raises the same
3827
+ // errors, so they are not duplicated in front of the delegation below.
3828
+ const session = this.sessions[params.sessionId];
3829
+ if (session?.queryRecreateInFlight) {
3652
3830
  await session.queryRecreateInFlight;
3653
3831
  }
3654
- // The SDK query stream already ended (see closeQueryStream); the session is
3655
- // a husk and `query.setPermissionMode` below would act on a closed query.
3656
- // Fail with the same clear message prompt()/cancel() give for a dead stream.
3657
- if (session.queryClosed) {
3658
- throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
3659
- }
3660
- await this.applySessionMode(params.sessionId, params.modeId);
3661
- await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId);
3662
- return {};
3832
+ return this.sessionModes.setSessionMode(params);
3663
3833
  }
3664
3834
  async setSessionConfigOption(params) {
3665
3835
  const session = this.sessions[params.sessionId];
@@ -3687,10 +3857,9 @@ export class ClaudeAcpAgent {
3687
3857
  if (!option) {
3688
3858
  throw new Error(`Unknown config option: ${params.configId}`);
3689
3859
  }
3690
- // Fast mode is always emitted as an "on"/"off" select, but a native
3691
- // boolean set value is still accepted for compatibility (R2.3), so it
3692
- // bypasses the string-only validation the select-style options below
3693
- // rely on.
3860
+ // Fast mode carries a boolean value (for Clients that opted into boolean
3861
+ // config options) or the "on"/"off" select fallback, so it bypasses the
3862
+ // string-only validation the select-style options below rely on.
3694
3863
  if (params.configId === FAST_MODE_CONFIG_ID) {
3695
3864
  await this.applyFastMode(session, resolveFastModeEnabled(params));
3696
3865
  return { configOptions: session.configOptions };
@@ -3759,15 +3928,10 @@ export class ClaudeAcpAgent {
3759
3928
  // Use the canonical option value so downstream code always receives the
3760
3929
  // model ID rather than the caller-supplied alias.
3761
3930
  const resolvedValue = validValue.value;
3931
+ let effectiveValue = resolvedValue;
3762
3932
  if (params.configId === MODE_CONFIG_ID) {
3763
- await this.applySessionMode(params.sessionId, resolvedValue);
3764
- await this.client.sessionUpdate({
3765
- sessionId: params.sessionId,
3766
- update: {
3767
- sessionUpdate: "current_mode_update",
3768
- currentModeId: resolvedValue,
3769
- },
3770
- });
3933
+ effectiveValue = await this.sessionModes.selectMode(params.sessionId, resolvedValue);
3934
+ await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
3771
3935
  }
3772
3936
  else if (params.configId === MODEL_CONFIG_ID) {
3773
3937
  await this.sessions[params.sessionId].query.setModel(resolvedValue);
@@ -3775,50 +3939,41 @@ export class ClaudeAcpAgent {
3775
3939
  // Effort SDK sync is handled inside applyConfigOptionValue so that direct
3776
3940
  // effort changes and effort changes induced by a model switch go through
3777
3941
  // the same path.
3778
- await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
3942
+ await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
3779
3943
  return { configOptions: session.configOptions };
3780
3944
  }
3781
- async applySessionMode(sessionId, modeId) {
3782
- switch (modeId) {
3783
- case "auto":
3784
- case "default":
3785
- case "acceptEdits":
3786
- case "bypassPermissions":
3787
- case "dontAsk":
3788
- case "plan":
3789
- break;
3790
- default:
3791
- throw new Error("Invalid Mode");
3792
- }
3793
- const session = this.sessions[sessionId];
3794
- if (!session) {
3795
- throw new Error("Session not found");
3796
- }
3797
- if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
3798
- throw new Error(`Mode ${modeId} is not available in this session`);
3799
- }
3800
- try {
3801
- await session.query.setPermissionMode(modeId);
3802
- }
3803
- catch (error) {
3804
- if (error instanceof Error) {
3805
- if (!error.message) {
3806
- error.message = "Invalid Mode";
3807
- }
3808
- throw error;
3809
- }
3810
- else {
3811
- // eslint-disable-next-line preserve-caught-error
3812
- throw new Error("Invalid Mode");
3813
- }
3814
- }
3815
- }
3816
3945
  async replaySessionHistory(sessionId) {
3817
3946
  const toolUseCache = {};
3818
3947
  const messages = await getSessionMessages(sessionId);
3819
- const forwardSubagentText = this.sessions[sessionId]?.forwardSubagentText ??
3820
- supportsSubagentTranscript(this.clientCapabilities);
3948
+ const session = this.sessions[sessionId];
3949
+ const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
3950
+ const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
3951
+ const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
3952
+ const sessionFailures = session && supportsTypedFailures
3953
+ ? new SessionFailureController({
3954
+ sessionId,
3955
+ state: session.sessionFailureState,
3956
+ capabilities: this.clientCapabilities,
3957
+ isCurrent: () => this.sessions[sessionId] === session,
3958
+ sendUpdate: (notification) => this.client.sessionUpdate(notification),
3959
+ logger: this.logger,
3960
+ })
3961
+ : undefined;
3962
+ let replayTurnId;
3963
+ // Stop-hook additionalContext is persisted as an internal user message.
3964
+ // Once that marker (or the internal tool itself) appears, suppress the
3965
+ // whole audit exchange until its tool result. This also hides a disobedient
3966
+ // model's separate prose message, while an ordinary next user prompt safely
3967
+ // ends an incomplete audit lane.
3968
+ let replayingFileChangeAudit = false;
3969
+ const replayFileChangeAuditToolUseIds = new Set();
3821
3970
  for (const message of messages) {
3971
+ if (message.type === "user" &&
3972
+ message.parent_tool_use_id === null &&
3973
+ typeof message.uuid === "string" &&
3974
+ message.uuid.length > 0) {
3975
+ replayTurnId = message.uuid;
3976
+ }
3822
3977
  // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
3823
3978
  // observe live (resumed/loaded sessions), so rewind/resume can translate
3824
3979
  // a client-supplied id without an extra getSessionMessages read. Not read
@@ -3834,6 +3989,21 @@ export class ClaudeAcpAgent {
3834
3989
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3835
3990
  continue;
3836
3991
  }
3992
+ // Capable clients saw every synthetic usage-limit message as a typed
3993
+ // failure live, so replay it at the same transcript position. The
3994
+ // preceding persisted user uuid is the live prompt uuid and therefore
3995
+ // recreates the same incident identity. Only the latest limit not
3996
+ // followed by a real model answer remains active internally.
3997
+ if (sessionFailures &&
3998
+ message.type === "assistant" &&
3999
+ message.parent_tool_use_id === null &&
4000
+ isSyntheticUsageLimitMessage(message.message)) {
4001
+ const title = assistantMessageText(message.message);
4002
+ if (title) {
4003
+ await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
4004
+ }
4005
+ continue;
4006
+ }
3837
4007
  // @ts-expect-error - untyped in SDK but we handle all of these
3838
4008
  let content = message.message.content;
3839
4009
  const parentToolUseId = parentToolUseIdOf(message);
@@ -3846,6 +4016,57 @@ export class ClaudeAcpAgent {
3846
4016
  if (content === null)
3847
4017
  continue;
3848
4018
  }
4019
+ const auditBlocks = Array.isArray(content)
4020
+ ? content.filter((block) => typeof block === "object" && block !== null)
4021
+ : [];
4022
+ const hasFileChangeAuditMarker = (typeof content === "string" && containsFileChangeAuditMarker(content)) ||
4023
+ auditBlocks.some((block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text));
4024
+ const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) => (block.type === "tool_use" ||
4025
+ block.type === "server_tool_use" ||
4026
+ block.type === "mcp_tool_use") &&
4027
+ typeof block.name === "string" &&
4028
+ isFileChangeAuditTool(block.name) &&
4029
+ typeof block.id === "string"
4030
+ ? [block.id]
4031
+ : []);
4032
+ const replayMessageRole = message.message
4033
+ ?.role;
4034
+ if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
4035
+ replayingFileChangeAudit = true;
4036
+ for (const toolUseId of fileChangeAuditToolUseIds) {
4037
+ replayFileChangeAuditToolUseIds.add(toolUseId);
4038
+ }
4039
+ continue;
4040
+ }
4041
+ if (replayingFileChangeAudit) {
4042
+ const toolResultIds = auditBlocks.flatMap((block) => (block.type === "tool_result" || block.type === "mcp_tool_result") &&
4043
+ typeof block.tool_use_id === "string"
4044
+ ? [block.tool_use_id]
4045
+ : []);
4046
+ let completedReport = false;
4047
+ for (const toolUseId of toolResultIds) {
4048
+ if (replayFileChangeAuditToolUseIds.delete(toolUseId))
4049
+ completedReport = true;
4050
+ }
4051
+ if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
4052
+ replayingFileChangeAudit = false;
4053
+ continue;
4054
+ }
4055
+ // A denied attempt to call another tool is still part of the hidden
4056
+ // lane. Its result must not end replay suppression before the report.
4057
+ if (toolResultIds.length > 0) {
4058
+ continue;
4059
+ }
4060
+ // The next real user prompt is already represented by the client and
4061
+ // starts a new turn; do not let a missing audit result hide it or the
4062
+ // rest of the replay.
4063
+ if (replayMessageRole === "user") {
4064
+ replayingFileChangeAudit = false;
4065
+ }
4066
+ else {
4067
+ continue;
4068
+ }
4069
+ }
3849
4070
  for (const notification of toAcpNotifications(
3850
4071
  // @ts-expect-error - untyped in SDK but we handle all of these
3851
4072
  content,
@@ -3873,20 +4094,26 @@ export class ClaudeAcpAgent {
3873
4094
  /** Forward a permission request to the client, wiring the tool call's
3874
4095
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
3875
4096
  * while the client's prompt is still open the signal aborts, the SDK sends
3876
- * `$/cancel_request`, and the client settles the request (a `cancelled`
3877
- * outcome or a `requestCancelled` rejection). Either way we surface the same
3878
- * "Tool use aborted" the callers already expect, so a cancelled dialog no
3879
- * longer leaves the `await` hanging. */
4097
+ * `$/cancel_request`, and our local abort race settles even if the client
4098
+ * ignores it. A `cancelled` outcome, request rejection, and local abort all
4099
+ * surface the same "Tool use aborted" the callers already expect. */
3880
4100
  async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
4101
+ if (signal.aborted)
4102
+ throw new Error("Tool use aborted");
3881
4103
  // The SDK may invoke `canUseTool` (and therefore this permission request)
3882
4104
  // before the assistant message's tool_use block streams to us. Some ACP clients
3883
4105
  // expect the `tool_call` a permission request references to already exist,
3884
4106
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
3885
4107
  // later refines it with a `tool_call_update` rather than emitting a
3886
4108
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
3887
- await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
4109
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal);
4110
+ if (signal.aborted)
4111
+ throw new Error("Tool use aborted");
4112
+ // Do not rely on every ACP client settling requestPermission after the
4113
+ // cancellation signal. The local race guarantees that Claude's tool call
4114
+ // is released even when an older or broken client ignores $/cancel_request.
3888
4115
  try {
3889
- return await this.client.requestPermission(params, signal);
4116
+ return await raceWithAbort(this.client.requestPermission(params, signal), signal);
3890
4117
  }
3891
4118
  catch (error) {
3892
4119
  if (signal.aborted) {
@@ -3907,7 +4134,7 @@ export class ClaudeAcpAgent {
3907
4134
  * are resolved at tool_result time instead (see `toAcpNotifications`).
3908
4135
  * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
3909
4136
  * that spawned it, matching the streamed path's `_meta`. */
3910
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
4137
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal) {
3911
4138
  const session = this.sessions[sessionId];
3912
4139
  if (!session) {
3913
4140
  return;
@@ -3927,10 +4154,20 @@ export class ClaudeAcpAgent {
3927
4154
  },
3928
4155
  };
3929
4156
  }
3930
- await this.client.sessionUpdate({ sessionId, update });
4157
+ try {
4158
+ const emission = this.client.sessionUpdate({ sessionId, update });
4159
+ await (signal ? raceWithAbort(emission, signal) : emission);
4160
+ }
4161
+ catch (error) {
4162
+ // The set is also the de-duplication guard for the later streamed
4163
+ // tool_use. Keep it truthful: if emission failed, that path must still
4164
+ // be allowed to publish the tool call instead of refining a phantom one.
4165
+ session.emittedToolCalls.delete(toolCallId);
4166
+ throw error;
4167
+ }
3931
4168
  }
3932
4169
  canUseTool(sessionId) {
3933
- return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
4170
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, }) => {
3934
4171
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
3935
4172
  const session = this.sessions[sessionId];
3936
4173
  if (!session) {
@@ -3939,6 +4176,26 @@ export class ClaudeAcpAgent {
3939
4176
  message: "Session not found",
3940
4177
  };
3941
4178
  }
4179
+ const fileChangeAudit = session.activeTurn?.fileChangeAudit;
4180
+ if (isFileChangeAuditReportPhase(fileChangeAudit)) {
4181
+ // The hidden continuation is an audit-only lane: it may submit the
4182
+ // wrapper-owned report, but it must not run another command after the
4183
+ // user-visible answer has already completed.
4184
+ if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
4185
+ return { behavior: "allow", updatedInput: toolInput };
4186
+ }
4187
+ return {
4188
+ behavior: "deny",
4189
+ message: "Only the internal file-change report is allowed during the audit.",
4190
+ };
4191
+ }
4192
+ // The tool is intentionally unusable outside a negotiated audit turn.
4193
+ if (isFileChangeAuditTool(toolName)) {
4194
+ return {
4195
+ behavior: "deny",
4196
+ message: "No file-change report was requested for this turn.",
4197
+ };
4198
+ }
3942
4199
  // When the tool call originates inside a subagent, attribute the eagerly
3943
4200
  // emitted tool_call (and the permission request itself) to the Agent/Task
3944
4201
  // tool call that spawned the subagent, mirroring the streamed subagent
@@ -3963,155 +4220,81 @@ export class ClaudeAcpAgent {
3963
4220
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
3964
4221
  // Like permission requests, the elicitation references this toolUseID, so
3965
4222
  // make sure the tool_call has surfaced to the client before we send it.
3966
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
4223
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal);
3967
4224
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
3968
4225
  }
3969
- if (toolName === "ExitPlanMode") {
3970
- const optionsAll = [
3971
- { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
3972
- {
3973
- kind: "allow_always",
3974
- name: "Yes, and auto-accept edits",
3975
- optionId: "acceptEdits",
3976
- },
3977
- { kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
3978
- { kind: "reject_once", name: "No, keep planning", optionId: "plan" },
3979
- ];
3980
- if (ALLOW_BYPASS) {
3981
- optionsAll.unshift({
3982
- kind: "allow_always",
3983
- name: "Yes, and bypass permissions",
3984
- optionId: "bypassPermissions",
3985
- });
3986
- }
3987
- // Filter against the session's currently-advertised modes so we never
3988
- // present options the active model can't honor (e.g. `auto` on Haiku).
3989
- // `bypassPermissions` is already covered by `availableModes` via
3990
- // `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
3991
- // "keep planning" reject path; it's always present in `availableModes`.
3992
- const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
3993
- const response = await this.requestPermissionFromClient({
3994
- options,
3995
- sessionId,
3996
- toolCall: {
3997
- toolCallId: toolUseID,
3998
- rawInput: toolInput,
3999
- ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
4000
- // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
4001
- // so clients can rely on one shape everywhere.
4002
- ...(parentToolUseId
4003
- ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
4004
- : {}),
4005
- },
4006
- }, toolName, signal, parentToolUseId);
4007
- if (signal.aborted || response.outcome?.outcome === "cancelled") {
4008
- throw new Error("Tool use aborted");
4009
- }
4010
- const selectedMode = response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
4011
- const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
4012
- if (selectedModeWasOffered &&
4013
- (selectedMode === "default" ||
4014
- selectedMode === "acceptEdits" ||
4015
- selectedMode === "auto" ||
4016
- selectedMode === "bypassPermissions")) {
4017
- await this.client.sessionUpdate({
4018
- sessionId,
4019
- update: {
4020
- sessionUpdate: "current_mode_update",
4021
- currentModeId: selectedMode,
4022
- },
4023
- });
4024
- await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode);
4025
- return {
4026
- behavior: "allow",
4027
- updatedInput: toolInput,
4028
- updatedPermissions: suggestions ?? [
4029
- { type: "setMode", mode: selectedMode, destination: "session" },
4030
- ],
4031
- };
4032
- }
4033
- else {
4034
- return {
4035
- behavior: "deny",
4036
- message: "User rejected request to exit plan mode.",
4037
- };
4038
- }
4039
- }
4040
- // In bypass mode the CLI skips permission checks itself; the asks that
4041
- // still reach canUseTool are the ones it insists on prompting for even
4042
- // under --dangerously-skip-permissions. Keep auto-allowing those —
4043
- // bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
4044
- // user explicitly configured a permissions.ask rule for this tool, and
4045
- // the SDK's guidance is that hosts running auto-approval must treat such
4046
- // asks as a human prompt. Fall through to the normal request below.
4047
- if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
4048
- return {
4049
- behavior: "allow",
4050
- updatedInput: toolInput,
4051
- updatedPermissions: suggestions ?? [
4052
- { type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
4053
- ],
4226
+ // Do not auto-allow here based on the session's advertised mode. Claude
4227
+ // Code applies bypassPermissions before invoking canUseTool; a request
4228
+ // that still reaches this callback is deliberately bypass-immune (for
4229
+ // example a safety check, a tool requiring user interaction, or an
4230
+ // explicit ask rule). Re-applying bypass in the host would erase that
4231
+ // provider safety decision.
4232
+ const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, matchedAskRule !== undefined);
4233
+ const presentation = buildClaudePermissionPresentation({
4234
+ toolName,
4235
+ input: toolInput,
4236
+ toolUseID,
4237
+ cwd: session.cwd,
4238
+ supportsTerminalOutput,
4239
+ blockedPath,
4240
+ title,
4241
+ displayName,
4242
+ description,
4243
+ decisionReason,
4244
+ });
4245
+ if (parentToolUseId) {
4246
+ presentation.toolCall._meta = {
4247
+ claudeCode: { toolName, parentToolUseId },
4054
4248
  };
4055
4249
  }
4250
+ const permissionOptions = buildClaudePermissionOptions({
4251
+ toolName,
4252
+ displayName,
4253
+ input: toolInput,
4254
+ cwd: session.cwd,
4255
+ durableChangeSet,
4256
+ allowPersistentOptions: matchedAskRule === undefined,
4257
+ availableModes: this.sessionModes.availableModeIds(session.modes),
4258
+ contextUsedPercent: session.contextUsedTokens === undefined || session.contextWindowSize <= 0
4259
+ ? undefined
4260
+ : Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
4261
+ });
4056
4262
  const response = await this.requestPermissionFromClient({
4057
- options: [
4058
- { kind: "reject_once", name: "Deny", optionId: "reject" },
4059
- { kind: "allow_once", name: "Allow Once", optionId: "allow" },
4060
- {
4061
- // The label discloses the scope for clients that render only the
4062
- // option name; `_meta.permission` carries the same commitment in
4063
- // the structured upstream form. See `describeAlwaysAllow`.
4064
- kind: "allow_always",
4065
- name: describeAlwaysAllow(suggestions, toolName),
4066
- optionId: "allow_always",
4067
- _meta: {
4068
- permission: permissionMetadataForAlwaysAllow(suggestions, toolName),
4069
- },
4070
- },
4071
- ],
4263
+ ...presentation,
4264
+ options: permissionOptions,
4072
4265
  sessionId,
4073
- toolCall: {
4074
- toolCallId: toolUseID,
4075
- rawInput: toolInput,
4076
- ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
4077
- // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
4078
- // so clients can rely on one shape everywhere.
4079
- ...(parentToolUseId
4080
- ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
4081
- : {}),
4082
- },
4083
4266
  }, toolName, signal, parentToolUseId);
4084
- if (signal.aborted || response.outcome?.outcome === "cancelled") {
4267
+ if (signal.aborted)
4085
4268
  throw new Error("Tool use aborted");
4269
+ const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
4270
+ let permissionResult = decodedPermission.permissionResult;
4271
+ const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
4272
+ permissionResult = autoFallback.permissionResult;
4273
+ if (autoFallback.fallbackApplied) {
4274
+ await this.sessionModes.publishFallbackWarning(sessionId, session);
4086
4275
  }
4087
- if (response.outcome?.outcome === "selected" &&
4088
- (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")) {
4089
- // If Claude Code has suggestions, it will update their settings already
4090
- if (response.outcome.optionId === "allow_always") {
4091
- return {
4092
- behavior: "allow",
4093
- updatedInput: toolInput,
4094
- updatedPermissions: suggestions ?? [
4095
- {
4096
- type: "addRules",
4097
- rules: [{ toolName }],
4098
- behavior: "allow",
4099
- destination: "session",
4100
- },
4101
- ],
4102
- };
4103
- }
4104
- return {
4105
- behavior: "allow",
4106
- updatedInput: toolInput,
4276
+ const clearContextMode = decodedPermission.contextResetMode
4277
+ ? this.sessionModes.effectiveMode(session, decodedPermission.contextResetMode)
4278
+ : undefined;
4279
+ if (toolName === "ExitPlanMode" && clearContextMode) {
4280
+ const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : "";
4281
+ if (!plan)
4282
+ throw new Error("ExitPlanMode clear-context selection requires a plan");
4283
+ session.pendingExitPlanContextReset = {
4284
+ toolUseId: toolUseID,
4285
+ plan,
4286
+ mode: clearContextMode,
4107
4287
  };
4108
4288
  }
4109
- else {
4110
- return {
4111
- behavior: "deny",
4112
- message: "User refused permission to run tool",
4289
+ if (toolName === "ExitPlanMode" &&
4290
+ permissionResult.behavior === "deny" &&
4291
+ permissionResult.interrupt === true) {
4292
+ session.pendingExitPlanModeInterruption = {
4293
+ toolUseId: toolUseID,
4294
+ toolResultSeen: false,
4113
4295
  };
4114
4296
  }
4297
+ return permissionResult;
4115
4298
  };
4116
4299
  }
4117
4300
  /**
@@ -4227,7 +4410,7 @@ export class ClaudeAcpAgent {
4227
4410
  sessionId,
4228
4411
  update: {
4229
4412
  sessionUpdate: "available_commands_update",
4230
- availableCommands: getAvailableSlashCommands(commands),
4413
+ availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
4231
4414
  },
4232
4415
  });
4233
4416
  }
@@ -4246,12 +4429,11 @@ export class ClaudeAcpAgent {
4246
4429
  }
4247
4430
  async applyConfigOptionValue(sessionId, session, configId, value) {
4248
4431
  if (configId === MODE_CONFIG_ID) {
4249
- session.modes = { ...session.modes, currentModeId: value };
4250
- session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
4432
+ this.sessionModes.syncConfig(session, value);
4251
4433
  }
4252
4434
  else if (configId === MODEL_CONFIG_ID) {
4253
- // `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
4254
- // modes below; its `displayName`/`description` also let us infer the
4435
+ // `ModelInfo.supportsAutoMode` is the canonical SDK signal for applying
4436
+ // the Auto fallback below; its `displayName`/`description` also let us infer the
4255
4437
  // context window for semantic aliases (e.g. `default`) whose ID alone
4256
4438
  // carries no "1m" token.
4257
4439
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
@@ -4273,41 +4455,7 @@ export class ClaudeAcpAgent {
4273
4455
  session.contextWindowAuthoritative = seeded.authoritative;
4274
4456
  }
4275
4457
  session.models = { ...session.models, currentModelId: value };
4276
- // Recompute availableModes for the new model and clamp the current
4277
- // mode if the SDK no longer offers it (today: "auto" on Haiku). An
4278
- // unknown model (an SDK-initiated refusal fallback to a model outside
4279
- // the user's `availableModels` allowlist — user-driven switches are
4280
- // validated against the options first) tells us nothing about its
4281
- // capabilities, so keep the current modes rather than spuriously
4282
- // downgrading (e.g. kicking the user out of "auto" for a model that
4283
- // does support it).
4284
- const newAvailableModes = newModelInfo
4285
- ? buildAvailableModes(newModelInfo)
4286
- : session.modes.availableModes;
4287
- // Capture BEFORE mutating session.modes so the log message reflects
4288
- // the invalidated mode rather than "default".
4289
- const previousModeId = session.modes.currentModeId;
4290
- let modeDowngraded = false;
4291
- if (!newAvailableModes.some((m) => m.id === previousModeId)) {
4292
- session.modes = {
4293
- availableModes: newAvailableModes,
4294
- currentModeId: "default",
4295
- };
4296
- try {
4297
- await session.query.setPermissionMode("default");
4298
- }
4299
- catch (err) {
4300
- // Failing the entire model switch over a bookkeeping sync error is
4301
- // worse UX than logging and continuing; the user explicitly asked
4302
- // to change models. The next setPermissionMode from the user will
4303
- // either succeed or surface a fresh error.
4304
- this.logger.error(`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, err);
4305
- }
4306
- modeDowngraded = true;
4307
- }
4308
- else {
4309
- session.modes = { ...session.modes, availableModes: newAvailableModes };
4310
- }
4458
+ const modeDowngraded = await this.sessionModes.reconcileForModel(session, newModelInfo);
4311
4459
  // `model_not_allowed` described the model we just left, so it must not
4312
4460
  // follow us onto the new one; the remaining reasons are account- or
4313
4461
  // environment-scoped and stay true across a switch. Either way the next
@@ -4324,6 +4472,7 @@ export class ClaudeAcpAgent {
4324
4472
  // intent) when a supporting model is selected again.
4325
4473
  supported: newModelInfo?.supportsFastMode ?? false,
4326
4474
  enabled: session.fastModeEnabled,
4475
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
4327
4476
  disabledReason: session.fastModeDisabledReason,
4328
4477
  },
4329
4478
  // Thinking is model-independent: re-render the retained tri-state
@@ -4349,13 +4498,7 @@ export class ClaudeAcpAgent {
4349
4498
  // still precedes the caller's config_option_update so order-sensitive
4350
4499
  // clients update currentModeId before re-rendering the option list.
4351
4500
  if (modeDowngraded) {
4352
- await this.client.sessionUpdate({
4353
- sessionId,
4354
- update: {
4355
- sessionUpdate: "current_mode_update",
4356
- currentModeId: "default",
4357
- },
4358
- });
4501
+ await this.sessionModes.publishFallbackState(sessionId, session);
4359
4502
  }
4360
4503
  }
4361
4504
  else if (configId === AGENT_CONFIG_ID) {
@@ -4410,13 +4553,11 @@ export class ClaudeAcpAgent {
4410
4553
  }
4411
4554
  }
4412
4555
  /** Replace the Fast mode option in `session.configOptions` so it reflects
4413
- * `enabled` (and the session's current disabled reason). A no-op when the
4556
+ * `enabled` (and the client's current boolean-capability). A no-op when the
4414
4557
  * option isn't present, so callers must confirm the current model surfaces
4415
- * it first. Rebuilds through {@link createFastModeConfigOption} — the one
4416
- * source of the option's shape — so the shape can't drift from what
4417
- * `buildConfigOptions` first emitted. */
4558
+ * it first. */
4418
4559
  refreshFastModeOption(session, enabled) {
4419
- const refreshed = createFastModeConfigOption(enabled, session.fastModeDisabledReason);
4560
+ const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities), session.fastModeDisabledReason);
4420
4561
  session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
4421
4562
  }
4422
4563
  /** Toggle Fast mode for a session: push the SDK flag, record the user's
@@ -4813,7 +4954,10 @@ export class ClaudeAcpAgent {
4813
4954
  // We want to create a new session id unless it is resume,
4814
4955
  // but not resume + forkSession.
4815
4956
  let sessionId;
4816
- if (creationOpts.forkSession) {
4957
+ if (creationOpts.publicSessionId) {
4958
+ sessionId = creationOpts.publicSessionId;
4959
+ }
4960
+ else if (creationOpts.forkSession) {
4817
4961
  sessionId = randomUUID();
4818
4962
  }
4819
4963
  else if (creationOpts.resume) {
@@ -4872,6 +5016,7 @@ export class ClaudeAcpAgent {
4872
5016
  }
4873
5017
  }
4874
5018
  const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
5019
+ const initialPermissionMode = creationOpts.permissionMode ?? permissionMode;
4875
5020
  // Extract options from _meta if provided
4876
5021
  const sessionMeta = params._meta;
4877
5022
  const userProvidedOptions = sessionMeta?.claudeCode?.options;
@@ -4906,6 +5051,30 @@ export class ClaudeAcpAgent {
4906
5051
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
4907
5052
  // the same Map that the streaming message handler will read from.
4908
5053
  const taskState = new Map();
5054
+ // Resolve every workspace root once. The hidden report tool uses this same
5055
+ // set for lexical path validation, and the SDK receives it below.
5056
+ const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
5057
+ const additionalDirectories = [
5058
+ ...(userProvidedOptions?.additionalDirectories ?? []),
5059
+ ...acpAdditionalDirectories,
5060
+ ];
5061
+ const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
5062
+ ? createFileChangeAuditSupport({
5063
+ cwd: params.cwd,
5064
+ additionalDirectories,
5065
+ getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
5066
+ publish: async (result) => {
5067
+ await this.client.sessionUpdate({
5068
+ sessionId,
5069
+ update: {
5070
+ sessionUpdate: "session_info_update",
5071
+ _meta: agentFileChangeReportMeta(result),
5072
+ },
5073
+ });
5074
+ },
5075
+ logError: (message) => this.logger.error(message),
5076
+ })
5077
+ : undefined;
4909
5078
  // The exact env the query will be created with. Built (and the provider
4910
5079
  // cache key derived from it, below) in one place so the key always
4911
5080
  // describes the backend this query actually talks to: `providers/set`,
@@ -4913,13 +5082,36 @@ export class ClaudeAcpAgent {
4913
5082
  // config concurrently, so re-resolving it after any of the awaits between
4914
5083
  // here and the session registration could disagree with the env baked
4915
5084
  // into the query.
5085
+ const resolvedProvider = this.resolveProviderConfig();
5086
+ const providerEnv = createEnvForProvider(resolvedProvider);
5087
+ const configuredSettings = userProvidedOptions?.settings ??
5088
+ (modelConfig
5089
+ ? {
5090
+ ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
5091
+ ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
5092
+ }
5093
+ : undefined);
5094
+ // Claude Code applies env from settings.json after the subprocess env. Put
5095
+ // an active ACP route in the programmatic settings tier too so user/project
5096
+ // settings cannot silently restore a different ANTHROPIC_BASE_URL.
5097
+ let settings = configuredSettings;
5098
+ if (resolvedProvider) {
5099
+ const baseSettings = typeof configuredSettings === "string"
5100
+ ? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
5101
+ : configuredSettings;
5102
+ settings = {
5103
+ ...baseSettings,
5104
+ apiKeyHelper: "",
5105
+ env: { ...baseSettings?.env, ...providerEnv },
5106
+ };
5107
+ }
4916
5108
  const env = {
4917
5109
  ...process.env,
4918
5110
  ...userProvidedOptions?.env,
4919
5111
  // Client-managed LLM routing: `providers/set` config wins, else the
4920
- // legacy gateway auth request. Baked into the query at creation, so it
4921
- // only affects sessions started after the change (matching the RFD).
4922
- ...createEnvForProvider(this.resolveProviderConfig()),
5112
+ // legacy gateway auth request. Routing is baked into the query at
5113
+ // creation; provider updates recreate loaded queries between turns.
5114
+ ...providerEnv,
4923
5115
  // Opt-in to session state events like when the agent is idle
4924
5116
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
4925
5117
  };
@@ -4928,6 +5120,7 @@ export class ClaudeAcpAgent {
4928
5120
  // SDK, so per-session `_meta` env routing and ambient process-env routing
4929
5121
  // are distinguished exactly as the CLI will see them.
4930
5122
  const providerCacheKey = providerCacheKeyFor(env);
5123
+ this.logger.log(`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`);
4931
5124
  const options = {
4932
5125
  systemPrompt,
4933
5126
  settingSources: ["user", "project", "local"],
@@ -4937,27 +5130,23 @@ export class ClaudeAcpAgent {
4937
5130
  // `enableFileCheckpointing` still wins.
4938
5131
  enableFileCheckpointing: true,
4939
5132
  ...userProvidedOptions,
4940
- // CLAUDE_MODEL_CONFIG env var is a fallback for model
4941
- // configuration (e.g. Bedrock model ID overrides). When the caller
4942
- // provides settings via _meta, we intentionally ignore the env var —
4943
- // the caller is assumed to have full control over model configuration.
4944
- ...(!userProvidedOptions?.settings &&
4945
- modelConfig && {
4946
- settings: {
4947
- ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
4948
- ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
4949
- },
4950
- }),
5133
+ ...(settings && { settings }),
4951
5134
  env,
4952
5135
  // Override certain fields that must be controlled by ACP
4953
5136
  cwd: params.cwd,
4954
5137
  includePartialMessages: true,
4955
5138
  forwardSubagentText,
4956
- mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
5139
+ mcpServers: {
5140
+ ...(userProvidedOptions?.mcpServers || {}),
5141
+ ...mcpServers,
5142
+ ...(fileChangeAuditSupport
5143
+ ? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
5144
+ : {}),
5145
+ },
4957
5146
  // If we want bypassPermissions to be an option, we have to allow it here.
4958
5147
  // But it doesn't work in root mode, so we only activate it if it will work.
4959
5148
  allowDangerouslySkipPermissions: ALLOW_BYPASS,
4960
- permissionMode,
5149
+ permissionMode: initialPermissionMode,
4961
5150
  canUseTool: this.canUseTool(sessionId),
4962
5151
  // Forward MCP elicitation requests onto ACP elicitation. Only attached
4963
5152
  // when the client advertised support, so non-supporting clients keep the
@@ -4987,40 +5176,42 @@ export class ClaudeAcpAgent {
4987
5176
  tools,
4988
5177
  hooks: {
4989
5178
  ...userProvidedOptions?.hooks,
5179
+ ...(fileChangeAuditSupport
5180
+ ? {
5181
+ PreToolUse: [
5182
+ ...(userProvidedOptions?.hooks?.PreToolUse || []),
5183
+ { hooks: [fileChangeAuditSupport.preToolUseHook] },
5184
+ ],
5185
+ }
5186
+ : {}),
4990
5187
  PostToolUse: [
4991
5188
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
4992
5189
  {
4993
5190
  hooks: [
4994
5191
  createPostToolUseHook({
4995
5192
  onEnterPlanMode: async () => {
4996
- await this.client.sessionUpdate({
4997
- sessionId,
4998
- update: {
4999
- sessionUpdate: "current_mode_update",
5000
- currentModeId: "plan",
5001
- },
5002
- });
5193
+ await this.sessionModes.publishCurrent(sessionId, "plan");
5003
5194
  await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
5004
5195
  },
5005
5196
  }),
5006
5197
  ],
5007
5198
  },
5008
5199
  ],
5200
+ ...(fileChangeAuditSupport
5201
+ ? {
5202
+ Stop: [
5203
+ ...(userProvidedOptions?.hooks?.Stop || []),
5204
+ { hooks: [fileChangeAuditSupport.stopHook] },
5205
+ ],
5206
+ }
5207
+ : {}),
5009
5208
  TaskCreated: [
5010
5209
  ...(userProvidedOptions?.hooks?.TaskCreated || []),
5011
5210
  {
5012
5211
  hooks: [
5013
5212
  createTaskHook({
5014
5213
  taskState,
5015
- onChange: async () => {
5016
- await this.client.sessionUpdate({
5017
- sessionId,
5018
- update: {
5019
- sessionUpdate: "plan",
5020
- entries: taskStateToPlanEntries(taskState),
5021
- },
5022
- });
5023
- },
5214
+ onChange: () => this.publishTaskPlan(sessionId, taskState),
5024
5215
  }),
5025
5216
  ],
5026
5217
  },
@@ -5031,35 +5222,24 @@ export class ClaudeAcpAgent {
5031
5222
  hooks: [
5032
5223
  createTaskHook({
5033
5224
  taskState,
5034
- onChange: async () => {
5035
- await this.client.sessionUpdate({
5036
- sessionId,
5037
- update: {
5038
- sessionUpdate: "plan",
5039
- entries: taskStateToPlanEntries(taskState),
5040
- },
5041
- });
5042
- },
5225
+ onChange: () => this.publishTaskPlan(sessionId, taskState),
5043
5226
  }),
5044
5227
  ],
5045
5228
  },
5046
5229
  ],
5047
5230
  },
5048
- ...creationOpts,
5231
+ ...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
5232
+ ...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
5049
5233
  abortController,
5050
5234
  };
5051
5235
  // Prefer the official ACP `additionalDirectories` field. Fall back to the
5052
5236
  // legacy `_meta.additionalRoots` extension for clients that haven't been
5053
5237
  // updated yet. Either source is merged with directories supplied via
5054
5238
  // `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
5055
- const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
5056
- options.additionalDirectories = [
5057
- ...(userProvidedOptions?.additionalDirectories ?? []),
5058
- ...acpAdditionalDirectories,
5059
- ];
5239
+ options.additionalDirectories = additionalDirectories;
5060
5240
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
5061
5241
  // Set our own session id if not resuming an existing session.
5062
- options.sessionId = sessionId;
5242
+ options.sessionId = creationOpts.publicSessionId ? randomUUID() : sessionId;
5063
5243
  }
5064
5244
  // Handle abort controller from meta options
5065
5245
  if (abortController?.signal.aborted) {
@@ -5114,8 +5294,8 @@ export class ClaudeAcpAgent {
5114
5294
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
5115
5295
  : hideDeprecatedModels(initializationResult.models, this.logger);
5116
5296
  const { modelState: models, resumedContextWindow } = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
5117
- // Gate `auto` (and future model-specific modes) on the resolved model's
5118
- // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
5297
+ // Resolve the current model's capabilities separately from the stable
5298
+ // permission-mode catalog advertised to ACP clients.
5119
5299
  // Looked up in the UNfiltered catalog: a session honoring a persisted
5120
5300
  // deprecated preference must keep that model's real capabilities (R4.3).
5121
5301
  // A resumed session can also be running a model outside the
@@ -5156,38 +5336,12 @@ export class ClaudeAcpAgent {
5156
5336
  },
5157
5337
  ]
5158
5338
  : catalogModels;
5159
- const availableModes = buildAvailableModes(currentModelInfo);
5160
- // Clamp `permissionMode` if the resolved session does not offer it. The
5161
- // common case is `permissions.defaultMode: "auto"` resolving to a model
5162
- // that does not support auto mode (e.g. Haiku); without this clamp the
5163
- // SDK would later throw `"auto mode unavailable for this model"` from
5164
- // `setPermissionMode`. Keep `permissionMode` as the resolved user intent
5165
- // (matches what was passed into `options.permissionMode` above) and use
5166
- // `effectiveMode` for the post-clamp value the session actually runs in.
5167
- let effectiveMode = permissionMode;
5168
- if (!availableModes.some((m) => m.id === effectiveMode)) {
5169
- if (effectiveMode === "auto") {
5170
- this.logger.error(`permissions.defaultMode "auto" is not available for model ` +
5171
- `"${models.currentModelId}"; falling back to "default".`);
5172
- }
5173
- else {
5174
- this.logger.error(`permissions.defaultMode "${effectiveMode}" is not available in ` +
5175
- `this session; falling back to "default".`);
5176
- }
5177
- effectiveMode = "default";
5178
- // Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
5179
- // try/catch since failing here would abort session creation entirely.
5180
- try {
5181
- await q.setPermissionMode("default");
5182
- }
5183
- catch (err) {
5184
- this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
5185
- }
5186
- }
5187
- const modes = {
5188
- currentModeId: effectiveMode,
5189
- availableModes,
5190
- };
5339
+ const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
5340
+ query: q,
5341
+ requestedMode: initialPermissionMode,
5342
+ currentModelInfo,
5343
+ currentModelId: models.currentModelId,
5344
+ });
5191
5345
  const agents = await discoverCustomAgents(q);
5192
5346
  // Only adopt the requested agent as the selected value if it's one we
5193
5347
  // actually surface in the picker. A built-in (filtered out above) or
@@ -5214,6 +5368,7 @@ export class ClaudeAcpAgent {
5214
5368
  const fastMode = {
5215
5369
  supported: currentModelInfo?.supportsFastMode ?? false,
5216
5370
  enabled: fastModeEnabled,
5371
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
5217
5372
  disabledReason: fastModeDisabledReason,
5218
5373
  };
5219
5374
  const configOptions = buildConfigOptions(modes, models,
@@ -5279,7 +5434,9 @@ export class ClaudeAcpAgent {
5279
5434
  // can rebuild an equivalent query without re-running this assembly.
5280
5435
  queryOptions: options,
5281
5436
  sessionFingerprint: computeSessionFingerprint(params),
5437
+ creationParams: params,
5282
5438
  settingsManager,
5439
+ titles: new SessionTitles(this, sessionId),
5283
5440
  accumulatedUsage: {
5284
5441
  inputTokens: 0,
5285
5442
  outputTokens: 0,
@@ -5293,6 +5450,8 @@ export class ClaudeAcpAgent {
5293
5450
  // capability lookups and `resolveModelPreference` (refusal fallback),
5294
5451
  // which must keep seeing deprecated rows (R4.3, visibility-only filter).
5295
5452
  modelInfos,
5453
+ autoModeFallbackWarningShown: false,
5454
+ autoModeFallbackWarningPending,
5296
5455
  configOptions,
5297
5456
  agents,
5298
5457
  currentAgent,
@@ -5311,6 +5470,9 @@ export class ClaudeAcpAgent {
5311
5470
  emittedAssistantText: false,
5312
5471
  owedTrailingIdles: 0,
5313
5472
  messageIdToUuid: new Map(),
5473
+ sessionFailureState: createSessionFailureState(),
5474
+ fileChangeReportRequestIds: new Set(),
5475
+ fileChangeAuditSupport,
5314
5476
  };
5315
5477
  return {
5316
5478
  sessionId,
@@ -5318,6 +5480,41 @@ export class ClaudeAcpAgent {
5318
5480
  configOptions,
5319
5481
  };
5320
5482
  }
5483
+ /**
5484
+ * Provider routing is baked into the environment of each SDK Query. Wait for
5485
+ * all submitted turns to settle, close every query, then resume each Claude
5486
+ * session with the same ID so subsequent turns inherit the new environment.
5487
+ */
5488
+ async enqueueProviderUpdate(config) {
5489
+ const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve();
5490
+ const update = previous.then(async () => {
5491
+ const sessions = Object.entries(this.sessions);
5492
+ const activeTurns = sessions.flatMap(([, session]) => (session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])));
5493
+ if (activeTurns.length > 0) {
5494
+ this.logger.log(`Waiting for ${activeTurns.length} active Claude turn(s) before provider update`);
5495
+ await Promise.all(activeTurns);
5496
+ }
5497
+ this.providerConfig = config;
5498
+ for (const [sessionId, session] of sessions) {
5499
+ if (this.sessions[sessionId] !== session || !session.creationParams) {
5500
+ continue;
5501
+ }
5502
+ this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
5503
+ this.closeQueryStream(session);
5504
+ delete this.sessions[sessionId];
5505
+ await this.createSession(session.creationParams, { resume: sessionId });
5506
+ }
5507
+ });
5508
+ this.providerUpdate = update;
5509
+ try {
5510
+ await update;
5511
+ }
5512
+ finally {
5513
+ if (this.providerUpdate === update) {
5514
+ this.providerUpdate = null;
5515
+ }
5516
+ }
5517
+ }
5321
5518
  }
5322
5519
  function shouldEmitRawMessage(config, message) {
5323
5520
  if (config === true)
@@ -5405,13 +5602,27 @@ function createEnvForProvider(config) {
5405
5602
  if (!config) {
5406
5603
  return {};
5407
5604
  }
5605
+ const resetRouting = {
5606
+ ANTHROPIC_BASE_URL: "",
5607
+ ANTHROPIC_BEDROCK_BASE_URL: "",
5608
+ ANTHROPIC_VERTEX_BASE_URL: "",
5609
+ CLAUDE_CODE_USE_BEDROCK: "0",
5610
+ CLAUDE_CODE_USE_VERTEX: "0",
5611
+ ANTHROPIC_VERTEX_PROJECT_ID: "",
5612
+ CLOUD_ML_REGION: "",
5613
+ AWS_REGION: "",
5614
+ ANTHROPIC_API_KEY: "",
5615
+ ANTHROPIC_AUTH_TOKEN: "",
5616
+ CLAUDE_CODE_OAUTH_TOKEN: "",
5617
+ };
5408
5618
  const customHeaders = Object.entries(config.headers)
5409
5619
  .map(([key, value]) => `${key}: ${value}`)
5410
5620
  .join("\n");
5411
5621
  if (config.apiType === "bedrock") {
5412
5622
  return {
5623
+ ...resetRouting,
5413
5624
  CLAUDE_CODE_USE_BEDROCK: "1",
5414
- AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
5625
+ AWS_BEARER_TOKEN_BEDROCK: "acp-proxy", // Bypass local AWS credential checks
5415
5626
  ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
5416
5627
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
5417
5628
  };
@@ -5420,6 +5631,7 @@ function createEnvForProvider(config) {
5420
5631
  // `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
5421
5632
  // validation; fall back to empty strings defensively.
5422
5633
  return {
5634
+ ...resetRouting,
5423
5635
  CLAUDE_CODE_USE_VERTEX: "1",
5424
5636
  ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
5425
5637
  ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
@@ -5428,9 +5640,10 @@ function createEnvForProvider(config) {
5428
5640
  };
5429
5641
  }
5430
5642
  return {
5643
+ ...resetRouting,
5431
5644
  ANTHROPIC_BASE_URL: config.baseUrl,
5432
5645
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
5433
- ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
5646
+ ANTHROPIC_AUTH_TOKEN: "acp-proxy", // Bypass local Claude login checks
5434
5647
  };
5435
5648
  }
5436
5649
  /**
@@ -5449,50 +5662,6 @@ function isValidBaseUrl(baseUrl) {
5449
5662
  }
5450
5663
  return parsed.protocol === "http:" || parsed.protocol === "https:";
5451
5664
  }
5452
- /**
5453
- * Build the list of permission modes the agent will advertise for the given
5454
- * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
5455
- * the SDK's model-level availability signal. `undefined`/`false` both exclude
5456
- * `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
5457
- */
5458
- function buildAvailableModes(modelInfo) {
5459
- const modes = [];
5460
- // Only advertise "auto" when the SDK reports the model supports it.
5461
- if (modelInfo?.supportsAutoMode === true) {
5462
- modes.push({
5463
- id: "auto",
5464
- name: "Auto",
5465
- description: "Use a model classifier to approve/deny permission prompts",
5466
- });
5467
- }
5468
- modes.push({
5469
- // Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces;
5470
- // the wire id stays "default" ("manual" is only an accepted input alias).
5471
- id: "default",
5472
- name: "Manual",
5473
- description: "Standard behavior, prompts for dangerous operations",
5474
- }, {
5475
- id: "acceptEdits",
5476
- name: "Accept Edits",
5477
- description: "Auto-accept file edit operations",
5478
- }, {
5479
- id: "plan",
5480
- name: "Plan Mode",
5481
- description: "Planning mode, no actual tool execution",
5482
- }, {
5483
- id: "dontAsk",
5484
- name: "Don't Ask",
5485
- description: "Don't prompt for permissions, deny if not pre-approved",
5486
- });
5487
- if (ALLOW_BYPASS) {
5488
- modes.push({
5489
- id: "bypassPermissions",
5490
- name: "Bypass Permissions",
5491
- description: "Bypass all permission checks",
5492
- });
5493
- }
5494
- return modes;
5495
- }
5496
5665
  // Translate a UI effort value into the flag-layer payload. The SDK
5497
5666
  // shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
5498
5667
  // and only clears a key when an explicit `null` is sent — see
@@ -5514,7 +5683,6 @@ function toSdkEffortLevel(value) {
5514
5683
  export const BUILTIN_AGENT_NAMES = new Set([
5515
5684
  "claude",
5516
5685
  "general-purpose",
5517
- "claude-code-guide",
5518
5686
  "Explore",
5519
5687
  "Plan",
5520
5688
  "statusline-setup",
@@ -5524,7 +5692,6 @@ export const BUILTIN_AGENT_NAMES = new Set([
5524
5692
  // reserved sentinel: a custom agent named exactly this would collide with it
5525
5693
  // (two options sharing the value, selection silently routing to `null`), so we
5526
5694
  // exclude that name from discovery.
5527
- export const DEFAULT_AGENT_ID = "default";
5528
5695
  /** Discover user/plugin/project-configured main-thread agents, excluding the
5529
5696
  * built-in subagents and the reserved "default" sentinel. Returns an empty
5530
5697
  * list if discovery fails so a flaky control request never blocks session
@@ -5542,13 +5709,12 @@ export async function discoverCustomAgents(q) {
5542
5709
  * Centralized so the option declarations in `buildConfigOptions` and the
5543
5710
  * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
5544
5711
  * same identifiers and can't drift apart. */
5545
- export const MODE_CONFIG_ID = "mode";
5712
+ export { MODE_CONFIG_ID };
5546
5713
  export const MODEL_CONFIG_ID = "model";
5547
- export const EFFORT_CONFIG_ID = "effort";
5548
5714
  export const AGENT_CONFIG_ID = "agent";
5549
5715
  export const FAST_MODE_CONFIG_ID = "fast";
5550
- /** Select values for the Fast mode on/off option
5551
- * (see {@link createFastModeConfigOption}). */
5716
+ /** Select-fallback values used when the client has not opted into boolean
5717
+ * config options (see {@link createFastModeConfigOption}). */
5552
5718
  export const FAST_MODE_ON = "on";
5553
5719
  export const FAST_MODE_OFF = "off";
5554
5720
  const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
@@ -5585,33 +5751,38 @@ const FAST_MODE_UNAVAILABLE_EXPLANATIONS = {
5585
5751
  export function normalizeFastModeDisabledReason(reason) {
5586
5752
  return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
5587
5753
  }
5588
- /** Build the Fast mode config option as a two-value on/off `select`. Emitted
5589
- * for EVERY Client — the boolean option shape is gone (story 006, R2.1;
5590
- * retained through the v0.64.0 sync by story 008 R3.4). Only the emitted SHAPE
5591
- * is fixed to a select; boolean VALUES are still honored on set (see
5592
- * {@link resolveFastModeEnabled}). This factory is the single source of the
5593
- * option's shape, re-rendered by `refreshFastModeOption` / `syncFastModeState`
5594
- * so the shape can never desync.
5595
- *
5596
- * `disabledReason` (the SDK's `fast_mode_disabled_reason`, upstream v0.64.0) is
5597
- * folded into the description while the toggle reads off, so a user whose
5598
- * account or provider can't serve Fast mode sees why instead of a switch that
5599
- * silently refuses to stay on. Ignored while enabled: a reason reported
5600
- * alongside an `on`/`cooldown` state isn't blocking anything right now.
5754
+ /** Whether the Client advertised support for boolean session config options
5755
+ * (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
5756
+ * config options to Clients that opt in; otherwise we fall back to a `select`.
5757
+ * See https://agentclientprotocol.com/rfds/boolean-config-option. */
5758
+ export function clientSupportsBooleanConfigOptions(clientCapabilities) {
5759
+ return clientCapabilities?.session?.configOptions?.boolean != null;
5760
+ }
5761
+ /** Build the Fast mode config option. When the Client supports boolean config
5762
+ * options we expose a native `type: "boolean"` toggle; otherwise we degrade to
5763
+ * a two-value `select` ("on"/"off") so older Clients still get a usable
5764
+ * control.
5601
5765
  *
5602
- * Upstream's second parameter (`useBooleanOption`) is deliberately absent: the
5603
- * shape is unconditionally a select, so there is no branch to select. What
5604
- * guards that is behavioural, not structural — `tests/fast-mode-select-only.
5605
- * test.ts` proves no argument combination can yield the boolean shape. */
5606
- export function createFastModeConfigOption(enabled, disabledReason) {
5766
+ * `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
5767
+ * description while the toggle reads off, so a user whose account or provider
5768
+ * can't serve Fast mode sees why instead of a switch that silently refuses to
5769
+ * stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
5770
+ * state isn't blocking anything right now. */
5771
+ export function createFastModeConfigOption(enabled, useBooleanOption, disabledReason) {
5607
5772
  const explanation = enabled
5608
5773
  ? undefined
5609
5774
  : disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
5610
- return {
5775
+ const base = {
5611
5776
  id: FAST_MODE_CONFIG_ID,
5612
5777
  name: "Fast mode",
5613
5778
  description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
5614
5779
  category: "model_config",
5780
+ };
5781
+ if (useBooleanOption) {
5782
+ return { ...base, type: "boolean", currentValue: enabled };
5783
+ }
5784
+ return {
5785
+ ...base,
5615
5786
  type: "select",
5616
5787
  currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
5617
5788
  options: [
@@ -5621,8 +5792,8 @@ export function createFastModeConfigOption(enabled, disabledReason) {
5621
5792
  };
5622
5793
  }
5623
5794
  /** Resolve the requested Fast mode value from a `session/set_config_option`
5624
- * request. Accepts the select's "on"/"off" strings or a native boolean,
5625
- * kept for backward compatibility (R2.3). */
5795
+ * request. Accepts a native boolean (boolean-capable Clients) or the
5796
+ * "on"/"off" select-fallback strings. */
5626
5797
  export function resolveFastModeEnabled(params) {
5627
5798
  const value = params.value;
5628
5799
  if (typeof value === "boolean") {
@@ -5644,19 +5815,7 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
5644
5815
  * callers/tests) omits the row. */
5645
5816
  thinkingEnabled) {
5646
5817
  const options = [
5647
- {
5648
- id: MODE_CONFIG_ID,
5649
- name: "Mode",
5650
- description: "Session permission mode",
5651
- category: "mode",
5652
- type: "select",
5653
- currentValue: modes.currentModeId,
5654
- options: modes.availableModes.map((m) => ({
5655
- value: m.id,
5656
- name: m.name,
5657
- description: m.description,
5658
- })),
5659
- },
5818
+ SessionModeManager.configOption(modes),
5660
5819
  {
5661
5820
  id: MODEL_CONFIG_ID,
5662
5821
  name: "Model",
@@ -5664,11 +5823,21 @@ thinkingEnabled) {
5664
5823
  category: "model",
5665
5824
  type: "select",
5666
5825
  currentValue: models.currentModelId,
5667
- options: models.availableModels.map((m) => ({
5668
- value: m.modelId,
5669
- name: m.name,
5670
- description: m.description ?? undefined,
5671
- })),
5826
+ options: models.availableModels.map((m) => {
5827
+ if (m.modelId === "default") {
5828
+ const defaultInfo = modelInfos.find((mi) => mi.value === "default");
5829
+ const resolvedModel = defaultInfo?.resolvedModel;
5830
+ if (resolvedModel) {
5831
+ const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
5832
+ return {
5833
+ value: m.modelId,
5834
+ name: m.name,
5835
+ description: namedMatch?.displayName ?? resolvedModel,
5836
+ };
5837
+ }
5838
+ }
5839
+ return { value: m.modelId, name: m.name, description: m.description ?? undefined };
5840
+ }),
5672
5841
  },
5673
5842
  ];
5674
5843
  // Add effort level option based on the currently selected model
@@ -5700,10 +5869,10 @@ thinkingEnabled) {
5700
5869
  });
5701
5870
  }
5702
5871
  // Surface the Fast mode toggle only when the current model supports it. The
5703
- // option is always emitted as a two-value on/off select for every Client
5704
- // (R2.1); boolean values remain accepted on set for boolean-era clients.
5872
+ // option renders as a native boolean toggle for Clients that opted in, and a
5873
+ // two-value select otherwise.
5705
5874
  if (fastMode?.supported) {
5706
- options.push(createFastModeConfigOption(fastMode.enabled, fastMode.disabledReason));
5875
+ options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption, fastMode.disabledReason));
5707
5876
  }
5708
5877
  // Surface the Thinking toggle whenever the caller supplies its display
5709
5878
  // state. Unlike Fast mode it is model-independent — no `supported` gate —
@@ -6167,7 +6336,11 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
6167
6336
  resumedContextWindow,
6168
6337
  };
6169
6338
  }
6170
- function getAvailableSlashCommands(commands) {
6339
+ function getAvailableSlashCommands(commands,
6340
+ // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
6341
+ // the CLI's own terminal, which ACP clients aren't) — filtered alongside
6342
+ // the static list. Raw CLI names, matched before the MCP rename.
6343
+ terminalCommands) {
6171
6344
  const UNSUPPORTED_COMMANDS = [
6172
6345
  "clear",
6173
6346
  "cost",
@@ -6179,6 +6352,7 @@ function getAvailableSlashCommands(commands) {
6179
6352
  "todos",
6180
6353
  ];
6181
6354
  const advertised = commands
6355
+ .filter((command) => !terminalCommands?.includes(command.name))
6182
6356
  .map((command) => {
6183
6357
  const input = command.argumentHint
6184
6358
  ? {
@@ -6362,13 +6536,13 @@ function isTaskTool(toolName) {
6362
6536
  * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
6363
6537
  * resolved explicitly at tool_result time. */
6364
6538
  function shouldEmitToolCall(toolName) {
6365
- return toolName !== "TodoWrite" && !isTaskTool(toolName);
6539
+ return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
6366
6540
  }
6367
6541
  /** Build the Claude Code-specific metadata for a tool call. Bash descriptions
6368
6542
  * are kept out of ACP's standard `title`, which clients may use as the shell
6369
6543
  * command preview, while still giving clients access to Claude's concise
6370
6544
  * human-readable title. */
6371
- function claudeCodeMetaFromToolUse(toolUse) {
6545
+ function claudeCodeMetaFromToolUse(toolUse, cwd) {
6372
6546
  const description = toolUse.name === "Bash" &&
6373
6547
  toolUse.input !== null &&
6374
6548
  typeof toolUse.input === "object" &&
@@ -6376,11 +6550,53 @@ function claudeCodeMetaFromToolUse(toolUse) {
6376
6550
  typeof toolUse.input.description === "string"
6377
6551
  ? toolUse.input.description
6378
6552
  : undefined;
6553
+ const skillName = toolUse.name === "Skill"
6554
+ ? toolUse.input?.skill
6555
+ : undefined;
6556
+ const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
6379
6557
  return {
6380
6558
  toolName: toolUse.name,
6381
6559
  ...(description ? { title: description } : {}),
6382
6560
  ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
6561
+ ...(skillName ? { skill: skillName } : {}),
6562
+ ...(skillPath ? { skillPath } : {}),
6563
+ };
6564
+ }
6565
+ /** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
6566
+ const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
6567
+ /**
6568
+ * Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
6569
+ *
6570
+ * The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
6571
+ * actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
6572
+ * for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
6573
+ * directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
6574
+ * on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
6575
+ */
6576
+ function resolveSkillPath(skillName, cwd) {
6577
+ if (!cwd) {
6578
+ return undefined;
6579
+ }
6580
+ const colon = skillName.indexOf(":");
6581
+ const scope = colon < 0 ? undefined : skillName.slice(0, colon);
6582
+ const name = colon < 0 ? skillName : skillName.slice(colon + 1);
6583
+ if (!name) {
6584
+ return undefined;
6585
+ }
6586
+ const candidates = [];
6587
+ const addCandidates = (base) => {
6588
+ for (const container of SKILL_CONTAINER_DIRS) {
6589
+ candidates.push(path.join(base, container, name, "SKILL.md"));
6590
+ }
6383
6591
  };
6592
+ if (scope) {
6593
+ // A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
6594
+ addCandidates(path.join(cwd, scope));
6595
+ candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
6596
+ }
6597
+ addCandidates(cwd);
6598
+ addCandidates(os.homedir());
6599
+ return candidates.find((candidate) => existsSync(candidate));
6384
6600
  }
6385
6601
  /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
6386
6602
  * notification for a tool_use. Shared by every site that surfaces a tool call:
@@ -6392,7 +6608,7 @@ function claudeCodeMetaFromToolUse(toolUse) {
6392
6608
  function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
6393
6609
  if (refine) {
6394
6610
  return {
6395
- _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
6611
+ _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
6396
6612
  toolCallId: toolUse.id,
6397
6613
  sessionUpdate: "tool_call_update",
6398
6614
  rawInput,
@@ -6401,7 +6617,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
6401
6617
  }
6402
6618
  return {
6403
6619
  _meta: {
6404
- claudeCode: claudeCodeMetaFromToolUse(toolUse),
6620
+ claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
6405
6621
  ...(toolUse.name === "Bash" && supportsTerminalOutput
6406
6622
  ? { terminal_info: { terminal_id: toolUse.id } }
6407
6623
  : {}),
@@ -6427,7 +6643,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
6427
6643
  const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
6428
6644
  return {
6429
6645
  _meta: {
6430
- claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
6646
+ claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
6431
6647
  },
6432
6648
  toolCallId: toolUse.id,
6433
6649
  sessionUpdate: "tool_call_update",
@@ -6437,33 +6653,6 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
6437
6653
  ...(locations ? { locations } : {}),
6438
6654
  };
6439
6655
  }
6440
- /** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
6441
- * wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
6442
- * by-tool_use_id lookup. Each entry explains why an is_error tool_result
6443
- * carries harness prose instead of the tool's own output — "user-rejected",
6444
- * "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
6445
- * on the wire ahead of schema updates, so no enum check). Malformed entries
6446
- * are skipped rather than failing the message. */
6447
- function parseToolResultMeta(raw) {
6448
- if (!Array.isArray(raw)) {
6449
- return undefined;
6450
- }
6451
- let byToolUseId;
6452
- for (const entry of raw) {
6453
- if (typeof entry !== "object" || entry === null) {
6454
- continue;
6455
- }
6456
- const { id, non_execution_kind, user_feedback } = entry;
6457
- if (typeof id !== "string" || typeof non_execution_kind !== "string") {
6458
- continue;
6459
- }
6460
- (byToolUseId ??= new Map()).set(id, {
6461
- nonExecutionKind: non_execution_kind,
6462
- ...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
6463
- });
6464
- }
6465
- return byToolUseId;
6466
- }
6467
6656
  /**
6468
6657
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
6469
6658
  * Only handles text, image, and thinking chunks for now.
@@ -6473,7 +6662,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6473
6662
  const registerHooks = options?.registerHooks !== false;
6474
6663
  const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
6475
6664
  if (typeof content === "string") {
6476
- if (content.length === 0) {
6665
+ if (content.length === 0 || containsFileChangeAuditMarker(content)) {
6477
6666
  return [];
6478
6667
  }
6479
6668
  const update = {
@@ -6507,6 +6696,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6507
6696
  // Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
6508
6697
  // messages need no single-block guard.
6509
6698
  const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
6699
+ // A report-phase assistant message may contain a short text preface and the
6700
+ // internal tool call in the same content array. Hide the whole message, not
6701
+ // only the tool block, so it stays absent on session replay as well as live.
6702
+ const containsFileChangeAuditToolUse = content.some((chunk) => (chunk.type === "tool_use" ||
6703
+ chunk.type === "server_tool_use" ||
6704
+ chunk.type === "mcp_tool_use") &&
6705
+ isFileChangeAuditTool(chunk.name));
6510
6706
  const output = [];
6511
6707
  // Only handle the first chunk for streaming; extend as needed for batching
6512
6708
  for (const chunk of content) {
@@ -6514,7 +6710,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6514
6710
  switch (chunk.type) {
6515
6711
  case "text":
6516
6712
  case "text_delta": {
6517
- if (chunk.text) {
6713
+ if (chunk.text &&
6714
+ !containsFileChangeAuditToolUse &&
6715
+ !containsFileChangeAuditMarker(chunk.text)) {
6518
6716
  update = {
6519
6717
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6520
6718
  content: {
@@ -6526,21 +6724,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6526
6724
  break;
6527
6725
  }
6528
6726
  case "image":
6529
- update = {
6530
- sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6531
- content: {
6532
- type: "image",
6533
- data: chunk.source.type === "base64" ? chunk.source.data : "",
6534
- mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
6535
- uri: chunk.source.type === "url" ? chunk.source.url : undefined,
6536
- },
6537
- };
6727
+ if (!containsFileChangeAuditToolUse)
6728
+ update = {
6729
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6730
+ content: {
6731
+ type: "image",
6732
+ data: chunk.source.type === "base64" ? chunk.source.data : "",
6733
+ mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
6734
+ uri: chunk.source.type === "url" ? chunk.source.url : undefined,
6735
+ },
6736
+ };
6538
6737
  break;
6539
6738
  case "thinking":
6540
6739
  case "thinking_delta": {
6541
6740
  // Recent models default `thinking.display` to "omitted", which streams
6542
6741
  // signature-only thinking blocks whose text is empty.
6543
- if (chunk.thinking) {
6742
+ if (chunk.thinking && !containsFileChangeAuditToolUse) {
6544
6743
  update = {
6545
6744
  sessionUpdate: "agent_thought_chunk",
6546
6745
  content: {
@@ -6556,7 +6755,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6556
6755
  case "mcp_tool_use": {
6557
6756
  const alreadyCached = chunk.id in toolUseCache;
6558
6757
  toolUseCache[chunk.id] = chunk;
6559
- if (chunk.name === "TodoWrite") {
6758
+ if (isFileChangeAuditTool(chunk.name)) {
6759
+ // Wrapper-owned audit protocol: never surface or register generic
6760
+ // PostToolUse callbacks for the internal tool.
6761
+ }
6762
+ else if (chunk.name === "TodoWrite") {
6560
6763
  // @ts-expect-error - sometimes input is empty object or undefined
6561
6764
  if (Array.isArray(chunk.input?.todos)) {
6562
6765
  update = {
@@ -6680,6 +6883,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6680
6883
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
6681
6884
  break;
6682
6885
  }
6886
+ if (isFileChangeAuditTool(toolUse.name)) {
6887
+ delete toolUseCache[chunk.tool_use_id];
6888
+ break;
6889
+ }
6683
6890
  // A permission request may have surfaced a plan-rendered (TodoWrite) or
6684
6891
  // suppressed (Task*) tool as a real tool_call so the request referenced
6685
6892
  // a tool call the client knows about (see `ensureToolCallEmitted`,
@@ -6710,20 +6917,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6710
6917
  }
6711
6918
  if (isTaskTool(toolUse.name)) {
6712
6919
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
6713
- // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
6714
- // and TaskGet are read-only so we just suppress their tool_call /
6715
- // tool_result events. The plan update is emitted as a snapshot of
6716
- // the accumulated state, mirroring the legacy TodoWrite behavior.
6920
+ // TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
6921
+ // reconciles it from the SDK's authoritative snapshot, which repairs
6922
+ // resumed or compacted sessions whose creating calls are no longer in
6923
+ // replay history. TaskGet is read-only and remains suppressed. Plan
6924
+ // updates always carry the full accumulated snapshot, mirroring the
6925
+ // legacy TodoWrite behavior.
6717
6926
  const isError = "is_error" in chunk && chunk.is_error;
6927
+ let shouldEmitTaskPlan = false;
6718
6928
  if (!isError) {
6719
6929
  if (toolUse.name === "TaskCreate") {
6720
- applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
6930
+ applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content));
6931
+ shouldEmitTaskPlan = true;
6721
6932
  }
6722
6933
  else if (toolUse.name === "TaskUpdate") {
6723
- applyTaskUpdate(taskState, toolUse.input);
6934
+ const input = toolUse.input;
6935
+ const output = parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
6936
+ parseTaskUpdateOutput(chunk.content, input?.taskId);
6937
+ // Older CLI transcripts have no structured output, so retain the
6938
+ // input-based fallback. When an output is available, only apply a
6939
+ // confirmed update for the same task.
6940
+ if (!output || (output.success && output.taskId === input?.taskId)) {
6941
+ applyTaskUpdate(taskState, input);
6942
+ shouldEmitTaskPlan = true;
6943
+ }
6944
+ }
6945
+ else if (toolUse.name === "TaskList") {
6946
+ const output = parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
6947
+ if (output) {
6948
+ applyTaskList(taskState, output);
6949
+ shouldEmitTaskPlan = true;
6950
+ }
6724
6951
  }
6725
6952
  }
6726
- if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
6953
+ if (shouldEmitTaskPlan) {
6727
6954
  update = {
6728
6955
  sessionUpdate: "plan",
6729
6956
  entries: taskStateToPlanEntries(taskState),
@@ -6763,7 +6990,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6763
6990
  toolCallId: chunk.tool_use_id,
6764
6991
  sessionUpdate: "tool_call_update",
6765
6992
  status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
6766
- rawOutput: chunk.content,
6993
+ // terminal_output already carried the exact bytes in the preceding
6994
+ // update. Repeating them as rawOutput wastes bandwidth and lets a
6995
+ // client accidentally render the same output twice.
6996
+ ...(toolMeta?.terminal_output
6997
+ ? {}
6998
+ : { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
6767
6999
  ...toolUpdate,
6768
7000
  };
6769
7001
  }
@@ -6784,7 +7016,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6784
7016
  case "compaction":
6785
7017
  case "compaction_delta":
6786
7018
  case "advisor_tool_result":
6787
- case "mid_conv_system":
6788
7019
  case "fallback":
6789
7020
  break;
6790
7021
  default:
@@ -6941,7 +7172,7 @@ export async function runPromptWithCancellation(agent, params, signal) {
6941
7172
  signal.removeEventListener("abort", onAbort);
6942
7173
  }
6943
7174
  }
6944
- export function runAcp() {
7175
+ export function runAcp(logger) {
6945
7176
  const input = nodeToWebWritable(process.stdout);
6946
7177
  const output = nodeToWebReadable(process.stdin);
6947
7178
  const stream = ndJsonStream(input, output);
@@ -6974,7 +7205,7 @@ export function runAcp() {
6974
7205
  .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
6975
7206
  .onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
6976
7207
  .connect(stream);
6977
- agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
7208
+ agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
6978
7209
  return { connection, agent };
6979
7210
  }
6980
7211
  function commonPrefixLength(a, b) {
@@ -7069,6 +7300,7 @@ const PROVIDER_ROUTING_ENV_VARS = [
7069
7300
  "ANTHROPIC_CUSTOM_HEADERS",
7070
7301
  "ANTHROPIC_API_KEY",
7071
7302
  "ANTHROPIC_AUTH_TOKEN",
7303
+ "CLAUDE_CODE_OAUTH_TOKEN",
7072
7304
  ];
7073
7305
  /** Stable identifier for the LLM backend a session's query is created against,
7074
7306
  * used to scope {@link contextWindowCache} per backend. Positional `\0`-join