@lucascouts/claude-agent-acp-plus 0.8.1 → 0.10.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 +132 -103
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +1096 -830
  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 +249 -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 +179 -24
  62. package/package.json +4 -4
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,
@@ -73,9 +76,13 @@ const STEER_METHOD = "_session/steering";
73
76
  * turn — an internal Claude implementation detail, not part of the wire
74
77
  * contract. `now` pre-empts the current generation and handles the message
75
78
  * immediately (interrupting a single-shot response, or slotting in between a
76
- * multi-step turn's tool calls). Maps to `SDKUserMessage.priority`; injected
77
- * steering always uses `now` so the running turn adapts as soon as possible. */
78
- const STEER_PRIORITY = "now";
79
+ * multi-step turn's tool calls), while `later` waits for a pending
80
+ * permission/elicitation callback to settle instead of cancelling its ACP
81
+ * request and hiding the client's user-input card (IJAI-1191). Maps to
82
+ * `SDKUserMessage.priority`; injected steering uses `now` unless the session is
83
+ * awaiting user input (see `Session.pendingUserInputCount`). */
84
+ const STEER_PRIORITY_NOW = "now";
85
+ const STEER_PRIORITY_LATER = "later";
79
86
  /** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
80
87
  * content blocks are handed to `promptToClaude`, which tolerates unknown block
81
88
  * types — but `sessionId` and a non-empty `prompt` array are required. */
@@ -114,11 +121,13 @@ function parseSteerRequest(params) {
114
121
  * result is the turn's real terminal.
115
122
  *
116
123
  * 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. */
124
+ * lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
125
+ * attribute this" marker, which gets the same safe default. Misrouting a
126
+ * USER result into the autonomous lane hangs the prompt un-detectably
127
+ * (the result is skipped, its trailing idle absorbed as owed, so the
128
+ * #825 detector can't fire); misrouting an autonomous result into the
129
+ * user lane is the bounded misattribution class this set exists to
130
+ * reduce. */
122
131
  const AUTONOMOUS_RESULT_ORIGINS = new Set([
123
132
  "task-notification",
124
133
  "peer",
@@ -157,17 +166,9 @@ function computeSessionFingerprint(params) {
157
166
  const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
158
167
  return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
159
168
  }
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
169
  const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
170
+ const PROVIDER_ID = "main";
171
+ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
171
172
  const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
172
173
  function supportsSubagentTranscript(capabilities) {
173
174
  return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
@@ -296,9 +297,6 @@ function shouldHideClaudeAuth() {
296
297
  * query stream has already ended (ran to `done` or died). The stream is not
297
298
  * revivable, so the only recovery is a fresh session. */
298
299
  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
300
  // Slash commands that the SDK handles locally without replaying the user
303
301
  // message and without invoking the model.
304
302
  const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
@@ -408,189 +406,6 @@ export function isSyntheticLoginMessage(apiMessage) {
408
406
  typeof block.text === "string" &&
409
407
  block.text.includes("Please run /login"));
410
408
  }
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
409
  /**
595
410
  * Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
596
411
  * exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
@@ -628,16 +443,38 @@ class ClientConnection {
628
443
  return this.ctx.notify(method, params);
629
444
  }
630
445
  }
446
+ function raceWithAbort(operation, signal) {
447
+ return new Promise((resolve, reject) => {
448
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
449
+ const onAbort = () => {
450
+ cleanup();
451
+ reject(new Error("Tool use aborted"));
452
+ };
453
+ signal.addEventListener("abort", onAbort, { once: true });
454
+ if (signal.aborted) {
455
+ onAbort();
456
+ }
457
+ void operation.then((value) => {
458
+ cleanup();
459
+ resolve(value);
460
+ }, (error) => {
461
+ cleanup();
462
+ reject(error);
463
+ });
464
+ });
465
+ }
631
466
  export class ClaudeAcpAgent {
632
467
  sessions;
633
468
  client;
634
469
  clientCapabilities;
635
470
  logger;
471
+ sessionModes;
636
472
  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}. */
473
+ /** Set while ACP overrides the agent's native provider configuration. */
640
474
  providerConfig;
475
+ /** Serializes provider changes while every open query is recreated between turns. */
476
+ providerUpdate = null;
477
+ exitPlan;
641
478
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
642
479
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
643
480
  * tests can shrink it. */
@@ -646,6 +483,48 @@ export class ClaudeAcpAgent {
646
483
  this.sessions = {};
647
484
  this.client = client;
648
485
  this.logger = logger ?? console;
486
+ this.exitPlan = new ExitPlanCoordinator({
487
+ currentSession: (id) => this.sessions[id],
488
+ closeQueryStream: (session) => this.closeQueryStream(session),
489
+ restartSession: async (params, options) => {
490
+ await this.createSession(params, options);
491
+ const session = this.sessions[options.publicSessionId];
492
+ if (!session)
493
+ throw new Error("Fresh Claude context was not created");
494
+ return session;
495
+ },
496
+ applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
497
+ sessionUpdate: (notification) => this.client.sessionUpdate(notification),
498
+ ensureConsumer: (session, id) => this.ensureConsumer(session, id),
499
+ logError: (message, error) => this.logger.error(message, error),
500
+ destroyReplacement: (id, session) => {
501
+ disarmForceCancel(session);
502
+ session.cancelController?.abort();
503
+ this.closeQueryStream(session);
504
+ session.abortController.abort();
505
+ if (this.sessions[id] === session)
506
+ delete this.sessions[id];
507
+ },
508
+ settleCancelledTurn: (original, session, turn) => {
509
+ disarmForceCancel(session);
510
+ this.finishFileChangeAudit(session, turn, "cancelled");
511
+ turn.settled = true;
512
+ turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) });
513
+ },
514
+ settleFailedTurn: (session, turn, error) => {
515
+ disarmForceCancel(session);
516
+ this.finishFileChangeAudit(session, turn, "providerError");
517
+ turn.settled = true;
518
+ turn.reject(error);
519
+ },
520
+ });
521
+ this.sessionModes = new SessionModeManager({
522
+ getSession: (sessionId) => this.sessions[sessionId],
523
+ sessionEndedMessage: SESSION_ENDED_MESSAGE,
524
+ updateConfigOption: (sessionId, configId, value) => this.updateConfigOption(sessionId, configId, value),
525
+ sessionUpdate: (params) => this.client.sessionUpdate(params),
526
+ logError: (...args) => this.logger.error(...args),
527
+ });
649
528
  }
650
529
  async initialize(request) {
651
530
  this.clientCapabilities = request.clientCapabilities;
@@ -790,6 +669,7 @@ export class ClaudeAcpAgent {
790
669
  // steering extension contract: advertises the `_session/steering` request
791
670
  // so clients know they may inject a follow-up into a running turn.
792
671
  _meta: {
672
+ ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
793
673
  steering: {
794
674
  supported: true,
795
675
  },
@@ -802,6 +682,8 @@ export class ClaudeAcpAgent {
802
682
  };
803
683
  }
804
684
  async newSession(params) {
685
+ if (this.providerUpdate)
686
+ await this.providerUpdate;
805
687
  const response = await this.createSession(params, {
806
688
  // Revisit these meta values once we support resume
807
689
  resume: params._meta?.claudeCode?.options?.resume,
@@ -813,6 +695,8 @@ export class ClaudeAcpAgent {
813
695
  return response;
814
696
  }
815
697
  async unstable_forkSession(params) {
698
+ if (this.providerUpdate)
699
+ await this.providerUpdate;
816
700
  const response = await this.createSession({
817
701
  cwd: params.cwd,
818
702
  mcpServers: params.mcpServers ?? [],
@@ -829,6 +713,8 @@ export class ClaudeAcpAgent {
829
713
  return response;
830
714
  }
831
715
  async resumeSession(params) {
716
+ if (this.providerUpdate)
717
+ await this.providerUpdate;
832
718
  const result = await this.getOrCreateSession(params);
833
719
  // Needs to happen after we return the session
834
720
  setTimeout(() => {
@@ -837,6 +723,8 @@ export class ClaudeAcpAgent {
837
723
  return result;
838
724
  }
839
725
  async loadSession(params) {
726
+ if (this.providerUpdate)
727
+ await this.providerUpdate;
840
728
  const result = await this.getOrCreateSession(params);
841
729
  await this.replaySessionHistory(params.sessionId);
842
730
  // Send available commands after replay so it doesn't interleave with history
@@ -862,40 +750,6 @@ export class ClaudeAcpAgent {
862
750
  sessions,
863
751
  };
864
752
  }
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
753
  async authenticate(_params) {
900
754
  if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
901
755
  this.gatewayAuthRequest = _params;
@@ -903,21 +757,14 @@ export class ClaudeAcpAgent {
903
757
  }
904
758
  throw new Error("Method not implemented.");
905
759
  }
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
760
  async unstable_listProviders(_params) {
915
- const config = this.resolveProviderConfig();
761
+ const config = this.providerConfig ?? this.defaultProviderConfig();
762
+ this.logger.log(`[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`);
916
763
  const provider = {
917
764
  providerId: PROVIDER_ID,
918
765
  supported: SUPPORTED_PROTOCOLS,
919
766
  required: false,
920
- current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
767
+ current: { apiType: config.apiType, baseUrl: config.baseUrl },
921
768
  };
922
769
  return { providers: [provider] };
923
770
  }
@@ -955,34 +802,49 @@ export class ClaudeAcpAgent {
955
802
  }
956
803
  config.vertex = { projectId: vertex.projectId, region: vertex.region };
957
804
  }
958
- this.providerConfig = config;
805
+ this.logger.log(`[providers/set] apiType=${config.apiType} baseUrl=${config.baseUrl} sessions=${Object.keys(this.sessions).length}`);
806
+ await this.enqueueProviderUpdate(config);
959
807
  return {};
960
808
  }
961
809
  /**
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.
810
+ * `providers/disable` ends ACP ownership of the single mutually exclusive
811
+ * backend slot and restores the agent's native routing state.
967
812
  */
968
813
  async unstable_disableProvider(params) {
969
814
  if (params.providerId === PROVIDER_ID) {
970
- this.providerConfig = undefined;
971
- this.gatewayAuthRequest = undefined;
815
+ this.logger.log(`[providers/disable] sessions=${Object.keys(this.sessions).length}`);
816
+ await this.enqueueProviderUpdate(undefined);
972
817
  }
973
818
  // Unknown provider: idempotent success.
974
819
  return {};
975
820
  }
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
821
  resolveProviderConfig() {
982
- if (this.providerConfig) {
983
- return this.providerConfig;
822
+ return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest);
823
+ }
824
+ defaultProviderConfig() {
825
+ const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest);
826
+ if (gatewayConfig) {
827
+ return gatewayConfig;
828
+ }
829
+ if (process.env.CLAUDE_CODE_USE_BEDROCK) {
830
+ return {
831
+ apiType: "bedrock",
832
+ baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
833
+ headers: {},
834
+ };
984
835
  }
985
- return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
836
+ if (process.env.CLAUDE_CODE_USE_VERTEX) {
837
+ return {
838
+ apiType: "vertex",
839
+ baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com",
840
+ headers: {},
841
+ };
842
+ }
843
+ return {
844
+ apiType: "anthropic",
845
+ baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
846
+ headers: {},
847
+ };
986
848
  }
987
849
  async logout(_params) {
988
850
  // Clear in-memory gateway credentials supplied via `authenticate` and any
@@ -1012,6 +874,8 @@ export class ClaudeAcpAgent {
1012
874
  }
1013
875
  }
1014
876
  async prompt(params) {
877
+ if (this.providerUpdate)
878
+ await this.providerUpdate;
1015
879
  const session = this.sessions[params.sessionId];
1016
880
  if (!session) {
1017
881
  throw new Error("Session not found");
@@ -1093,6 +957,12 @@ export class ClaudeAcpAgent {
1093
957
  }
1094
958
  return { stopReason: "end_turn" };
1095
959
  }
960
+ if (session.autoModeFallbackWarningPending) {
961
+ await this.sessionModes.publishFallbackWarning(params.sessionId, session);
962
+ }
963
+ if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
964
+ await this.publishTaskPlan(params.sessionId, session.taskState);
965
+ }
1096
966
  // Lazy Thinking application (R1.3): a pending config change is applied by
1097
967
  // recreating the SDK query with session resume BEFORE this turn is
1098
968
  // enqueued — never mid-turn, so only when no other prompt is in flight. A
@@ -1126,6 +996,16 @@ export class ClaudeAcpAgent {
1126
996
  // user message, so the consumer can't promote the turn from the echo.
1127
997
  const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
1128
998
  const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
999
+ const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
1000
+ ? agentFileChangeReportRequestId(params._meta)
1001
+ : undefined;
1002
+ let fileChangeAudit;
1003
+ if (fileChangeReportRequestId &&
1004
+ !session.fileChangeReportRequestIds.has(fileChangeReportRequestId)) {
1005
+ session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
1006
+ fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
1007
+ }
1008
+ session.titles.onPrompt(params.prompt);
1129
1009
  // Each prompt is a Turn whose deferred the persistent consumer settles once
1130
1010
  // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
1131
1011
  // turn, pushes the user message onto the streaming input, makes sure the
@@ -1133,13 +1013,24 @@ export class ClaudeAcpAgent {
1133
1013
  const turn = {
1134
1014
  promptUuid,
1135
1015
  isLocalOnlyCommand,
1016
+ ...(fileChangeAudit ? { fileChangeAudit } : {}),
1136
1017
  settled: false,
1137
1018
  resolve: () => { },
1138
1019
  reject: () => { },
1139
1020
  };
1021
+ let completeTurn;
1022
+ turn.completion = new Promise((resolve) => {
1023
+ completeTurn = resolve;
1024
+ });
1140
1025
  const response = new Promise((resolve, reject) => {
1141
- turn.resolve = resolve;
1142
- turn.reject = reject;
1026
+ turn.resolve = (result) => {
1027
+ resolve(result);
1028
+ completeTurn();
1029
+ };
1030
+ turn.reject = (error) => {
1031
+ reject(error);
1032
+ completeTurn();
1033
+ };
1143
1034
  });
1144
1035
  session.turnQueue ??= [];
1145
1036
  session.turnQueue.push(turn);
@@ -1174,6 +1065,15 @@ export class ClaudeAcpAgent {
1174
1065
  },
1175
1066
  });
1176
1067
  }
1068
+ async publishTaskPlan(sessionId, taskState) {
1069
+ await this.client.sessionUpdate({
1070
+ sessionId,
1071
+ update: {
1072
+ sessionUpdate: "plan",
1073
+ entries: taskStateToPlanEntries(taskState),
1074
+ },
1075
+ });
1076
+ }
1177
1077
  async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
1178
1078
  const goalUpdate = goalUpdateFromPrompt(prompt);
1179
1079
  if (goalUpdate !== undefined) {
@@ -1213,11 +1113,14 @@ export class ClaudeAcpAgent {
1213
1113
  * an `SDKUserMessage` onto the same streaming input, which the SDK routes
1214
1114
  * into the in-flight turn. The injected message's echo carries a uuid that
1215
1115
  * matches no queued turn, so the consumer drops it as an unrelated replay
1216
- * without promoting/settling anything. It is delivered at {@link
1217
- * STEER_PRIORITY} (`now`) so it pre-empts the current generation (interrupting
1218
- * a single-shot response, or slotting in between a multi-step turn's tool
1219
- * calls). The steered message's own output streams via `session/update`, not
1220
- * this response.
1116
+ * without promoting/settling anything. It is normally delivered at {@link
1117
+ * STEER_PRIORITY_NOW} so it pre-empts the current generation (interrupting a
1118
+ * single-shot response, or slotting in between a multi-step turn's tool
1119
+ * calls). While a permission or elicitation is awaiting user input it uses
1120
+ * {@link STEER_PRIORITY_LATER} instead, because interrupting that SDK
1121
+ * callback cancels the ACP request and can strand the prompt (IJAI-1191).
1122
+ * The steered message's own output streams via `session/update`, not this
1123
+ * response.
1221
1124
  *
1222
1125
  * Pre-empting means ABORTING: the interrupted cycle emits a `result` of its
1223
1126
  * own and the steered message runs as a second one, so the turn is marked
@@ -1268,8 +1171,11 @@ export class ClaudeAcpAgent {
1268
1171
  const steeredUuid = randomUUID();
1269
1172
  userMessage.uuid = steeredUuid;
1270
1173
  // Deliver into the running turn rather than queuing behind it as a fresh
1271
- // prompt would.
1272
- userMessage.priority = STEER_PRIORITY;
1174
+ // prompt would. A steer landing while the client has a permission or
1175
+ // elicitation card open must not pre-empt it — the interrupt cancels that
1176
+ // ACP request — so it waits for the LAST such request to settle instead.
1177
+ userMessage.priority =
1178
+ (session.pendingUserInputCount ?? 0) > 0 ? STEER_PRIORITY_LATER : STEER_PRIORITY_NOW;
1273
1179
  // Mark before the push and in the same synchronous section as the in-flight
1274
1180
  // check: the interrupt can have the CLI finalizing the aborted cycle by the
1275
1181
  // time the consumer next runs, and an unmarked result would settle the turn
@@ -1287,6 +1193,15 @@ export class ClaudeAcpAgent {
1287
1193
  await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
1288
1194
  return { outcome: "injected" };
1289
1195
  }
1196
+ /** Publish the audit terminal for every turn path that did not reach the
1197
+ * report tool. The support flips the turn state synchronously before its
1198
+ * transport await, so callers can stay fail-open and settle the ACP prompt
1199
+ * immediately without allowing a racing lifecycle path to publish twice. */
1200
+ finishFileChangeAudit(session, turn, reason) {
1201
+ if (!turn.fileChangeAudit || !session.fileChangeAuditSupport)
1202
+ return;
1203
+ void session.fileChangeAuditSupport.finishUnavailable(turn.fileChangeAudit, reason);
1204
+ }
1290
1205
  /** Lazily start the per-session consumer that drains the SDK query stream for
1291
1206
  * the session's whole life. Idempotent: only the first `prompt()` starts it. */
1292
1207
  ensureConsumer(session, sessionId) {
@@ -1321,6 +1236,8 @@ export class ClaudeAcpAgent {
1321
1236
  // it here so the subsequent `RequestError.internalError` can forward it to
1322
1237
  // clients as structured `data`, sparing them from pattern-matching on text.
1323
1238
  let lastAssistantError;
1239
+ let lastAssistantWasUsageLimit = false;
1240
+ let lastAssistantFailureTitle;
1324
1241
  // When a streaming classifier refuses a turn, the assistant message carries
1325
1242
  // stop_reason "refusal" and structured stop_details. We capture the
1326
1243
  // human-readable explanation so the terminal `result` can surface it.
@@ -1366,19 +1283,67 @@ export class ClaudeAcpAgent {
1366
1283
  * as the turn's answer. */
1367
1284
  const sendUpdate = async (notification) => {
1368
1285
  const { update } = notification;
1286
+ if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
1287
+ (update.sessionUpdate === "agent_message_chunk" ||
1288
+ update.sessionUpdate === "agent_thought_chunk" ||
1289
+ update.sessionUpdate === "user_message_chunk" ||
1290
+ update.sessionUpdate === "tool_call" ||
1291
+ update.sessionUpdate === "tool_call_update")) {
1292
+ return;
1293
+ }
1369
1294
  if (update.sessionUpdate === "agent_message_chunk") {
1370
1295
  const claudeMeta = update._meta?.claudeCode;
1371
1296
  if (!claudeMeta?.parentToolUseId) {
1372
1297
  session.emittedAssistantText = true;
1298
+ session.titles.onAssistantText(update.content);
1373
1299
  }
1374
1300
  }
1375
1301
  await this.client.sessionUpdate(notification);
1376
1302
  };
1303
+ let pendingWorkerShutdown = false;
1304
+ const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
1305
+ const sessionFailures = new SessionFailureController({
1306
+ sessionId: params.sessionId,
1307
+ state: session.sessionFailureState,
1308
+ capabilities: this.clientCapabilities,
1309
+ isCurrent: isCurrentConsumer,
1310
+ sendUpdate,
1311
+ logger: this.logger,
1312
+ });
1313
+ const createSessionFailure = async (kind, options = {}) => {
1314
+ const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1315
+ return sessionFailures.prepare(kind, {
1316
+ turnId,
1317
+ sessionScoped: options.turnScoped === false,
1318
+ title: options.title,
1319
+ details: options.details,
1320
+ severity: options.severity,
1321
+ });
1322
+ };
1323
+ const publishSessionFailure = async (kind, options = {}) => {
1324
+ const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
1325
+ await sessionFailures.publish(kind, {
1326
+ turnId,
1327
+ sessionScoped: options.turnScoped === false,
1328
+ title: options.title,
1329
+ details: options.details,
1330
+ severity: options.severity,
1331
+ });
1332
+ };
1333
+ const clearFailuresFromEarlierTurns = async () => {
1334
+ const activeTurnId = session.activeTurn?.promptUuid;
1335
+ // Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
1336
+ // They are session-scoped and stay until superseded or dismissed by the user.
1337
+ await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
1338
+ };
1339
+ const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
1377
1340
  const resetTurnScratch = () => {
1378
1341
  lastAssistantTotalUsage = null;
1379
1342
  lastAssistantUsage = null;
1380
1343
  lastAssistantModel = null;
1381
1344
  lastAssistantError = undefined;
1345
+ lastAssistantWasUsageLimit = false;
1346
+ lastAssistantFailureTitle = undefined;
1382
1347
  lastRefusalExplanation = null;
1383
1348
  compactionInProgress = false;
1384
1349
  // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
@@ -1390,12 +1355,14 @@ export class ClaudeAcpAgent {
1390
1355
  // cleared when each consolidated message consumes it. #785 stopped
1391
1356
  // resetting the streamed-content tracking here but left this line.
1392
1357
  stopReason = "end_turn";
1393
- session.accumulatedUsage = {
1358
+ session.accumulatedUsage = session.activeTurn?.carriedUsage ?? {
1394
1359
  inputTokens: 0,
1395
1360
  outputTokens: 0,
1396
1361
  cachedReadTokens: 0,
1397
1362
  cachedWriteTokens: 0,
1398
1363
  };
1364
+ if (session.activeTurn)
1365
+ session.activeTurn.carriedUsage = undefined;
1399
1366
  };
1400
1367
  /** Promote a queued turn to active: it becomes the one output is attributed
1401
1368
  * to, and its scratch starts fresh. Clears the cancelled flag so a turn
@@ -1622,11 +1589,14 @@ export class ClaudeAcpAgent {
1622
1589
  };
1623
1590
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
1624
1591
  * backstop (the turn is over), and drop it from the queue. */
1625
- const settleActive = (result) => {
1592
+ const settleActive = (result, auditReason = result.stopReason === "cancelled"
1593
+ ? "cancelled"
1594
+ : "notReported") => {
1626
1595
  const turn = session.activeTurn;
1627
1596
  if (!turn || turn.settled) {
1628
1597
  return;
1629
1598
  }
1599
+ this.finishFileChangeAudit(session, turn, auditReason);
1630
1600
  // Captured before the settled flip below (isHeldOpen tests !settled).
1631
1601
  const wasHeld = isHeldOpen(turn);
1632
1602
  turn.settled = true;
@@ -1657,8 +1627,10 @@ export class ClaudeAcpAgent {
1657
1627
  disarmForceCancel(session);
1658
1628
  const turn = session.activeTurn;
1659
1629
  if (!turn || turn.settled) {
1630
+ this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
1660
1631
  return;
1661
1632
  }
1633
+ this.finishFileChangeAudit(session, turn, "providerError");
1662
1634
  turn.settled = true;
1663
1635
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
1664
1636
  session.activeTurn = null;
@@ -1670,6 +1642,31 @@ export class ClaudeAcpAgent {
1670
1642
  session.emittedAssistantText = false;
1671
1643
  turn.reject(error);
1672
1644
  };
1645
+ /** Complete a negotiated terminal failure on the prompt response itself,
1646
+ * which is the canonical AIR carrier. Legacy clients keep the historical
1647
+ * JSON-RPC rejection path. */
1648
+ const failActiveWithSessionFailure = async (kind, error, title) => {
1649
+ if (!supportsAirSessionFailures(this.clientCapabilities)) {
1650
+ failActive(error);
1651
+ return;
1652
+ }
1653
+ if (!session.activeTurn || session.activeTurn.settled) {
1654
+ this.logger.error(`Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`);
1655
+ await publishSessionFailure(kind, { turnScoped: false, title });
1656
+ return;
1657
+ }
1658
+ const failure = await createSessionFailure(kind, { title });
1659
+ if (!failure) {
1660
+ failActive(error);
1661
+ return;
1662
+ }
1663
+ sessionFailures.recordActive(failure);
1664
+ settleActive({
1665
+ stopReason: "end_turn",
1666
+ usage: sessionUsage(session),
1667
+ _meta: sessionFailureMeta(failure),
1668
+ }, "providerError");
1669
+ };
1673
1670
  /** Reject every in-flight turn — used when the stream dies. */
1674
1671
  const failAllTurns = (error) => {
1675
1672
  disarmForceCancel(session);
@@ -1680,6 +1677,7 @@ export class ClaudeAcpAgent {
1680
1677
  session.turnQueue = [];
1681
1678
  for (const turn of turns) {
1682
1679
  if (!turn.settled) {
1680
+ this.finishFileChangeAudit(session, turn, "providerError");
1683
1681
  const wasHeld = isHeldOpen(turn);
1684
1682
  turn.settled = true;
1685
1683
  if (wasHeld) {
@@ -1752,7 +1750,9 @@ export class ClaudeAcpAgent {
1752
1750
  // spent would never drain (it would swallow an unrelated later
1753
1751
  // echo-less result instead).
1754
1752
  const active = session.activeTurn;
1755
- if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
1753
+ if (active.commandFinished === "completed" ||
1754
+ active.commandFinished === "discarded" ||
1755
+ active.commandFinished === "refused") {
1756
1756
  // Finished SDK-side; any result already passed. Nothing to
1757
1757
  // track.
1758
1758
  }
@@ -1812,6 +1812,24 @@ export class ClaudeAcpAgent {
1812
1812
  if (session.query !== myQuery) {
1813
1813
  return;
1814
1814
  }
1815
+ if (pendingWorkerShutdown) {
1816
+ pendingWorkerShutdown = false;
1817
+ if (session.activeTurn) {
1818
+ if (!isHeldOpen(session.activeTurn)) {
1819
+ await failActiveWithSessionFailure("worker_shutdown", internalErrorForClient({ errorKind: "worker_shutdown" }));
1820
+ }
1821
+ else {
1822
+ // The held turn already has its authoritative terminal outcome,
1823
+ // but EOF permanently closes the non-revivable Query. Preserve
1824
+ // the turn result below and report the independent session-health
1825
+ // failure without a turnId so AIR can offer a new session.
1826
+ await publishSessionFailure("worker_shutdown", { turnScoped: false });
1827
+ }
1828
+ }
1829
+ else {
1830
+ await publishSessionFailure("worker_shutdown", { turnScoped: false });
1831
+ }
1832
+ }
1815
1833
  // The stream ended. Settle the in-flight turns FIRST, then release the
1816
1834
  // stream resources — same order as the error paths (failAllTurns before
1817
1835
  // closeQueryStream). Settling is the user-facing contract; resource
@@ -1835,6 +1853,7 @@ export class ClaudeAcpAgent {
1835
1853
  // still here was enqueued afterward and was not part of the cancel.)
1836
1854
  for (const queued of [...(session.turnQueue ?? [])]) {
1837
1855
  if (!queued.settled) {
1856
+ this.finishFileChangeAudit(session, queued, "providerError");
1838
1857
  queued.settled = true;
1839
1858
  queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
1840
1859
  }
@@ -1856,7 +1875,7 @@ export class ClaudeAcpAgent {
1856
1875
  }
1857
1876
  // CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
1858
1877
  // uuid-stamped queued command (queued/started/completed/cancelled/
1859
- // discarded) as `command_lifecycle` frames — 2-3 per prompt, since
1878
+ // discarded/refused) as `command_lifecycle` frames — 2-3 per prompt, since
1860
1879
  // prompt() stamps a uuid on every message. The frame is @internal and
1861
1880
  // absent from the SDKMessage union, so handle it BEFORE the exhaustive
1862
1881
  // switch: it must not reach `unreachable`'s error log, and a `case`
@@ -1891,6 +1910,7 @@ export class ClaudeAcpAgent {
1891
1910
  }
1892
1911
  case "completed":
1893
1912
  case "discarded":
1913
+ case "refused":
1894
1914
  case "cancelled": {
1895
1915
  // Terminal frames. Latch the fate on a still-queued turn so a
1896
1916
  // later cancel() doesn't seed an orphan entry for a command
@@ -1924,6 +1944,9 @@ export class ClaudeAcpAgent {
1924
1944
  // command folded into another turn whose result is attributed
1925
1945
  // elsewhere — either way no echo-less result remains to skip.
1926
1946
  // "discarded" = session ended with it still queued; no result.
1947
+ // "refused" (2.1.238+) = a cross-session peer message declined
1948
+ // by receive-side policy before dispatch; never a prompt-lane
1949
+ // command of ours, and no result will ever come.
1927
1950
  session.orphanCommands?.delete(frame.command_uuid);
1928
1951
  break;
1929
1952
  }
@@ -1963,6 +1986,23 @@ export class ClaudeAcpAgent {
1963
1986
  // updated Fast mode state; reconcile it with what we seeded at
1964
1987
  // session creation.
1965
1988
  await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
1989
+ // Terminal-bound slash commands (absent when none, and on
1990
+ // older CLIs). The session/new advertisement runs before any
1991
+ // init frame can be observed, so the first latch (or a
1992
+ // genuine change) re-publishes the now-filtered list.
1993
+ if (message.terminal_slash_commands &&
1994
+ JSON.stringify(message.terminal_slash_commands) !==
1995
+ JSON.stringify(session.terminalSlashCommands)) {
1996
+ session.terminalSlashCommands = message.terminal_slash_commands;
1997
+ try {
1998
+ await this.sendAvailableCommandsUpdate(message.session_id);
1999
+ }
2000
+ catch (error) {
2001
+ // Advisory reconcile only — the client keeps its current
2002
+ // (unfiltered) list; never fail the turn over it.
2003
+ this.logger.error(`Failed to re-advertise slash commands: ${error}`);
2004
+ }
2005
+ }
1966
2006
  break;
1967
2007
  case "status": {
1968
2008
  // These banners count as delivered text (via sendUpdate), so
@@ -2030,6 +2070,7 @@ export class ClaudeAcpAgent {
2030
2070
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
2031
2071
  lastAssistantUsage = null;
2032
2072
  lastAssistantTotalUsage = usedTokens ?? 0;
2073
+ session.contextUsedTokens = usedTokens ?? 0;
2033
2074
  await sendUpdate({
2034
2075
  sessionId: message.session_id,
2035
2076
  update: {
@@ -2155,13 +2196,11 @@ export class ClaudeAcpAgent {
2155
2196
  // the next prompt; only a timer could tell those apart.
2156
2197
  this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
2157
2198
  `for the active turn; failing the in-flight prompt (issue #825)`);
2158
- failActive(RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
2199
+ await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
2159
2200
  }
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);
2201
+ // Turn-over is when a title may have landed or become
2202
+ // generatable; see SessionTitles.onTurnEnd.
2203
+ await session.titles.onTurnEnd(session);
2165
2204
  }
2166
2205
  break;
2167
2206
  }
@@ -2213,7 +2252,7 @@ export class ClaudeAcpAgent {
2213
2252
  sessionId: message.session_id,
2214
2253
  update: {
2215
2254
  sessionUpdate: "available_commands_update",
2216
- availableCommands: getAvailableSlashCommands(message.commands),
2255
+ availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
2217
2256
  },
2218
2257
  });
2219
2258
  break;
@@ -2355,9 +2394,14 @@ export class ClaudeAcpAgent {
2355
2394
  }
2356
2395
  break;
2357
2396
  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.
2397
+ // Defer until stream end. The announcement is durable and may be
2398
+ // replayed before later frames, but those frames do not prove that
2399
+ // a new worker epoch began: they can be buffered output from the
2400
+ // shutting-down worker. Keep the signal armed until the transport
2401
+ // actually ends. Deliberately do not add a quiet-period timer: the
2402
+ // iterator has no replay/live boundary, so a timeout could publish
2403
+ // while a slow replay is still in flight.
2404
+ pendingWorkerShutdown = true;
2361
2405
  break;
2362
2406
  case "elicitation_complete": {
2363
2407
  // A url-mode MCP elicitation finished server-side. Let the client
@@ -2377,10 +2421,21 @@ export class ClaudeAcpAgent {
2377
2421
  }
2378
2422
  case "plugin_install":
2379
2423
  case "notification":
2380
- case "api_retry":
2381
2424
  case "thinking_tokens":
2382
2425
  // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
2383
2426
  break;
2427
+ case "api_retry": {
2428
+ const title = message.error_status === null
2429
+ ? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
2430
+ : `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
2431
+ await publishSessionFailure(message.error_status === null
2432
+ ? "transport_lost"
2433
+ : providerFailureCategory(message.error), {
2434
+ title,
2435
+ severity: "warning",
2436
+ });
2437
+ break;
2438
+ }
2384
2439
  case "model_refusal_fallback": {
2385
2440
  // The SDK retried a refused turn on the fallback model and made
2386
2441
  // the swap persistent for the session. Without a notice the
@@ -2394,26 +2449,53 @@ export class ClaudeAcpAgent {
2394
2449
  // CLIs, where "revert" marked a turn-only fallback — for that
2395
2450
  // direction the session stays on the original model, so skip
2396
2451
  // the persistent-swap claim and the state sync.
2397
- const persistent = message.direction !== "revert";
2452
+ //
2453
+ // `scope` (CLI 2.1.232+) marks WHERE the fallback happened:
2454
+ // "local" means a subagent / side-question / background fork
2455
+ // response fell back and the session model is unchanged, so
2456
+ // syncing the picker would advertise a model the session
2457
+ // isn't running. Absent scope means an older CLI, where every
2458
+ // retry was a session-level swap — treat as "session".
2459
+ const local = message.scope === "local";
2460
+ const persistent = message.direction !== "revert" && !local;
2398
2461
  const category = message.api_refusal_category
2399
2462
  ? ` (${message.api_refusal_category})`
2400
2463
  : "";
2401
- const explanation = message.api_refusal_explanation
2402
- ? `\n\n${message.api_refusal_explanation}`
2403
- : "";
2404
2464
  const outcome = persistent
2405
2465
  ? `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}`,
2466
+ : local
2467
+ ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
2468
+ : `The session stays on ${message.original_model}.`;
2469
+ const fallbackSummary = `${message.original_model} declined this request${category}; ` +
2470
+ `retried with ${message.fallback_model}. ${outcome}`;
2471
+ const explanation = message.api_refusal_explanation || undefined;
2472
+ const fallbackNotice = explanation
2473
+ ? `${fallbackSummary}\n\n${explanation}`
2474
+ : fallbackSummary;
2475
+ // A silent model swap is a session-level advisory, not something the model said.
2476
+ // Clients that negotiated typed records get it as one; the rest keep the bold-label
2477
+ // transcript line, which was the only way to flag it before.
2478
+ if (supportsAirSessionFailures(this.clientCapabilities)) {
2479
+ const useDetails = explanation !== undefined &&
2480
+ fallbackSummary.length + 2 + explanation.length >
2481
+ MAX_INLINE_FAILURE_TITLE_LENGTH;
2482
+ await publishSessionFailure("advisory", {
2483
+ title: useDetails ? fallbackSummary : fallbackNotice,
2484
+ ...(useDetails ? { details: explanation } : {}),
2485
+ });
2486
+ }
2487
+ else {
2488
+ await sendUpdate({
2489
+ sessionId: message.session_id,
2490
+ update: {
2491
+ sessionUpdate: "agent_message_chunk",
2492
+ content: {
2493
+ type: "text",
2494
+ text: `**Model fallback:** ${fallbackNotice}`,
2495
+ },
2414
2496
  },
2415
- },
2416
- });
2497
+ });
2498
+ }
2417
2499
  if (persistent) {
2418
2500
  await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
2419
2501
  }
@@ -2498,6 +2580,8 @@ export class ClaudeAcpAgent {
2498
2580
  // user-turn lifecycle (stop reason, settles, failActive,
2499
2581
  // slash-command output forwarding), though their cost is real.
2500
2582
  const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
2583
+ const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption;
2584
+ const pendingExitPlanContextReset = session.pendingExitPlanContextReset;
2501
2585
  try {
2502
2586
  // Reconcile the Fast mode toggle with the SDK's reported state.
2503
2587
  // Gated to user-driven turns like every other side effect below;
@@ -2650,7 +2734,10 @@ export class ClaudeAcpAgent {
2650
2734
  });
2651
2735
  }
2652
2736
  if (session.cancelled) {
2737
+ session.pendingExitPlanModeInterruption = undefined;
2738
+ session.pendingExitPlanContextReset = undefined;
2653
2739
  if (!isAutonomousResult) {
2740
+ await clearFailuresFromEarlierTurns();
2654
2741
  stopReason = "cancelled";
2655
2742
  }
2656
2743
  break;
@@ -2686,6 +2773,37 @@ export class ClaudeAcpAgent {
2686
2773
  }
2687
2774
  break;
2688
2775
  }
2776
+ await clearFailuresFromEarlierTurns();
2777
+ // `interrupt: true` is required to make "No, keep planning"
2778
+ // terminate the ACP turn. Claude represents that intentional
2779
+ // interrupt as an error-shaped diagnostic, so translate only a
2780
+ // diagnostic causally paired with the recorded ExitPlanMode
2781
+ // permission response.
2782
+ const diagnostic = executionDiagnostic(message);
2783
+ if (pendingExitPlanModeInterruption &&
2784
+ pendingExitPlanModeInterruption.toolResultSeen &&
2785
+ message.is_error &&
2786
+ diagnostic &&
2787
+ /(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
2788
+ /(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic)) {
2789
+ session.pendingExitPlanModeInterruption = undefined;
2790
+ if (pendingExitPlanContextReset &&
2791
+ pendingExitPlanContextReset.toolUseId ===
2792
+ pendingExitPlanModeInterruption.toolUseId) {
2793
+ await this.exitPlan.restart(params.sessionId, session, pendingExitPlanContextReset);
2794
+ return;
2795
+ }
2796
+ stopReason = "cancelled";
2797
+ settleOrDefer({ stopReason: "cancelled", usage: sessionUsage(session) });
2798
+ break;
2799
+ }
2800
+ if (pendingExitPlanModeInterruption) {
2801
+ // This result ended the interrupted cycle without the exact
2802
+ // correlated cancellation shape. Never carry its marker into
2803
+ // a later turn, whether or not the tool result was observed.
2804
+ session.pendingExitPlanModeInterruption = undefined;
2805
+ session.pendingExitPlanContextReset = undefined;
2806
+ }
2689
2807
  // A refusal can arrive on any result subtype (and may even set
2690
2808
  // is_error), so handle it before the subtype switch — otherwise the
2691
2809
  // is_error throw below would surface it as an internal error. The
@@ -2722,10 +2840,15 @@ export class ClaudeAcpAgent {
2722
2840
  settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
2723
2841
  break;
2724
2842
  }
2843
+ if (!message.is_error && lastAssistantModel !== null) {
2844
+ const activeTurnId = session.activeTurn?.promptUuid;
2845
+ await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
2846
+ (failure.severity === "warning" && failure.turnId === activeTurnId));
2847
+ }
2725
2848
  switch (message.subtype) {
2726
2849
  case "success": {
2727
2850
  if (message.result.includes("Please run /login")) {
2728
- failActive(RequestError.authRequired());
2851
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
2729
2852
  break;
2730
2853
  }
2731
2854
  if (message.stop_reason === "max_tokens") {
@@ -2733,7 +2856,7 @@ export class ClaudeAcpAgent {
2733
2856
  break;
2734
2857
  }
2735
2858
  if (message.is_error) {
2736
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2859
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), lastAssistantFailureTitle ?? message.result);
2737
2860
  break;
2738
2861
  }
2739
2862
  // The result text is forwarded in two cases. Local-only
@@ -2769,17 +2892,29 @@ export class ClaudeAcpAgent {
2769
2892
  break;
2770
2893
  }
2771
2894
  if (message.is_error) {
2772
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2895
+ await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype));
2773
2896
  break;
2774
2897
  }
2775
2898
  stopReason = "end_turn";
2776
2899
  break;
2777
2900
  }
2778
2901
  case "error_max_budget_usd":
2902
+ if (message.is_error) {
2903
+ await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2904
+ break;
2905
+ }
2906
+ stopReason = "max_turn_requests";
2907
+ break;
2779
2908
  case "error_max_turns":
2909
+ if (message.is_error) {
2910
+ await failActiveWithSessionFailure("context_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2911
+ break;
2912
+ }
2913
+ stopReason = "max_turn_requests";
2914
+ break;
2780
2915
  case "error_max_structured_output_retries":
2781
2916
  if (message.is_error) {
2782
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2917
+ await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
2783
2918
  break;
2784
2919
  }
2785
2920
  stopReason = "max_turn_requests";
@@ -2917,6 +3052,7 @@ export class ClaudeAcpAgent {
2917
3052
  const nextUsage = totalTokens(lastAssistantUsage);
2918
3053
  if (nextUsage !== lastAssistantTotalUsage) {
2919
3054
  lastAssistantTotalUsage = nextUsage;
3055
+ session.contextUsedTokens = nextUsage;
2920
3056
  await sendUpdate({
2921
3057
  sessionId: params.sessionId,
2922
3058
  update: {
@@ -3053,6 +3189,11 @@ export class ClaudeAcpAgent {
3053
3189
  if (message.type === "assistant" && message.parent_tool_use_id === null) {
3054
3190
  lastAssistantUsage = snapshotFromUsage(message.message.usage);
3055
3191
  lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
3192
+ session.contextUsedTokens = lastAssistantTotalUsage;
3193
+ lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
3194
+ if (message.error || lastAssistantWasUsageLimit) {
3195
+ lastAssistantFailureTitle = assistantMessageText(message.message);
3196
+ }
3056
3197
  if (message.message.model && message.message.model !== "<synthetic>") {
3057
3198
  lastAssistantModel = message.message.model;
3058
3199
  }
@@ -3111,7 +3252,18 @@ export class ClaudeAcpAgent {
3111
3252
  break;
3112
3253
  }
3113
3254
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3114
- failActive(RequestError.authRequired());
3255
+ await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), assistantMessageText(message.message));
3256
+ break;
3257
+ }
3258
+ // AIR receives this provider condition on the terminal prompt
3259
+ // response as a typed failure whose title is the exact assistant
3260
+ // error text captured above. Do not duplicate that text as an
3261
+ // ordinary assistant message; legacy clients retain the historical
3262
+ // transcript behavior.
3263
+ if (message.type === "assistant" &&
3264
+ message.parent_tool_use_id === null &&
3265
+ (message.error || isSyntheticUsageLimitMessage(message.message)) &&
3266
+ supportsAirSessionFailures(this.clientCapabilities)) {
3115
3267
  break;
3116
3268
  }
3117
3269
  let content;
@@ -3188,6 +3340,7 @@ export class ClaudeAcpAgent {
3188
3340
  else {
3189
3341
  content = message.message.content;
3190
3342
  }
3343
+ const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
3191
3344
  for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
3192
3345
  clientCapabilities: this.clientCapabilities,
3193
3346
  parentToolUseId: message.parent_tool_use_id,
@@ -3206,7 +3359,7 @@ export class ClaudeAcpAgent {
3206
3359
  // filtered out of `content` above; blocks that do pass through
3207
3360
  // (e.g. a subagent image) carry the stamped parentToolUseId
3208
3361
  // meta and are excluded there.
3209
- await sendUpdate(notification);
3362
+ await sendUpdate(acceptedPlanToolResult(notification, acceptedPlanToolUseId));
3210
3363
  }
3211
3364
  break;
3212
3365
  }
@@ -3273,13 +3426,26 @@ export class ClaudeAcpAgent {
3273
3426
  }
3274
3427
  break;
3275
3428
  }
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.
3429
+ case "conversation_reset": {
3430
+ // The SDK has switched to a fresh conversation, whose Task* IDs
3431
+ // and task store are independent of the previous transcript.
3432
+ // Clear both the in-memory snapshot and the client's visible plan
3433
+ // before any follow-up prompt can republish stale tasks.
3434
+ session.taskState.clear();
3435
+ await this.publishTaskPlan(params.sessionId, session.taskState);
3436
+ // A reset mounts a fresh transcript (`new_conversation_id`), so our
3437
+ // cached title no longer describes the session: drop it and
3438
+ // re-evaluate at the next turn-end.
3439
+ session.titles.reset();
3440
+ break;
3441
+ }
3279
3442
  case "tool_use_summary":
3280
- case "auth_status":
3281
3443
  case "prompt_suggestion":
3282
- case "conversation_reset":
3444
+ break;
3445
+ case "auth_status":
3446
+ if (!message.isAuthenticating && message.error === undefined) {
3447
+ await sessionFailures.clear((failure) => failure.kind === "auth_required");
3448
+ }
3283
3449
  break;
3284
3450
  default:
3285
3451
  unreachable(message, this.logger);
@@ -3308,6 +3474,19 @@ export class ClaudeAcpAgent {
3308
3474
  message.includes("process exited with") ||
3309
3475
  message.includes("process terminated by signal") ||
3310
3476
  message.includes("Failed to write to process stdin"));
3477
+ if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
3478
+ if (!isHeldOpen(session.activeTurn)) {
3479
+ await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
3480
+ }
3481
+ else {
3482
+ // The held turn keeps its recorded PromptResponse outcome, while the
3483
+ // exhausted Query makes the session independently unrecoverable.
3484
+ await publishSessionFailure("transport_lost", { turnScoped: false });
3485
+ }
3486
+ }
3487
+ else {
3488
+ await publishSessionFailure("transport_lost", { turnScoped: false });
3489
+ }
3311
3490
  // Either way the query iterator is finished and the consumer is exiting,
3312
3491
  // so release its resources via closeQueryStream (idempotent). A process
3313
3492
  // death is unrecoverable, so additionally evict the session so the client
@@ -3321,7 +3500,9 @@ export class ClaudeAcpAgent {
3321
3500
  }
3322
3501
  else {
3323
3502
  this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
3324
- failAllTurns(error);
3503
+ failAllTurns(supportsAirSessionFailures(this.clientCapabilities)
3504
+ ? internalErrorForClient({ errorKind: "transport_lost" })
3505
+ : error);
3325
3506
  this.closeQueryStream(session);
3326
3507
  }
3327
3508
  }
@@ -3351,6 +3532,7 @@ export class ClaudeAcpAgent {
3351
3532
  }
3352
3533
  }
3353
3534
  async cancel(params) {
3535
+ this.exitPlan.cancel(params.sessionId);
3354
3536
  const session = this.sessions[params.sessionId];
3355
3537
  if (!session) {
3356
3538
  return;
@@ -3364,6 +3546,9 @@ export class ClaudeAcpAgent {
3364
3546
  if (session.queryRecreateInFlight) {
3365
3547
  await session.queryRecreateInFlight;
3366
3548
  }
3549
+ session.cancelled = true;
3550
+ session.pendingExitPlanModeInterruption = undefined;
3551
+ session.pendingExitPlanContextReset = undefined;
3367
3552
  // The stream already ended (see closeQueryStream): every in-flight turn was
3368
3553
  // settled when it closed, and there is no live query to interrupt. Calling
3369
3554
  // query.interrupt() on a finished iterator could reject and surface from
@@ -3371,7 +3556,6 @@ export class ClaudeAcpAgent {
3371
3556
  if (session.queryClosed) {
3372
3557
  return;
3373
3558
  }
3374
- session.cancelled = true;
3375
3559
  // A priority steer may still be queued in the SDK when cancellation
3376
3560
  // settles its owning turn. Its later echo matches no live turn, and its
3377
3561
  // result must be skipped rather than promoted onto the next prompt.
@@ -3395,6 +3579,7 @@ export class ClaudeAcpAgent {
3395
3579
  if (session.turnQueue) {
3396
3580
  for (const turn of session.turnQueue) {
3397
3581
  if (turn !== session.activeTurn && !turn.settled) {
3582
+ this.finishFileChangeAudit(session, turn, "cancelled");
3398
3583
  turn.settled = true;
3399
3584
  // Deliberately no `usage`: a queued turn never ran, so the session
3400
3585
  // accumulator (the active turn's tally) is not its spend.
@@ -3414,7 +3599,9 @@ export class ClaudeAcpAgent {
3414
3599
  // never see lifecycle frames, so commandStarted/commandFinished stay
3415
3600
  // unset and every turn takes the plain-seed path below).
3416
3601
  for (const turn of orphanedTurns) {
3417
- if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
3602
+ if (turn.commandFinished === "completed" ||
3603
+ turn.commandFinished === "discarded" ||
3604
+ turn.commandFinished === "refused") {
3418
3605
  // The command already finished SDK-side and its terminal frame was
3419
3606
  // consumed while the turn sat queued — nothing is left to skip, and
3420
3607
  // a seeded entry would never drain.
@@ -3456,6 +3643,7 @@ export class ClaudeAcpAgent {
3456
3643
  {
3457
3644
  const active = session.activeTurn;
3458
3645
  if (isHeldOpen(active)) {
3646
+ this.finishFileChangeAudit(session, active, "cancelled");
3459
3647
  active.settled = true;
3460
3648
  // Mirror settleActive's invariants (it is consumer-scoped and
3461
3649
  // unreachable from here): disarm the backstop — none should be
@@ -3639,27 +3827,19 @@ export class ClaudeAcpAgent {
3639
3827
  return {};
3640
3828
  }
3641
3829
  async setSessionMode(params) {
3642
- const session = this.sessions[params.sessionId];
3643
- if (!session) {
3644
- throw new Error("Session not found");
3645
- }
3646
3830
  // A lazy Thinking recreate may be swapping the session's query right now;
3647
3831
  // join it (mirroring prompt()) so `setPermissionMode` below lands on the
3648
3832
  // replacement query after cutover instead of an about-to-close one — and
3649
3833
  // so the mode isn't silently dropped from a query built from the
3650
3834
  // pre-await state snapshot. Safe: `recreateSessionQuery` never rejects.
3651
- if (session.queryRecreateInFlight) {
3835
+ // The session-not-found and SESSION_ENDED guards the fork inlined here now
3836
+ // live in `SessionModeManager.requireOpenSession`, which raises the same
3837
+ // errors, so they are not duplicated in front of the delegation below.
3838
+ const session = this.sessions[params.sessionId];
3839
+ if (session?.queryRecreateInFlight) {
3652
3840
  await session.queryRecreateInFlight;
3653
3841
  }
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 {};
3842
+ return this.sessionModes.setSessionMode(params);
3663
3843
  }
3664
3844
  async setSessionConfigOption(params) {
3665
3845
  const session = this.sessions[params.sessionId];
@@ -3687,10 +3867,9 @@ export class ClaudeAcpAgent {
3687
3867
  if (!option) {
3688
3868
  throw new Error(`Unknown config option: ${params.configId}`);
3689
3869
  }
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.
3870
+ // Fast mode carries a boolean value (for Clients that opted into boolean
3871
+ // config options) or the "on"/"off" select fallback, so it bypasses the
3872
+ // string-only validation the select-style options below rely on.
3694
3873
  if (params.configId === FAST_MODE_CONFIG_ID) {
3695
3874
  await this.applyFastMode(session, resolveFastModeEnabled(params));
3696
3875
  return { configOptions: session.configOptions };
@@ -3759,15 +3938,10 @@ export class ClaudeAcpAgent {
3759
3938
  // Use the canonical option value so downstream code always receives the
3760
3939
  // model ID rather than the caller-supplied alias.
3761
3940
  const resolvedValue = validValue.value;
3941
+ let effectiveValue = resolvedValue;
3762
3942
  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
- });
3943
+ effectiveValue = await this.sessionModes.selectMode(params.sessionId, resolvedValue);
3944
+ await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
3771
3945
  }
3772
3946
  else if (params.configId === MODEL_CONFIG_ID) {
3773
3947
  await this.sessions[params.sessionId].query.setModel(resolvedValue);
@@ -3775,50 +3949,41 @@ export class ClaudeAcpAgent {
3775
3949
  // Effort SDK sync is handled inside applyConfigOptionValue so that direct
3776
3950
  // effort changes and effort changes induced by a model switch go through
3777
3951
  // the same path.
3778
- await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
3952
+ await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
3779
3953
  return { configOptions: session.configOptions };
3780
3954
  }
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
3955
  async replaySessionHistory(sessionId) {
3817
3956
  const toolUseCache = {};
3818
3957
  const messages = await getSessionMessages(sessionId);
3819
- const forwardSubagentText = this.sessions[sessionId]?.forwardSubagentText ??
3820
- supportsSubagentTranscript(this.clientCapabilities);
3958
+ const session = this.sessions[sessionId];
3959
+ const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
3960
+ const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
3961
+ const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
3962
+ const sessionFailures = session && supportsTypedFailures
3963
+ ? new SessionFailureController({
3964
+ sessionId,
3965
+ state: session.sessionFailureState,
3966
+ capabilities: this.clientCapabilities,
3967
+ isCurrent: () => this.sessions[sessionId] === session,
3968
+ sendUpdate: (notification) => this.client.sessionUpdate(notification),
3969
+ logger: this.logger,
3970
+ })
3971
+ : undefined;
3972
+ let replayTurnId;
3973
+ // Stop-hook additionalContext is persisted as an internal user message.
3974
+ // Once that marker (or the internal tool itself) appears, suppress the
3975
+ // whole audit exchange until its tool result. This also hides a disobedient
3976
+ // model's separate prose message, while an ordinary next user prompt safely
3977
+ // ends an incomplete audit lane.
3978
+ let replayingFileChangeAudit = false;
3979
+ const replayFileChangeAuditToolUseIds = new Set();
3821
3980
  for (const message of messages) {
3981
+ if (message.type === "user" &&
3982
+ message.parent_tool_use_id === null &&
3983
+ typeof message.uuid === "string" &&
3984
+ message.uuid.length > 0) {
3985
+ replayTurnId = message.uuid;
3986
+ }
3822
3987
  // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
3823
3988
  // observe live (resumed/loaded sessions), so rewind/resume can translate
3824
3989
  // a client-supplied id without an extra getSessionMessages read. Not read
@@ -3834,6 +3999,21 @@ export class ClaudeAcpAgent {
3834
3999
  if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3835
4000
  continue;
3836
4001
  }
4002
+ // Capable clients saw every synthetic usage-limit message as a typed
4003
+ // failure live, so replay it at the same transcript position. The
4004
+ // preceding persisted user uuid is the live prompt uuid and therefore
4005
+ // recreates the same incident identity. Only the latest limit not
4006
+ // followed by a real model answer remains active internally.
4007
+ if (sessionFailures &&
4008
+ message.type === "assistant" &&
4009
+ message.parent_tool_use_id === null &&
4010
+ isSyntheticUsageLimitMessage(message.message)) {
4011
+ const title = assistantMessageText(message.message);
4012
+ if (title) {
4013
+ await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
4014
+ }
4015
+ continue;
4016
+ }
3837
4017
  // @ts-expect-error - untyped in SDK but we handle all of these
3838
4018
  let content = message.message.content;
3839
4019
  const parentToolUseId = parentToolUseIdOf(message);
@@ -3846,6 +4026,57 @@ export class ClaudeAcpAgent {
3846
4026
  if (content === null)
3847
4027
  continue;
3848
4028
  }
4029
+ const auditBlocks = Array.isArray(content)
4030
+ ? content.filter((block) => typeof block === "object" && block !== null)
4031
+ : [];
4032
+ const hasFileChangeAuditMarker = (typeof content === "string" && containsFileChangeAuditMarker(content)) ||
4033
+ auditBlocks.some((block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text));
4034
+ const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) => (block.type === "tool_use" ||
4035
+ block.type === "server_tool_use" ||
4036
+ block.type === "mcp_tool_use") &&
4037
+ typeof block.name === "string" &&
4038
+ isFileChangeAuditTool(block.name) &&
4039
+ typeof block.id === "string"
4040
+ ? [block.id]
4041
+ : []);
4042
+ const replayMessageRole = message.message
4043
+ ?.role;
4044
+ if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
4045
+ replayingFileChangeAudit = true;
4046
+ for (const toolUseId of fileChangeAuditToolUseIds) {
4047
+ replayFileChangeAuditToolUseIds.add(toolUseId);
4048
+ }
4049
+ continue;
4050
+ }
4051
+ if (replayingFileChangeAudit) {
4052
+ const toolResultIds = auditBlocks.flatMap((block) => (block.type === "tool_result" || block.type === "mcp_tool_result") &&
4053
+ typeof block.tool_use_id === "string"
4054
+ ? [block.tool_use_id]
4055
+ : []);
4056
+ let completedReport = false;
4057
+ for (const toolUseId of toolResultIds) {
4058
+ if (replayFileChangeAuditToolUseIds.delete(toolUseId))
4059
+ completedReport = true;
4060
+ }
4061
+ if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
4062
+ replayingFileChangeAudit = false;
4063
+ continue;
4064
+ }
4065
+ // A denied attempt to call another tool is still part of the hidden
4066
+ // lane. Its result must not end replay suppression before the report.
4067
+ if (toolResultIds.length > 0) {
4068
+ continue;
4069
+ }
4070
+ // The next real user prompt is already represented by the client and
4071
+ // starts a new turn; do not let a missing audit result hide it or the
4072
+ // rest of the replay.
4073
+ if (replayMessageRole === "user") {
4074
+ replayingFileChangeAudit = false;
4075
+ }
4076
+ else {
4077
+ continue;
4078
+ }
4079
+ }
3849
4080
  for (const notification of toAcpNotifications(
3850
4081
  // @ts-expect-error - untyped in SDK but we handle all of these
3851
4082
  content,
@@ -3870,23 +4101,52 @@ export class ClaudeAcpAgent {
3870
4101
  const response = await this.client.writeTextFile(params);
3871
4102
  return response;
3872
4103
  }
4104
+ /** Mark a client request as blocking on user input for exactly the lifetime
4105
+ * of its promise. Steering consults this session-local count synchronously,
4106
+ * so a message arriving while any permission/elicitation card is open uses
4107
+ * non-interrupting SDK delivery. The decrement sits in `finally` (and is
4108
+ * clamped at zero), so accepted, declined, cancelled, aborted and failed
4109
+ * requests all settle the count — a request that ends by throwing must not
4110
+ * pin the session at `later` for the rest of its life. */
4111
+ async withPendingUserInput(sessionId, request) {
4112
+ const session = this.sessions[sessionId];
4113
+ if (!session)
4114
+ return request();
4115
+ session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
4116
+ try {
4117
+ return await request();
4118
+ }
4119
+ finally {
4120
+ // Decrement the SAME object the increment ran on, not a fresh
4121
+ // `this.sessions[sessionId]` lookup: a session replaced mid-request would
4122
+ // otherwise be charged for a request it never made. Both the clamp and
4123
+ // the `?? 1` land such a replacement's own count on 0, never below it.
4124
+ session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
4125
+ }
4126
+ }
3873
4127
  /** Forward a permission request to the client, wiring the tool call's
3874
4128
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
3875
4129
  * 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. */
4130
+ * `$/cancel_request`, and our local abort race settles even if the client
4131
+ * ignores it. A `cancelled` outcome, request rejection, and local abort all
4132
+ * surface the same "Tool use aborted" the callers already expect. */
3880
4133
  async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
4134
+ if (signal.aborted)
4135
+ throw new Error("Tool use aborted");
3881
4136
  // The SDK may invoke `canUseTool` (and therefore this permission request)
3882
4137
  // before the assistant message's tool_use block streams to us. Some ACP clients
3883
4138
  // expect the `tool_call` a permission request references to already exist,
3884
4139
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
3885
4140
  // later refines it with a `tool_call_update` rather than emitting a
3886
4141
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
3887
- await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
4142
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal);
4143
+ if (signal.aborted)
4144
+ throw new Error("Tool use aborted");
4145
+ // Do not rely on every ACP client settling requestPermission after the
4146
+ // cancellation signal. The local race guarantees that Claude's tool call
4147
+ // is released even when an older or broken client ignores $/cancel_request.
3888
4148
  try {
3889
- return await this.client.requestPermission(params, signal);
4149
+ return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
3890
4150
  }
3891
4151
  catch (error) {
3892
4152
  if (signal.aborted) {
@@ -3907,7 +4167,7 @@ export class ClaudeAcpAgent {
3907
4167
  * are resolved at tool_result time instead (see `toAcpNotifications`).
3908
4168
  * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
3909
4169
  * that spawned it, matching the streamed path's `_meta`. */
3910
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
4170
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal) {
3911
4171
  const session = this.sessions[sessionId];
3912
4172
  if (!session) {
3913
4173
  return;
@@ -3927,10 +4187,20 @@ export class ClaudeAcpAgent {
3927
4187
  },
3928
4188
  };
3929
4189
  }
3930
- await this.client.sessionUpdate({ sessionId, update });
4190
+ try {
4191
+ const emission = this.client.sessionUpdate({ sessionId, update });
4192
+ await (signal ? raceWithAbort(emission, signal) : emission);
4193
+ }
4194
+ catch (error) {
4195
+ // The set is also the de-duplication guard for the later streamed
4196
+ // tool_use. Keep it truthful: if emission failed, that path must still
4197
+ // be allowed to publish the tool call instead of refining a phantom one.
4198
+ session.emittedToolCalls.delete(toolCallId);
4199
+ throw error;
4200
+ }
3931
4201
  }
3932
4202
  canUseTool(sessionId) {
3933
- return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
4203
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, }) => {
3934
4204
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
3935
4205
  const session = this.sessions[sessionId];
3936
4206
  if (!session) {
@@ -3939,6 +4209,26 @@ export class ClaudeAcpAgent {
3939
4209
  message: "Session not found",
3940
4210
  };
3941
4211
  }
4212
+ const fileChangeAudit = session.activeTurn?.fileChangeAudit;
4213
+ if (isFileChangeAuditReportPhase(fileChangeAudit)) {
4214
+ // The hidden continuation is an audit-only lane: it may submit the
4215
+ // wrapper-owned report, but it must not run another command after the
4216
+ // user-visible answer has already completed.
4217
+ if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
4218
+ return { behavior: "allow", updatedInput: toolInput };
4219
+ }
4220
+ return {
4221
+ behavior: "deny",
4222
+ message: "Only the internal file-change report is allowed during the audit.",
4223
+ };
4224
+ }
4225
+ // The tool is intentionally unusable outside a negotiated audit turn.
4226
+ if (isFileChangeAuditTool(toolName)) {
4227
+ return {
4228
+ behavior: "deny",
4229
+ message: "No file-change report was requested for this turn.",
4230
+ };
4231
+ }
3942
4232
  // When the tool call originates inside a subagent, attribute the eagerly
3943
4233
  // emitted tool_call (and the permission request itself) to the Agent/Task
3944
4234
  // tool call that spawned the subagent, mirroring the streamed subagent
@@ -3963,155 +4253,81 @@ export class ClaudeAcpAgent {
3963
4253
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
3964
4254
  // Like permission requests, the elicitation references this toolUseID, so
3965
4255
  // make sure the tool_call has surfaced to the client before we send it.
3966
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
4256
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal);
3967
4257
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
3968
4258
  }
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
- ],
4259
+ // Do not auto-allow here based on the session's advertised mode. Claude
4260
+ // Code applies bypassPermissions before invoking canUseTool; a request
4261
+ // that still reaches this callback is deliberately bypass-immune (for
4262
+ // example a safety check, a tool requiring user interaction, or an
4263
+ // explicit ask rule). Re-applying bypass in the host would erase that
4264
+ // provider safety decision.
4265
+ const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, matchedAskRule !== undefined);
4266
+ const presentation = buildClaudePermissionPresentation({
4267
+ toolName,
4268
+ input: toolInput,
4269
+ toolUseID,
4270
+ cwd: session.cwd,
4271
+ supportsTerminalOutput,
4272
+ blockedPath,
4273
+ title,
4274
+ displayName,
4275
+ description,
4276
+ decisionReason,
4277
+ });
4278
+ if (parentToolUseId) {
4279
+ presentation.toolCall._meta = {
4280
+ claudeCode: { toolName, parentToolUseId },
4054
4281
  };
4055
4282
  }
4283
+ const permissionOptions = buildClaudePermissionOptions({
4284
+ toolName,
4285
+ displayName,
4286
+ input: toolInput,
4287
+ cwd: session.cwd,
4288
+ durableChangeSet,
4289
+ allowPersistentOptions: matchedAskRule === undefined,
4290
+ availableModes: this.sessionModes.availableModeIds(session.modes),
4291
+ contextUsedPercent: session.contextUsedTokens === undefined || session.contextWindowSize <= 0
4292
+ ? undefined
4293
+ : Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
4294
+ });
4056
4295
  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
- ],
4296
+ ...presentation,
4297
+ options: permissionOptions,
4072
4298
  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
4299
  }, toolName, signal, parentToolUseId);
4084
- if (signal.aborted || response.outcome?.outcome === "cancelled") {
4300
+ if (signal.aborted)
4085
4301
  throw new Error("Tool use aborted");
4302
+ const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
4303
+ let permissionResult = decodedPermission.permissionResult;
4304
+ const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
4305
+ permissionResult = autoFallback.permissionResult;
4306
+ if (autoFallback.fallbackApplied) {
4307
+ await this.sessionModes.publishFallbackWarning(sessionId, session);
4086
4308
  }
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,
4309
+ const clearContextMode = decodedPermission.contextResetMode
4310
+ ? this.sessionModes.effectiveMode(session, decodedPermission.contextResetMode)
4311
+ : undefined;
4312
+ if (toolName === "ExitPlanMode" && clearContextMode) {
4313
+ const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : "";
4314
+ if (!plan)
4315
+ throw new Error("ExitPlanMode clear-context selection requires a plan");
4316
+ session.pendingExitPlanContextReset = {
4317
+ toolUseId: toolUseID,
4318
+ plan,
4319
+ mode: clearContextMode,
4107
4320
  };
4108
4321
  }
4109
- else {
4110
- return {
4111
- behavior: "deny",
4112
- message: "User refused permission to run tool",
4322
+ if (toolName === "ExitPlanMode" &&
4323
+ permissionResult.behavior === "deny" &&
4324
+ permissionResult.interrupt === true) {
4325
+ session.pendingExitPlanModeInterruption = {
4326
+ toolUseId: toolUseID,
4327
+ toolResultSeen: false,
4113
4328
  };
4114
4329
  }
4330
+ return permissionResult;
4115
4331
  };
4116
4332
  }
4117
4333
  /**
@@ -4130,7 +4346,7 @@ export class ClaudeAcpAgent {
4130
4346
  return { action: "decline" };
4131
4347
  }
4132
4348
  try {
4133
- const response = await this.client.unstable_createElicitation(createRequest, signal);
4349
+ const response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
4134
4350
  if (signal.aborted) {
4135
4351
  return { action: "cancel" };
4136
4352
  }
@@ -4160,7 +4376,7 @@ export class ClaudeAcpAgent {
4160
4376
  const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
4161
4377
  let response;
4162
4378
  try {
4163
- response = await this.client.unstable_createElicitation(createRequest, signal);
4379
+ response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
4164
4380
  }
4165
4381
  catch (error) {
4166
4382
  // A cancellation we requested (signal aborted) settles as an aborted tool
@@ -4200,7 +4416,7 @@ export class ClaudeAcpAgent {
4200
4416
  }
4201
4417
  let response;
4202
4418
  try {
4203
- response = await this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal);
4419
+ response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal));
4204
4420
  }
4205
4421
  catch (error) {
4206
4422
  // A cancellation we requested (signal aborted) is expected teardown;
@@ -4227,7 +4443,7 @@ export class ClaudeAcpAgent {
4227
4443
  sessionId,
4228
4444
  update: {
4229
4445
  sessionUpdate: "available_commands_update",
4230
- availableCommands: getAvailableSlashCommands(commands),
4446
+ availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
4231
4447
  },
4232
4448
  });
4233
4449
  }
@@ -4246,12 +4462,11 @@ export class ClaudeAcpAgent {
4246
4462
  }
4247
4463
  async applyConfigOptionValue(sessionId, session, configId, value) {
4248
4464
  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);
4465
+ this.sessionModes.syncConfig(session, value);
4251
4466
  }
4252
4467
  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
4468
+ // `ModelInfo.supportsAutoMode` is the canonical SDK signal for applying
4469
+ // the Auto fallback below; its `displayName`/`description` also let us infer the
4255
4470
  // context window for semantic aliases (e.g. `default`) whose ID alone
4256
4471
  // carries no "1m" token.
4257
4472
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
@@ -4273,41 +4488,7 @@ export class ClaudeAcpAgent {
4273
4488
  session.contextWindowAuthoritative = seeded.authoritative;
4274
4489
  }
4275
4490
  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
- }
4491
+ const modeDowngraded = await this.sessionModes.reconcileForModel(session, newModelInfo);
4311
4492
  // `model_not_allowed` described the model we just left, so it must not
4312
4493
  // follow us onto the new one; the remaining reasons are account- or
4313
4494
  // environment-scoped and stay true across a switch. Either way the next
@@ -4324,6 +4505,7 @@ export class ClaudeAcpAgent {
4324
4505
  // intent) when a supporting model is selected again.
4325
4506
  supported: newModelInfo?.supportsFastMode ?? false,
4326
4507
  enabled: session.fastModeEnabled,
4508
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
4327
4509
  disabledReason: session.fastModeDisabledReason,
4328
4510
  },
4329
4511
  // Thinking is model-independent: re-render the retained tri-state
@@ -4349,13 +4531,7 @@ export class ClaudeAcpAgent {
4349
4531
  // still precedes the caller's config_option_update so order-sensitive
4350
4532
  // clients update currentModeId before re-rendering the option list.
4351
4533
  if (modeDowngraded) {
4352
- await this.client.sessionUpdate({
4353
- sessionId,
4354
- update: {
4355
- sessionUpdate: "current_mode_update",
4356
- currentModeId: "default",
4357
- },
4358
- });
4534
+ await this.sessionModes.publishFallbackState(sessionId, session);
4359
4535
  }
4360
4536
  }
4361
4537
  else if (configId === AGENT_CONFIG_ID) {
@@ -4410,13 +4586,11 @@ export class ClaudeAcpAgent {
4410
4586
  }
4411
4587
  }
4412
4588
  /** 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
4589
+ * `enabled` (and the client's current boolean-capability). A no-op when the
4414
4590
  * 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. */
4591
+ * it first. */
4418
4592
  refreshFastModeOption(session, enabled) {
4419
- const refreshed = createFastModeConfigOption(enabled, session.fastModeDisabledReason);
4593
+ const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities), session.fastModeDisabledReason);
4420
4594
  session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
4421
4595
  }
4422
4596
  /** Toggle Fast mode for a session: push the SDK flag, record the user's
@@ -4813,7 +4987,10 @@ export class ClaudeAcpAgent {
4813
4987
  // We want to create a new session id unless it is resume,
4814
4988
  // but not resume + forkSession.
4815
4989
  let sessionId;
4816
- if (creationOpts.forkSession) {
4990
+ if (creationOpts.publicSessionId) {
4991
+ sessionId = creationOpts.publicSessionId;
4992
+ }
4993
+ else if (creationOpts.forkSession) {
4817
4994
  sessionId = randomUUID();
4818
4995
  }
4819
4996
  else if (creationOpts.resume) {
@@ -4872,6 +5049,7 @@ export class ClaudeAcpAgent {
4872
5049
  }
4873
5050
  }
4874
5051
  const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
5052
+ const initialPermissionMode = creationOpts.permissionMode ?? permissionMode;
4875
5053
  // Extract options from _meta if provided
4876
5054
  const sessionMeta = params._meta;
4877
5055
  const userProvidedOptions = sessionMeta?.claudeCode?.options;
@@ -4906,6 +5084,30 @@ export class ClaudeAcpAgent {
4906
5084
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
4907
5085
  // the same Map that the streaming message handler will read from.
4908
5086
  const taskState = new Map();
5087
+ // Resolve every workspace root once. The hidden report tool uses this same
5088
+ // set for lexical path validation, and the SDK receives it below.
5089
+ const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
5090
+ const additionalDirectories = [
5091
+ ...(userProvidedOptions?.additionalDirectories ?? []),
5092
+ ...acpAdditionalDirectories,
5093
+ ];
5094
+ const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
5095
+ ? createFileChangeAuditSupport({
5096
+ cwd: params.cwd,
5097
+ additionalDirectories,
5098
+ getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
5099
+ publish: async (result) => {
5100
+ await this.client.sessionUpdate({
5101
+ sessionId,
5102
+ update: {
5103
+ sessionUpdate: "session_info_update",
5104
+ _meta: agentFileChangeReportMeta(result),
5105
+ },
5106
+ });
5107
+ },
5108
+ logError: (message) => this.logger.error(message),
5109
+ })
5110
+ : undefined;
4909
5111
  // The exact env the query will be created with. Built (and the provider
4910
5112
  // cache key derived from it, below) in one place so the key always
4911
5113
  // describes the backend this query actually talks to: `providers/set`,
@@ -4913,13 +5115,36 @@ export class ClaudeAcpAgent {
4913
5115
  // config concurrently, so re-resolving it after any of the awaits between
4914
5116
  // here and the session registration could disagree with the env baked
4915
5117
  // into the query.
5118
+ const resolvedProvider = this.resolveProviderConfig();
5119
+ const providerEnv = createEnvForProvider(resolvedProvider);
5120
+ const configuredSettings = userProvidedOptions?.settings ??
5121
+ (modelConfig
5122
+ ? {
5123
+ ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
5124
+ ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
5125
+ }
5126
+ : undefined);
5127
+ // Claude Code applies env from settings.json after the subprocess env. Put
5128
+ // an active ACP route in the programmatic settings tier too so user/project
5129
+ // settings cannot silently restore a different ANTHROPIC_BASE_URL.
5130
+ let settings = configuredSettings;
5131
+ if (resolvedProvider) {
5132
+ const baseSettings = typeof configuredSettings === "string"
5133
+ ? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
5134
+ : configuredSettings;
5135
+ settings = {
5136
+ ...baseSettings,
5137
+ apiKeyHelper: "",
5138
+ env: { ...baseSettings?.env, ...providerEnv },
5139
+ };
5140
+ }
4916
5141
  const env = {
4917
5142
  ...process.env,
4918
5143
  ...userProvidedOptions?.env,
4919
5144
  // 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()),
5145
+ // legacy gateway auth request. Routing is baked into the query at
5146
+ // creation; provider updates recreate loaded queries between turns.
5147
+ ...providerEnv,
4923
5148
  // Opt-in to session state events like when the agent is idle
4924
5149
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
4925
5150
  };
@@ -4928,6 +5153,7 @@ export class ClaudeAcpAgent {
4928
5153
  // SDK, so per-session `_meta` env routing and ambient process-env routing
4929
5154
  // are distinguished exactly as the CLI will see them.
4930
5155
  const providerCacheKey = providerCacheKeyFor(env);
5156
+ this.logger.log(`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`);
4931
5157
  const options = {
4932
5158
  systemPrompt,
4933
5159
  settingSources: ["user", "project", "local"],
@@ -4937,27 +5163,23 @@ export class ClaudeAcpAgent {
4937
5163
  // `enableFileCheckpointing` still wins.
4938
5164
  enableFileCheckpointing: true,
4939
5165
  ...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
- }),
5166
+ ...(settings && { settings }),
4951
5167
  env,
4952
5168
  // Override certain fields that must be controlled by ACP
4953
5169
  cwd: params.cwd,
4954
5170
  includePartialMessages: true,
4955
5171
  forwardSubagentText,
4956
- mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
5172
+ mcpServers: {
5173
+ ...(userProvidedOptions?.mcpServers || {}),
5174
+ ...mcpServers,
5175
+ ...(fileChangeAuditSupport
5176
+ ? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
5177
+ : {}),
5178
+ },
4957
5179
  // If we want bypassPermissions to be an option, we have to allow it here.
4958
5180
  // But it doesn't work in root mode, so we only activate it if it will work.
4959
5181
  allowDangerouslySkipPermissions: ALLOW_BYPASS,
4960
- permissionMode,
5182
+ permissionMode: initialPermissionMode,
4961
5183
  canUseTool: this.canUseTool(sessionId),
4962
5184
  // Forward MCP elicitation requests onto ACP elicitation. Only attached
4963
5185
  // when the client advertised support, so non-supporting clients keep the
@@ -4987,40 +5209,42 @@ export class ClaudeAcpAgent {
4987
5209
  tools,
4988
5210
  hooks: {
4989
5211
  ...userProvidedOptions?.hooks,
5212
+ ...(fileChangeAuditSupport
5213
+ ? {
5214
+ PreToolUse: [
5215
+ ...(userProvidedOptions?.hooks?.PreToolUse || []),
5216
+ { hooks: [fileChangeAuditSupport.preToolUseHook] },
5217
+ ],
5218
+ }
5219
+ : {}),
4990
5220
  PostToolUse: [
4991
5221
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
4992
5222
  {
4993
5223
  hooks: [
4994
5224
  createPostToolUseHook({
4995
5225
  onEnterPlanMode: async () => {
4996
- await this.client.sessionUpdate({
4997
- sessionId,
4998
- update: {
4999
- sessionUpdate: "current_mode_update",
5000
- currentModeId: "plan",
5001
- },
5002
- });
5226
+ await this.sessionModes.publishCurrent(sessionId, "plan");
5003
5227
  await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
5004
5228
  },
5005
5229
  }),
5006
5230
  ],
5007
5231
  },
5008
5232
  ],
5233
+ ...(fileChangeAuditSupport
5234
+ ? {
5235
+ Stop: [
5236
+ ...(userProvidedOptions?.hooks?.Stop || []),
5237
+ { hooks: [fileChangeAuditSupport.stopHook] },
5238
+ ],
5239
+ }
5240
+ : {}),
5009
5241
  TaskCreated: [
5010
5242
  ...(userProvidedOptions?.hooks?.TaskCreated || []),
5011
5243
  {
5012
5244
  hooks: [
5013
5245
  createTaskHook({
5014
5246
  taskState,
5015
- onChange: async () => {
5016
- await this.client.sessionUpdate({
5017
- sessionId,
5018
- update: {
5019
- sessionUpdate: "plan",
5020
- entries: taskStateToPlanEntries(taskState),
5021
- },
5022
- });
5023
- },
5247
+ onChange: () => this.publishTaskPlan(sessionId, taskState),
5024
5248
  }),
5025
5249
  ],
5026
5250
  },
@@ -5031,35 +5255,24 @@ export class ClaudeAcpAgent {
5031
5255
  hooks: [
5032
5256
  createTaskHook({
5033
5257
  taskState,
5034
- onChange: async () => {
5035
- await this.client.sessionUpdate({
5036
- sessionId,
5037
- update: {
5038
- sessionUpdate: "plan",
5039
- entries: taskStateToPlanEntries(taskState),
5040
- },
5041
- });
5042
- },
5258
+ onChange: () => this.publishTaskPlan(sessionId, taskState),
5043
5259
  }),
5044
5260
  ],
5045
5261
  },
5046
5262
  ],
5047
5263
  },
5048
- ...creationOpts,
5264
+ ...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
5265
+ ...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
5049
5266
  abortController,
5050
5267
  };
5051
5268
  // Prefer the official ACP `additionalDirectories` field. Fall back to the
5052
5269
  // legacy `_meta.additionalRoots` extension for clients that haven't been
5053
5270
  // updated yet. Either source is merged with directories supplied via
5054
5271
  // `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
5055
- const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
5056
- options.additionalDirectories = [
5057
- ...(userProvidedOptions?.additionalDirectories ?? []),
5058
- ...acpAdditionalDirectories,
5059
- ];
5272
+ options.additionalDirectories = additionalDirectories;
5060
5273
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
5061
5274
  // Set our own session id if not resuming an existing session.
5062
- options.sessionId = sessionId;
5275
+ options.sessionId = creationOpts.publicSessionId ? randomUUID() : sessionId;
5063
5276
  }
5064
5277
  // Handle abort controller from meta options
5065
5278
  if (abortController?.signal.aborted) {
@@ -5114,8 +5327,8 @@ export class ClaudeAcpAgent {
5114
5327
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
5115
5328
  : hideDeprecatedModels(initializationResult.models, this.logger);
5116
5329
  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.
5330
+ // Resolve the current model's capabilities separately from the stable
5331
+ // permission-mode catalog advertised to ACP clients.
5119
5332
  // Looked up in the UNfiltered catalog: a session honoring a persisted
5120
5333
  // deprecated preference must keep that model's real capabilities (R4.3).
5121
5334
  // A resumed session can also be running a model outside the
@@ -5156,38 +5369,12 @@ export class ClaudeAcpAgent {
5156
5369
  },
5157
5370
  ]
5158
5371
  : 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
- };
5372
+ const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
5373
+ query: q,
5374
+ requestedMode: initialPermissionMode,
5375
+ currentModelInfo,
5376
+ currentModelId: models.currentModelId,
5377
+ });
5191
5378
  const agents = await discoverCustomAgents(q);
5192
5379
  // Only adopt the requested agent as the selected value if it's one we
5193
5380
  // actually surface in the picker. A built-in (filtered out above) or
@@ -5214,6 +5401,7 @@ export class ClaudeAcpAgent {
5214
5401
  const fastMode = {
5215
5402
  supported: currentModelInfo?.supportsFastMode ?? false,
5216
5403
  enabled: fastModeEnabled,
5404
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
5217
5405
  disabledReason: fastModeDisabledReason,
5218
5406
  };
5219
5407
  const configOptions = buildConfigOptions(modes, models,
@@ -5279,7 +5467,9 @@ export class ClaudeAcpAgent {
5279
5467
  // can rebuild an equivalent query without re-running this assembly.
5280
5468
  queryOptions: options,
5281
5469
  sessionFingerprint: computeSessionFingerprint(params),
5470
+ creationParams: params,
5282
5471
  settingsManager,
5472
+ titles: new SessionTitles(this, sessionId),
5283
5473
  accumulatedUsage: {
5284
5474
  inputTokens: 0,
5285
5475
  outputTokens: 0,
@@ -5293,6 +5483,8 @@ export class ClaudeAcpAgent {
5293
5483
  // capability lookups and `resolveModelPreference` (refusal fallback),
5294
5484
  // which must keep seeing deprecated rows (R4.3, visibility-only filter).
5295
5485
  modelInfos,
5486
+ autoModeFallbackWarningShown: false,
5487
+ autoModeFallbackWarningPending,
5296
5488
  configOptions,
5297
5489
  agents,
5298
5490
  currentAgent,
@@ -5311,6 +5503,9 @@ export class ClaudeAcpAgent {
5311
5503
  emittedAssistantText: false,
5312
5504
  owedTrailingIdles: 0,
5313
5505
  messageIdToUuid: new Map(),
5506
+ sessionFailureState: createSessionFailureState(),
5507
+ fileChangeReportRequestIds: new Set(),
5508
+ fileChangeAuditSupport,
5314
5509
  };
5315
5510
  return {
5316
5511
  sessionId,
@@ -5318,6 +5513,41 @@ export class ClaudeAcpAgent {
5318
5513
  configOptions,
5319
5514
  };
5320
5515
  }
5516
+ /**
5517
+ * Provider routing is baked into the environment of each SDK Query. Wait for
5518
+ * all submitted turns to settle, close every query, then resume each Claude
5519
+ * session with the same ID so subsequent turns inherit the new environment.
5520
+ */
5521
+ async enqueueProviderUpdate(config) {
5522
+ const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve();
5523
+ const update = previous.then(async () => {
5524
+ const sessions = Object.entries(this.sessions);
5525
+ const activeTurns = sessions.flatMap(([, session]) => (session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])));
5526
+ if (activeTurns.length > 0) {
5527
+ this.logger.log(`Waiting for ${activeTurns.length} active Claude turn(s) before provider update`);
5528
+ await Promise.all(activeTurns);
5529
+ }
5530
+ this.providerConfig = config;
5531
+ for (const [sessionId, session] of sessions) {
5532
+ if (this.sessions[sessionId] !== session || !session.creationParams) {
5533
+ continue;
5534
+ }
5535
+ this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
5536
+ this.closeQueryStream(session);
5537
+ delete this.sessions[sessionId];
5538
+ await this.createSession(session.creationParams, { resume: sessionId });
5539
+ }
5540
+ });
5541
+ this.providerUpdate = update;
5542
+ try {
5543
+ await update;
5544
+ }
5545
+ finally {
5546
+ if (this.providerUpdate === update) {
5547
+ this.providerUpdate = null;
5548
+ }
5549
+ }
5550
+ }
5321
5551
  }
5322
5552
  function shouldEmitRawMessage(config, message) {
5323
5553
  if (config === true)
@@ -5405,13 +5635,27 @@ function createEnvForProvider(config) {
5405
5635
  if (!config) {
5406
5636
  return {};
5407
5637
  }
5638
+ const resetRouting = {
5639
+ ANTHROPIC_BASE_URL: "",
5640
+ ANTHROPIC_BEDROCK_BASE_URL: "",
5641
+ ANTHROPIC_VERTEX_BASE_URL: "",
5642
+ CLAUDE_CODE_USE_BEDROCK: "0",
5643
+ CLAUDE_CODE_USE_VERTEX: "0",
5644
+ ANTHROPIC_VERTEX_PROJECT_ID: "",
5645
+ CLOUD_ML_REGION: "",
5646
+ AWS_REGION: "",
5647
+ ANTHROPIC_API_KEY: "",
5648
+ ANTHROPIC_AUTH_TOKEN: "",
5649
+ CLAUDE_CODE_OAUTH_TOKEN: "",
5650
+ };
5408
5651
  const customHeaders = Object.entries(config.headers)
5409
5652
  .map(([key, value]) => `${key}: ${value}`)
5410
5653
  .join("\n");
5411
5654
  if (config.apiType === "bedrock") {
5412
5655
  return {
5656
+ ...resetRouting,
5413
5657
  CLAUDE_CODE_USE_BEDROCK: "1",
5414
- AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
5658
+ AWS_BEARER_TOKEN_BEDROCK: "acp-proxy", // Bypass local AWS credential checks
5415
5659
  ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
5416
5660
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
5417
5661
  };
@@ -5420,6 +5664,7 @@ function createEnvForProvider(config) {
5420
5664
  // `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
5421
5665
  // validation; fall back to empty strings defensively.
5422
5666
  return {
5667
+ ...resetRouting,
5423
5668
  CLAUDE_CODE_USE_VERTEX: "1",
5424
5669
  ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
5425
5670
  ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
@@ -5428,9 +5673,10 @@ function createEnvForProvider(config) {
5428
5673
  };
5429
5674
  }
5430
5675
  return {
5676
+ ...resetRouting,
5431
5677
  ANTHROPIC_BASE_URL: config.baseUrl,
5432
5678
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
5433
- ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
5679
+ ANTHROPIC_AUTH_TOKEN: "acp-proxy", // Bypass local Claude login checks
5434
5680
  };
5435
5681
  }
5436
5682
  /**
@@ -5449,50 +5695,6 @@ function isValidBaseUrl(baseUrl) {
5449
5695
  }
5450
5696
  return parsed.protocol === "http:" || parsed.protocol === "https:";
5451
5697
  }
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
5698
  // Translate a UI effort value into the flag-layer payload. The SDK
5497
5699
  // shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
5498
5700
  // and only clears a key when an explicit `null` is sent — see
@@ -5523,7 +5725,6 @@ export const BUILTIN_AGENT_NAMES = new Set([
5523
5725
  // reserved sentinel: a custom agent named exactly this would collide with it
5524
5726
  // (two options sharing the value, selection silently routing to `null`), so we
5525
5727
  // exclude that name from discovery.
5526
- export const DEFAULT_AGENT_ID = "default";
5527
5728
  /** Discover user/plugin/project-configured main-thread agents, excluding the
5528
5729
  * built-in subagents and the reserved "default" sentinel. Returns an empty
5529
5730
  * list if discovery fails so a flaky control request never blocks session
@@ -5541,13 +5742,12 @@ export async function discoverCustomAgents(q) {
5541
5742
  * Centralized so the option declarations in `buildConfigOptions` and the
5542
5743
  * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
5543
5744
  * same identifiers and can't drift apart. */
5544
- export const MODE_CONFIG_ID = "mode";
5745
+ export { MODE_CONFIG_ID };
5545
5746
  export const MODEL_CONFIG_ID = "model";
5546
- export const EFFORT_CONFIG_ID = "effort";
5547
5747
  export const AGENT_CONFIG_ID = "agent";
5548
5748
  export const FAST_MODE_CONFIG_ID = "fast";
5549
- /** Select values for the Fast mode on/off option
5550
- * (see {@link createFastModeConfigOption}). */
5749
+ /** Select-fallback values used when the client has not opted into boolean
5750
+ * config options (see {@link createFastModeConfigOption}). */
5551
5751
  export const FAST_MODE_ON = "on";
5552
5752
  export const FAST_MODE_OFF = "off";
5553
5753
  const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
@@ -5584,33 +5784,38 @@ const FAST_MODE_UNAVAILABLE_EXPLANATIONS = {
5584
5784
  export function normalizeFastModeDisabledReason(reason) {
5585
5785
  return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
5586
5786
  }
5587
- /** Build the Fast mode config option as a two-value on/off `select`. Emitted
5588
- * for EVERY Client — the boolean option shape is gone (story 006, R2.1;
5589
- * retained through the v0.64.0 sync by story 008 R3.4). Only the emitted SHAPE
5590
- * is fixed to a select; boolean VALUES are still honored on set (see
5591
- * {@link resolveFastModeEnabled}). This factory is the single source of the
5592
- * option's shape, re-rendered by `refreshFastModeOption` / `syncFastModeState`
5593
- * so the shape can never desync.
5594
- *
5595
- * `disabledReason` (the SDK's `fast_mode_disabled_reason`, upstream v0.64.0) is
5596
- * folded into the description while the toggle reads off, so a user whose
5597
- * account or provider can't serve Fast mode sees why instead of a switch that
5598
- * silently refuses to stay on. Ignored while enabled: a reason reported
5599
- * alongside an `on`/`cooldown` state isn't blocking anything right now.
5787
+ /** Whether the Client advertised support for boolean session config options
5788
+ * (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
5789
+ * config options to Clients that opt in; otherwise we fall back to a `select`.
5790
+ * See https://agentclientprotocol.com/rfds/boolean-config-option. */
5791
+ export function clientSupportsBooleanConfigOptions(clientCapabilities) {
5792
+ return clientCapabilities?.session?.configOptions?.boolean != null;
5793
+ }
5794
+ /** Build the Fast mode config option. When the Client supports boolean config
5795
+ * options we expose a native `type: "boolean"` toggle; otherwise we degrade to
5796
+ * a two-value `select` ("on"/"off") so older Clients still get a usable
5797
+ * control.
5600
5798
  *
5601
- * Upstream's second parameter (`useBooleanOption`) is deliberately absent: the
5602
- * shape is unconditionally a select, so there is no branch to select. What
5603
- * guards that is behavioural, not structural — `tests/fast-mode-select-only.
5604
- * test.ts` proves no argument combination can yield the boolean shape. */
5605
- export function createFastModeConfigOption(enabled, disabledReason) {
5799
+ * `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
5800
+ * description while the toggle reads off, so a user whose account or provider
5801
+ * can't serve Fast mode sees why instead of a switch that silently refuses to
5802
+ * stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
5803
+ * state isn't blocking anything right now. */
5804
+ export function createFastModeConfigOption(enabled, useBooleanOption, disabledReason) {
5606
5805
  const explanation = enabled
5607
5806
  ? undefined
5608
5807
  : disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
5609
- return {
5808
+ const base = {
5610
5809
  id: FAST_MODE_CONFIG_ID,
5611
5810
  name: "Fast mode",
5612
5811
  description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
5613
5812
  category: "model_config",
5813
+ };
5814
+ if (useBooleanOption) {
5815
+ return { ...base, type: "boolean", currentValue: enabled };
5816
+ }
5817
+ return {
5818
+ ...base,
5614
5819
  type: "select",
5615
5820
  currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
5616
5821
  options: [
@@ -5620,8 +5825,8 @@ export function createFastModeConfigOption(enabled, disabledReason) {
5620
5825
  };
5621
5826
  }
5622
5827
  /** Resolve the requested Fast mode value from a `session/set_config_option`
5623
- * request. Accepts the select's "on"/"off" strings or a native boolean,
5624
- * kept for backward compatibility (R2.3). */
5828
+ * request. Accepts a native boolean (boolean-capable Clients) or the
5829
+ * "on"/"off" select-fallback strings. */
5625
5830
  export function resolveFastModeEnabled(params) {
5626
5831
  const value = params.value;
5627
5832
  if (typeof value === "boolean") {
@@ -5643,19 +5848,7 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
5643
5848
  * callers/tests) omits the row. */
5644
5849
  thinkingEnabled) {
5645
5850
  const options = [
5646
- {
5647
- id: MODE_CONFIG_ID,
5648
- name: "Mode",
5649
- description: "Session permission mode",
5650
- category: "mode",
5651
- type: "select",
5652
- currentValue: modes.currentModeId,
5653
- options: modes.availableModes.map((m) => ({
5654
- value: m.id,
5655
- name: m.name,
5656
- description: m.description,
5657
- })),
5658
- },
5851
+ SessionModeManager.configOption(modes),
5659
5852
  {
5660
5853
  id: MODEL_CONFIG_ID,
5661
5854
  name: "Model",
@@ -5663,11 +5856,21 @@ thinkingEnabled) {
5663
5856
  category: "model",
5664
5857
  type: "select",
5665
5858
  currentValue: models.currentModelId,
5666
- options: models.availableModels.map((m) => ({
5667
- value: m.modelId,
5668
- name: m.name,
5669
- description: m.description ?? undefined,
5670
- })),
5859
+ options: models.availableModels.map((m) => {
5860
+ if (m.modelId === "default") {
5861
+ const defaultInfo = modelInfos.find((mi) => mi.value === "default");
5862
+ const resolvedModel = defaultInfo?.resolvedModel;
5863
+ if (resolvedModel) {
5864
+ const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
5865
+ return {
5866
+ value: m.modelId,
5867
+ name: m.name,
5868
+ description: namedMatch?.displayName ?? resolvedModel,
5869
+ };
5870
+ }
5871
+ }
5872
+ return { value: m.modelId, name: m.name, description: m.description ?? undefined };
5873
+ }),
5671
5874
  },
5672
5875
  ];
5673
5876
  // Add effort level option based on the currently selected model
@@ -5699,10 +5902,10 @@ thinkingEnabled) {
5699
5902
  });
5700
5903
  }
5701
5904
  // Surface the Fast mode toggle only when the current model supports it. The
5702
- // option is always emitted as a two-value on/off select for every Client
5703
- // (R2.1); boolean values remain accepted on set for boolean-era clients.
5905
+ // option renders as a native boolean toggle for Clients that opted in, and a
5906
+ // two-value select otherwise.
5704
5907
  if (fastMode?.supported) {
5705
- options.push(createFastModeConfigOption(fastMode.enabled, fastMode.disabledReason));
5908
+ options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption, fastMode.disabledReason));
5706
5909
  }
5707
5910
  // Surface the Thinking toggle whenever the caller supplies its display
5708
5911
  // state. Unlike Fast mode it is model-independent — no `supported` gate —
@@ -6166,7 +6369,11 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
6166
6369
  resumedContextWindow,
6167
6370
  };
6168
6371
  }
6169
- function getAvailableSlashCommands(commands) {
6372
+ function getAvailableSlashCommands(commands,
6373
+ // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
6374
+ // the CLI's own terminal, which ACP clients aren't) — filtered alongside
6375
+ // the static list. Raw CLI names, matched before the MCP rename.
6376
+ terminalCommands) {
6170
6377
  const UNSUPPORTED_COMMANDS = [
6171
6378
  "clear",
6172
6379
  "cost",
@@ -6178,6 +6385,7 @@ function getAvailableSlashCommands(commands) {
6178
6385
  "todos",
6179
6386
  ];
6180
6387
  const advertised = commands
6388
+ .filter((command) => !terminalCommands?.includes(command.name))
6181
6389
  .map((command) => {
6182
6390
  const input = command.argumentHint
6183
6391
  ? {
@@ -6361,13 +6569,13 @@ function isTaskTool(toolName) {
6361
6569
  * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
6362
6570
  * resolved explicitly at tool_result time. */
6363
6571
  function shouldEmitToolCall(toolName) {
6364
- return toolName !== "TodoWrite" && !isTaskTool(toolName);
6572
+ return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
6365
6573
  }
6366
6574
  /** Build the Claude Code-specific metadata for a tool call. Bash descriptions
6367
6575
  * are kept out of ACP's standard `title`, which clients may use as the shell
6368
6576
  * command preview, while still giving clients access to Claude's concise
6369
6577
  * human-readable title. */
6370
- function claudeCodeMetaFromToolUse(toolUse) {
6578
+ function claudeCodeMetaFromToolUse(toolUse, cwd) {
6371
6579
  const description = toolUse.name === "Bash" &&
6372
6580
  toolUse.input !== null &&
6373
6581
  typeof toolUse.input === "object" &&
@@ -6375,11 +6583,53 @@ function claudeCodeMetaFromToolUse(toolUse) {
6375
6583
  typeof toolUse.input.description === "string"
6376
6584
  ? toolUse.input.description
6377
6585
  : undefined;
6586
+ const skillName = toolUse.name === "Skill"
6587
+ ? toolUse.input?.skill
6588
+ : undefined;
6589
+ const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
6378
6590
  return {
6379
6591
  toolName: toolUse.name,
6380
6592
  ...(description ? { title: description } : {}),
6381
6593
  ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
6594
+ ...(skillName ? { skill: skillName } : {}),
6595
+ ...(skillPath ? { skillPath } : {}),
6596
+ };
6597
+ }
6598
+ /** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
6599
+ const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
6600
+ /**
6601
+ * Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
6602
+ *
6603
+ * The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
6604
+ * actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
6605
+ * for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
6606
+ * directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
6607
+ * on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
6608
+ */
6609
+ function resolveSkillPath(skillName, cwd) {
6610
+ if (!cwd) {
6611
+ return undefined;
6612
+ }
6613
+ const colon = skillName.indexOf(":");
6614
+ const scope = colon < 0 ? undefined : skillName.slice(0, colon);
6615
+ const name = colon < 0 ? skillName : skillName.slice(colon + 1);
6616
+ if (!name) {
6617
+ return undefined;
6618
+ }
6619
+ const candidates = [];
6620
+ const addCandidates = (base) => {
6621
+ for (const container of SKILL_CONTAINER_DIRS) {
6622
+ candidates.push(path.join(base, container, name, "SKILL.md"));
6623
+ }
6382
6624
  };
6625
+ if (scope) {
6626
+ // A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
6627
+ addCandidates(path.join(cwd, scope));
6628
+ candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
6629
+ }
6630
+ addCandidates(cwd);
6631
+ addCandidates(os.homedir());
6632
+ return candidates.find((candidate) => existsSync(candidate));
6383
6633
  }
6384
6634
  /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
6385
6635
  * notification for a tool_use. Shared by every site that surfaces a tool call:
@@ -6391,7 +6641,7 @@ function claudeCodeMetaFromToolUse(toolUse) {
6391
6641
  function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
6392
6642
  if (refine) {
6393
6643
  return {
6394
- _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
6644
+ _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
6395
6645
  toolCallId: toolUse.id,
6396
6646
  sessionUpdate: "tool_call_update",
6397
6647
  rawInput,
@@ -6400,7 +6650,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
6400
6650
  }
6401
6651
  return {
6402
6652
  _meta: {
6403
- claudeCode: claudeCodeMetaFromToolUse(toolUse),
6653
+ claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
6404
6654
  ...(toolUse.name === "Bash" && supportsTerminalOutput
6405
6655
  ? { terminal_info: { terminal_id: toolUse.id } }
6406
6656
  : {}),
@@ -6426,7 +6676,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
6426
6676
  const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
6427
6677
  return {
6428
6678
  _meta: {
6429
- claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
6679
+ claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
6430
6680
  },
6431
6681
  toolCallId: toolUse.id,
6432
6682
  sessionUpdate: "tool_call_update",
@@ -6436,33 +6686,6 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
6436
6686
  ...(locations ? { locations } : {}),
6437
6687
  };
6438
6688
  }
6439
- /** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
6440
- * wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
6441
- * by-tool_use_id lookup. Each entry explains why an is_error tool_result
6442
- * carries harness prose instead of the tool's own output — "user-rejected",
6443
- * "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
6444
- * on the wire ahead of schema updates, so no enum check). Malformed entries
6445
- * are skipped rather than failing the message. */
6446
- function parseToolResultMeta(raw) {
6447
- if (!Array.isArray(raw)) {
6448
- return undefined;
6449
- }
6450
- let byToolUseId;
6451
- for (const entry of raw) {
6452
- if (typeof entry !== "object" || entry === null) {
6453
- continue;
6454
- }
6455
- const { id, non_execution_kind, user_feedback } = entry;
6456
- if (typeof id !== "string" || typeof non_execution_kind !== "string") {
6457
- continue;
6458
- }
6459
- (byToolUseId ??= new Map()).set(id, {
6460
- nonExecutionKind: non_execution_kind,
6461
- ...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
6462
- });
6463
- }
6464
- return byToolUseId;
6465
- }
6466
6689
  /**
6467
6690
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
6468
6691
  * Only handles text, image, and thinking chunks for now.
@@ -6472,7 +6695,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6472
6695
  const registerHooks = options?.registerHooks !== false;
6473
6696
  const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
6474
6697
  if (typeof content === "string") {
6475
- if (content.length === 0) {
6698
+ if (content.length === 0 || containsFileChangeAuditMarker(content)) {
6476
6699
  return [];
6477
6700
  }
6478
6701
  const update = {
@@ -6506,6 +6729,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6506
6729
  // Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
6507
6730
  // messages need no single-block guard.
6508
6731
  const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
6732
+ // A report-phase assistant message may contain a short text preface and the
6733
+ // internal tool call in the same content array. Hide the whole message, not
6734
+ // only the tool block, so it stays absent on session replay as well as live.
6735
+ const containsFileChangeAuditToolUse = content.some((chunk) => (chunk.type === "tool_use" ||
6736
+ chunk.type === "server_tool_use" ||
6737
+ chunk.type === "mcp_tool_use") &&
6738
+ isFileChangeAuditTool(chunk.name));
6509
6739
  const output = [];
6510
6740
  // Only handle the first chunk for streaming; extend as needed for batching
6511
6741
  for (const chunk of content) {
@@ -6513,7 +6743,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6513
6743
  switch (chunk.type) {
6514
6744
  case "text":
6515
6745
  case "text_delta": {
6516
- if (chunk.text) {
6746
+ if (chunk.text &&
6747
+ !containsFileChangeAuditToolUse &&
6748
+ !containsFileChangeAuditMarker(chunk.text)) {
6517
6749
  update = {
6518
6750
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6519
6751
  content: {
@@ -6525,21 +6757,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6525
6757
  break;
6526
6758
  }
6527
6759
  case "image":
6528
- update = {
6529
- sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6530
- content: {
6531
- type: "image",
6532
- data: chunk.source.type === "base64" ? chunk.source.data : "",
6533
- mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
6534
- uri: chunk.source.type === "url" ? chunk.source.url : undefined,
6535
- },
6536
- };
6760
+ if (!containsFileChangeAuditToolUse)
6761
+ update = {
6762
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
6763
+ content: {
6764
+ type: "image",
6765
+ data: chunk.source.type === "base64" ? chunk.source.data : "",
6766
+ mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
6767
+ uri: chunk.source.type === "url" ? chunk.source.url : undefined,
6768
+ },
6769
+ };
6537
6770
  break;
6538
6771
  case "thinking":
6539
6772
  case "thinking_delta": {
6540
6773
  // Recent models default `thinking.display` to "omitted", which streams
6541
6774
  // signature-only thinking blocks whose text is empty.
6542
- if (chunk.thinking) {
6775
+ if (chunk.thinking && !containsFileChangeAuditToolUse) {
6543
6776
  update = {
6544
6777
  sessionUpdate: "agent_thought_chunk",
6545
6778
  content: {
@@ -6555,7 +6788,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6555
6788
  case "mcp_tool_use": {
6556
6789
  const alreadyCached = chunk.id in toolUseCache;
6557
6790
  toolUseCache[chunk.id] = chunk;
6558
- if (chunk.name === "TodoWrite") {
6791
+ if (isFileChangeAuditTool(chunk.name)) {
6792
+ // Wrapper-owned audit protocol: never surface or register generic
6793
+ // PostToolUse callbacks for the internal tool.
6794
+ }
6795
+ else if (chunk.name === "TodoWrite") {
6559
6796
  // @ts-expect-error - sometimes input is empty object or undefined
6560
6797
  if (Array.isArray(chunk.input?.todos)) {
6561
6798
  update = {
@@ -6679,6 +6916,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6679
6916
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
6680
6917
  break;
6681
6918
  }
6919
+ if (isFileChangeAuditTool(toolUse.name)) {
6920
+ delete toolUseCache[chunk.tool_use_id];
6921
+ break;
6922
+ }
6682
6923
  // A permission request may have surfaced a plan-rendered (TodoWrite) or
6683
6924
  // suppressed (Task*) tool as a real tool_call so the request referenced
6684
6925
  // a tool call the client knows about (see `ensureToolCallEmitted`,
@@ -6709,20 +6950,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6709
6950
  }
6710
6951
  if (isTaskTool(toolUse.name)) {
6711
6952
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
6712
- // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
6713
- // and TaskGet are read-only so we just suppress their tool_call /
6714
- // tool_result events. The plan update is emitted as a snapshot of
6715
- // the accumulated state, mirroring the legacy TodoWrite behavior.
6953
+ // TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
6954
+ // reconciles it from the SDK's authoritative snapshot, which repairs
6955
+ // resumed or compacted sessions whose creating calls are no longer in
6956
+ // replay history. TaskGet is read-only and remains suppressed. Plan
6957
+ // updates always carry the full accumulated snapshot, mirroring the
6958
+ // legacy TodoWrite behavior.
6716
6959
  const isError = "is_error" in chunk && chunk.is_error;
6960
+ let shouldEmitTaskPlan = false;
6717
6961
  if (!isError) {
6718
6962
  if (toolUse.name === "TaskCreate") {
6719
- applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
6963
+ applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content));
6964
+ shouldEmitTaskPlan = true;
6720
6965
  }
6721
6966
  else if (toolUse.name === "TaskUpdate") {
6722
- applyTaskUpdate(taskState, toolUse.input);
6967
+ const input = toolUse.input;
6968
+ const output = parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
6969
+ parseTaskUpdateOutput(chunk.content, input?.taskId);
6970
+ // Older CLI transcripts have no structured output, so retain the
6971
+ // input-based fallback. When an output is available, only apply a
6972
+ // confirmed update for the same task.
6973
+ if (!output || (output.success && output.taskId === input?.taskId)) {
6974
+ applyTaskUpdate(taskState, input);
6975
+ shouldEmitTaskPlan = true;
6976
+ }
6977
+ }
6978
+ else if (toolUse.name === "TaskList") {
6979
+ const output = parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
6980
+ if (output) {
6981
+ applyTaskList(taskState, output);
6982
+ shouldEmitTaskPlan = true;
6983
+ }
6723
6984
  }
6724
6985
  }
6725
- if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
6986
+ if (shouldEmitTaskPlan) {
6726
6987
  update = {
6727
6988
  sessionUpdate: "plan",
6728
6989
  entries: taskStateToPlanEntries(taskState),
@@ -6762,7 +7023,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6762
7023
  toolCallId: chunk.tool_use_id,
6763
7024
  sessionUpdate: "tool_call_update",
6764
7025
  status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
6765
- rawOutput: chunk.content,
7026
+ // terminal_output already carried the exact bytes in the preceding
7027
+ // update. Repeating them as rawOutput wastes bandwidth and lets a
7028
+ // client accidentally render the same output twice.
7029
+ ...(toolMeta?.terminal_output
7030
+ ? {}
7031
+ : { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
6766
7032
  ...toolUpdate,
6767
7033
  };
6768
7034
  }
@@ -6783,7 +7049,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6783
7049
  case "compaction":
6784
7050
  case "compaction_delta":
6785
7051
  case "advisor_tool_result":
6786
- case "mid_conv_system":
6787
7052
  case "fallback":
6788
7053
  break;
6789
7054
  default:
@@ -6940,7 +7205,7 @@ export async function runPromptWithCancellation(agent, params, signal) {
6940
7205
  signal.removeEventListener("abort", onAbort);
6941
7206
  }
6942
7207
  }
6943
- export function runAcp() {
7208
+ export function runAcp(logger) {
6944
7209
  const input = nodeToWebWritable(process.stdout);
6945
7210
  const output = nodeToWebReadable(process.stdin);
6946
7211
  const stream = ndJsonStream(input, output);
@@ -6973,7 +7238,7 @@ export function runAcp() {
6973
7238
  .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
6974
7239
  .onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
6975
7240
  .connect(stream);
6976
- agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
7241
+ agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
6977
7242
  return { connection, agent };
6978
7243
  }
6979
7244
  function commonPrefixLength(a, b) {
@@ -7068,6 +7333,7 @@ const PROVIDER_ROUTING_ENV_VARS = [
7068
7333
  "ANTHROPIC_CUSTOM_HEADERS",
7069
7334
  "ANTHROPIC_API_KEY",
7070
7335
  "ANTHROPIC_AUTH_TOKEN",
7336
+ "CLAUDE_CODE_OAUTH_TOKEN",
7071
7337
  ];
7072
7338
  /** Stable identifier for the LLM backend a session's query is created against,
7073
7339
  * used to scope {@link contextWindowCache} per backend. Positional `\0`-join