@agentclientprotocol/claude-agent-acp 0.70.0 → 0.71.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 (76) hide show
  1. package/README.md +10 -9
  2. package/dist/acp-agent.d.ts +105 -38
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +1085 -660
  5. package/dist/acp-subagents.d.ts +82 -0
  6. package/dist/acp-subagents.d.ts.map +1 -0
  7. package/dist/acp-subagents.js +13 -0
  8. package/dist/air-extension.d.ts +23 -0
  9. package/dist/air-extension.d.ts.map +1 -0
  10. package/dist/air-extension.js +63 -0
  11. package/dist/async-tasks.d.ts +109 -0
  12. package/dist/async-tasks.d.ts.map +1 -0
  13. package/dist/async-tasks.js +613 -0
  14. package/dist/clear-context-coordinator.d.ts +60 -0
  15. package/dist/clear-context-coordinator.d.ts.map +1 -0
  16. package/dist/clear-context-coordinator.js +80 -0
  17. package/dist/exit-plan.d.ts +42 -0
  18. package/dist/exit-plan.d.ts.map +1 -0
  19. package/dist/exit-plan.js +154 -0
  20. package/dist/file-change-audit.d.ts.map +1 -1
  21. package/dist/file-change-audit.js +4 -33
  22. package/dist/fork-session.d.ts +12 -0
  23. package/dist/fork-session.d.ts.map +1 -0
  24. package/dist/fork-session.js +41 -0
  25. package/dist/native-subagents.d.ts +71 -0
  26. package/dist/native-subagents.d.ts.map +1 -0
  27. package/dist/native-subagents.js +422 -0
  28. package/dist/permissions/effects.d.ts +19 -0
  29. package/dist/permissions/effects.d.ts.map +1 -0
  30. package/dist/permissions/effects.js +166 -0
  31. package/dist/permissions/modes.d.ts +8 -0
  32. package/dist/permissions/modes.d.ts.map +1 -0
  33. package/dist/permissions/modes.js +41 -0
  34. package/dist/permissions/normalization.d.ts +6 -0
  35. package/dist/permissions/normalization.d.ts.map +1 -0
  36. package/dist/permissions/normalization.js +100 -0
  37. package/dist/permissions/options/filesystem.d.ts +9 -0
  38. package/dist/permissions/options/filesystem.d.ts.map +1 -0
  39. package/dist/permissions/options/filesystem.js +124 -0
  40. package/dist/permissions/options/shared.d.ts +39 -0
  41. package/dist/permissions/options/shared.d.ts.map +1 -0
  42. package/dist/permissions/options/shared.js +64 -0
  43. package/dist/permissions/options/shell.d.ts +5 -0
  44. package/dist/permissions/options/shell.d.ts.map +1 -0
  45. package/dist/permissions/options/shell.js +100 -0
  46. package/dist/permissions/options/tools.d.ts +11 -0
  47. package/dist/permissions/options/tools.d.ts.map +1 -0
  48. package/dist/permissions/options/tools.js +135 -0
  49. package/dist/permissions/options.d.ts +5 -0
  50. package/dist/permissions/options.d.ts.map +1 -0
  51. package/dist/permissions/options.js +60 -0
  52. package/dist/permissions/presentation.d.ts +15 -0
  53. package/dist/permissions/presentation.d.ts.map +1 -0
  54. package/dist/permissions/presentation.js +83 -0
  55. package/dist/permissions/response.d.ts +11 -0
  56. package/dist/permissions/response.d.ts.map +1 -0
  57. package/dist/permissions/response.js +19 -0
  58. package/dist/session-config-ids.d.ts +5 -0
  59. package/dist/session-config-ids.d.ts.map +1 -0
  60. package/dist/session-config-ids.js +4 -0
  61. package/dist/session-failure-extension.d.ts +2 -24
  62. package/dist/session-failure-extension.d.ts.map +1 -1
  63. package/dist/session-failure-extension.js +13 -40
  64. package/dist/session-mode.d.ts +66 -0
  65. package/dist/session-mode.d.ts.map +1 -0
  66. package/dist/session-mode.js +234 -0
  67. package/dist/session-titles.d.ts +77 -0
  68. package/dist/session-titles.d.ts.map +1 -0
  69. package/dist/session-titles.js +199 -0
  70. package/dist/tool-result-meta.d.ts +8 -0
  71. package/dist/tool-result-meta.d.ts.map +1 -0
  72. package/dist/tool-result-meta.js +19 -0
  73. package/dist/tools.d.ts +6 -1
  74. package/dist/tools.d.ts.map +1 -1
  75. package/dist/tools.js +39 -18
  76. package/package.json +6 -6
package/dist/acp-agent.js CHANGED
@@ -1,6 +1,11 @@
1
- import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
- import { deleteSession, getSessionInfo, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
1
+ import { agent as acpAgent, CreateElicitationResponse, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/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";
5
+ import { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, asSdkSessionNotification, clientSupportsSubagents, } from "./acp-subagents.js";
6
+ import { isNativeSubagentControlTool, isNativeSubagentControlUpdate, NativeSubagentRuntime, } from "./native-subagents.js";
7
+ import { AIR_ASYNC_TASKS_CAPABILITY, withAirMeta } from "./air-extension.js";
8
+ import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, clientSupportsAsyncTasks, } from "./async-tasks.js";
4
9
  import { execFile } from "node:child_process";
5
10
  import { randomUUID } from "node:crypto";
6
11
  import { existsSync } from "node:fs";
@@ -10,25 +15,37 @@ import * as path from "node:path";
10
15
  import { promisify } from "node:util";
11
16
  import packageJson from "../package.json" with { type: "json" };
12
17
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
18
+ import { forkSession } from "./fork-session.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";
13
24
  import { SettingsManager } from "./settings.js";
14
25
  import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
15
26
  import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
16
- import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
27
+ import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, unregisterHookCallback, } from "./tools.js";
17
28
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
29
+ import { acceptedPlanToolResult, ExitPlanCoordinator, executionDiagnostic, exitPlanModeRawOutput, observeExitPlanToolResults, } from "./exit-plan.js";
30
+ import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
31
+ import { parseToolResultMeta } from "./tool-result-meta.js";
32
+ export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
33
+ import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
18
34
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
19
35
  const execFileAsync = promisify(execFile);
20
- const MAX_TITLE_LENGTH = 256;
21
36
  const MAX_INLINE_FAILURE_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) + "…";
37
+ /** Claude CLI emits this synthetic result when an interrupted cycle ends on
38
+ * queued user input before producing any assistant content. It is a hand-off
39
+ * marker, not the outcome of the replacement prompt that may already be
40
+ * active by the time the SDK stream delivers it. */
41
+ function isEmptyUserInterruptionDiagnostic(message) {
42
+ const diagnostic = "result" in message
43
+ ? message.result
44
+ : message.errors.find((error) => error.startsWith("[ede_diagnostic]"));
45
+ return (diagnostic?.startsWith("[ede_diagnostic]") === true &&
46
+ /(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
47
+ /(?:^|\s)last_content_type=n\/a(?:\s|$)/.test(diagnostic) &&
48
+ /(?:^|\s)stop_reason=null(?:\s|$)/.test(diagnostic));
32
49
  }
33
50
  const ZERO_USAGE = Object.freeze({
34
51
  input_tokens: 0,
@@ -47,6 +64,31 @@ const DEFAULT_CONTEXT_WINDOW = 200000;
47
64
  * "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
48
65
  * pre-empt a slow-but-healthy interrupt. */
49
66
  const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
67
+ /** Claude Code keeps the OAuth callback listener open in the background after
68
+ * `mcpAuthenticate` returns the authorization URL. The SDK does not expose
69
+ * that listener's completion promise, so watch the server status while the
70
+ * ACP client has the URL elicitation open. */
71
+ const MCP_OAUTH_STATUS_POLL_MS = 1_000;
72
+ const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000;
73
+ function supportsMcpOAuth(query) {
74
+ return typeof query.mcpAuthenticate === "function";
75
+ }
76
+ /** Wait for a polling interval, resolving false when the session is aborted. */
77
+ function waitUnlessAborted(milliseconds, signal) {
78
+ if (signal.aborted)
79
+ return Promise.resolve(false);
80
+ return new Promise((resolve) => {
81
+ const timer = setTimeout(() => {
82
+ signal.removeEventListener("abort", onAbort);
83
+ resolve(true);
84
+ }, milliseconds);
85
+ const onAbort = () => {
86
+ clearTimeout(timer);
87
+ resolve(false);
88
+ };
89
+ signal.addEventListener("abort", onAbort, { once: true });
90
+ });
91
+ }
50
92
  /** Error surfaced when the SDK declares a turn over (`session_state_changed:
51
93
  * idle`, its authoritative turn-over signal) without ever emitting the turn's
52
94
  * `result` — a model stream that dropped mid-turn, or an async agent that
@@ -59,13 +101,30 @@ const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went
59
101
  * agreed ACP steering wire protocol; advertised to clients via the top-level
60
102
  * `InitializeResponse._meta.steering.supported`. */
61
103
  const STEER_METHOD = "_session/steering";
104
+ /** Stops one Claude background task without cancelling the parent prompt turn. */
105
+ const ASYNC_TASK_STOP_METHOD = "_session/async_task/stop";
106
+ function parseAsyncTaskStopRequest(value) {
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
108
+ throw RequestError.invalidParams(undefined, "async task stop params must be an object");
109
+ }
110
+ const params = value;
111
+ const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
112
+ const asyncTaskId = typeof params.asyncTaskId === "string" ? params.asyncTaskId.trim() : "";
113
+ if (!sessionId) {
114
+ throw RequestError.invalidParams(undefined, "async task stop params require a non-empty sessionId");
115
+ }
116
+ if (!asyncTaskId) {
117
+ throw RequestError.invalidParams(undefined, "async task stop params require a non-empty asyncTaskId");
118
+ }
119
+ return { sessionId, asyncTaskId };
120
+ }
62
121
  /** How urgently the SDK delivers a steered message relative to the running
63
122
  * turn — an internal Claude implementation detail, not part of the wire
64
- * contract. `now` pre-empts the current generation and handles the message
65
- * immediately (interrupting a single-shot response, or slotting in between a
66
- * multi-step turn's tool calls). Maps to `SDKUserMessage.priority`; injected
67
- * steering always uses `now` so the running turn adapts as soon as possible. */
68
- const STEER_PRIORITY = "now";
123
+ * contract. `now` pre-empts the current generation, while `later` waits for a
124
+ * pending permission/elicitation callback to settle instead of cancelling its
125
+ * ACP request and hiding the client's user-input card (IJAI-1191). */
126
+ const STEER_PRIORITY_NOW = "now";
127
+ const STEER_PRIORITY_LATER = "later";
69
128
  /** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
70
129
  * content blocks are handed to `promptToClaude`, which tolerates unknown block
71
130
  * types — but `sessionId` and a non-empty `prompt` array are required. */
@@ -161,6 +220,28 @@ function parentToolUseIdOf(message) {
161
220
  return null;
162
221
  return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null;
163
222
  }
223
+ function replaySubagentTerminalState(block) {
224
+ if (block.is_error !== true)
225
+ return "completed";
226
+ const text = replayContentText(block.content).toLowerCase();
227
+ return /\b(?:cancelled|canceled|interrupted|stopped|killed)\b/.test(text)
228
+ ? "cancelled"
229
+ : "failed";
230
+ }
231
+ function replayContentText(value, seen = new Set()) {
232
+ if (typeof value === "string")
233
+ return value;
234
+ if (typeof value !== "object" || value === null || seen.has(value))
235
+ return "";
236
+ seen.add(value);
237
+ if (Array.isArray(value))
238
+ return value.map((item) => replayContentText(item, seen)).join(" ");
239
+ const record = value;
240
+ return [record.text, record.content, record.message]
241
+ .map((item) => replayContentText(item, seen))
242
+ .filter(Boolean)
243
+ .join(" ");
244
+ }
164
245
  function stripSubagentTextAndThinking(content) {
165
246
  if (!Array.isArray(content))
166
247
  return content;
@@ -280,9 +361,6 @@ function shouldHideClaudeAuth() {
280
361
  * query stream has already ended (ran to `done` or died). The stream is not
281
362
  * revivable, so the only recovery is a fresh session. */
282
363
  const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session.";
283
- // Bypass Permissions doesn't work if we are a root/sudo user
284
- const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
285
- const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
286
364
  // Slash commands that the SDK handles locally without replaying the user
287
365
  // message and without invoking the model.
288
366
  const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
@@ -392,147 +470,6 @@ export function isSyntheticLoginMessage(apiMessage) {
392
470
  typeof block.text === "string" &&
393
471
  block.text.includes("Please run /login"));
394
472
  }
395
- const PERMISSION_MODE_ALIASES = {
396
- auto: "auto",
397
- default: "default",
398
- // Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts
399
- // `"defaultMode": "manual"` in settings.json; honor the same alias here.
400
- manual: "default",
401
- acceptedits: "acceptEdits",
402
- dontask: "dontAsk",
403
- plan: "plan",
404
- bypasspermissions: "bypassPermissions",
405
- bypass: "bypassPermissions",
406
- };
407
- export function resolvePermissionMode(defaultMode, logger = console) {
408
- if (defaultMode === undefined) {
409
- return "default";
410
- }
411
- if (typeof defaultMode !== "string") {
412
- logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
413
- return "default";
414
- }
415
- const normalized = defaultMode.trim().toLowerCase();
416
- if (normalized === "") {
417
- logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
418
- return "default";
419
- }
420
- const mapped = PERMISSION_MODE_ALIASES[normalized];
421
- if (!mapped) {
422
- logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
423
- return "default";
424
- }
425
- if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
426
- logger.error("Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.");
427
- return "default";
428
- }
429
- return mapped;
430
- }
431
- function permissionLifetime(destination) {
432
- switch (destination) {
433
- case "session":
434
- return { scope: "session" };
435
- case "cliArg":
436
- return { scope: "process", storage: "cli_argument" };
437
- case "userSettings":
438
- return { scope: "persistent", storage: "user" };
439
- case "projectSettings":
440
- return { scope: "persistent", storage: "project" };
441
- case "localSettings":
442
- return { scope: "persistent", storage: "project_local" };
443
- default:
444
- return { scope: "unknown" };
445
- }
446
- }
447
- function permissionMetadataForAlwaysAllow(suggestions, toolName) {
448
- const effectiveSuggestions = suggestions && suggestions.length > 0
449
- ? suggestions
450
- : [
451
- {
452
- type: "addRules",
453
- rules: [{ toolName }],
454
- behavior: "allow",
455
- destination: "session",
456
- },
457
- ];
458
- const changes = [];
459
- for (const update of effectiveSuggestions) {
460
- switch (update.type) {
461
- case "addRules":
462
- case "removeRules":
463
- case "replaceRules": {
464
- const operation = update.type === "addRules" ? "add" : update.type === "removeRules" ? "remove" : "replace";
465
- const targets = update.rules.map((rule) => ({
466
- type: "tool",
467
- toolName: rule.toolName,
468
- ...(rule.ruleContent
469
- ? {
470
- matcher: {
471
- type: "provider_rule",
472
- provider: "claudeCode",
473
- value: rule.ruleContent,
474
- },
475
- }
476
- : {}),
477
- }));
478
- const renderedRules = update.rules
479
- .map((rule) => rule.ruleContent
480
- ? `${rule.toolName} calls matching ${rule.ruleContent}`
481
- : `all ${rule.toolName} calls`)
482
- .join(", ");
483
- const verb = operation === "add"
484
- ? update.behavior === "allow"
485
- ? "Allow"
486
- : update.behavior === "deny"
487
- ? "Deny"
488
- : "Ask before"
489
- : operation === "remove"
490
- ? `Remove ${update.behavior} rules for`
491
- : `Replace ${update.behavior} rules with`;
492
- changes.push({
493
- type: "policy_rule",
494
- operation,
495
- ruleBehavior: update.behavior,
496
- description: `${verb} ${renderedRules}`,
497
- lifetime: permissionLifetime(update.destination),
498
- targets,
499
- });
500
- break;
501
- }
502
- case "addDirectories":
503
- case "removeDirectories": {
504
- const operation = update.type === "addDirectories" ? "add" : "remove";
505
- changes.push({
506
- type: "policy_rule",
507
- operation,
508
- ruleBehavior: "allow",
509
- description: operation === "add"
510
- ? `Allow filesystem access under ${update.directories.join(", ")}`
511
- : `Remove additional filesystem access under ${update.directories.join(", ")}`,
512
- lifetime: permissionLifetime(update.destination),
513
- targets: update.directories.map((path) => ({
514
- type: "filesystem",
515
- matcher: { type: "directory", path },
516
- })),
517
- });
518
- break;
519
- }
520
- case "setMode":
521
- changes.push({
522
- type: "permission_mode",
523
- operation: "set",
524
- provider: "claudeCode",
525
- mode: update.mode,
526
- description: `Set Claude Code permission mode to ${update.mode}`,
527
- lifetime: permissionLifetime(update.destination),
528
- });
529
- break;
530
- default:
531
- break;
532
- }
533
- }
534
- return { version: 1, changes };
535
- }
536
473
  /**
537
474
  * Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
538
475
  * exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
@@ -545,7 +482,7 @@ class ClientConnection {
545
482
  this.ctx = ctx;
546
483
  }
547
484
  sessionUpdate(params) {
548
- return this.ctx.notify(methods.client.session.update, params);
485
+ return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
549
486
  }
550
487
  requestPermission(params, signal) {
551
488
  return this.ctx.request(methods.client.session.requestPermission, params, {
@@ -570,16 +507,152 @@ class ClientConnection {
570
507
  return this.ctx.notify(method, params);
571
508
  }
572
509
  }
510
+ function raceWithAbort(operation, signal) {
511
+ return new Promise((resolve, reject) => {
512
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
513
+ const onAbort = () => {
514
+ cleanup();
515
+ reject(new Error("Tool use aborted"));
516
+ };
517
+ signal.addEventListener("abort", onAbort, { once: true });
518
+ if (signal.aborted) {
519
+ onAbort();
520
+ }
521
+ void operation.then((value) => {
522
+ cleanup();
523
+ resolve(value);
524
+ }, (error) => {
525
+ cleanup();
526
+ reject(error);
527
+ });
528
+ });
529
+ }
530
+ /** Background OAuth work is implementation state, not part of the public
531
+ * `Session` shape exposed through `ClaudeAcpAgent.sessions`. */
532
+ const mcpAuthentications = new WeakMap();
533
+ /** Start OAuth for ACP-provided MCP servers that Claude reported as needing
534
+ * authentication. This runs after session creation has returned so an
535
+ * interactive browser flow never delays `session/new`. */
536
+ function startMcpAuthentication(host, sessionId, mcpServers) {
537
+ if (!host.clientCapabilities?.elicitation?.url || mcpServers.length === 0)
538
+ return;
539
+ const session = host.sessions[sessionId];
540
+ if (!session || mcpAuthentications.has(session))
541
+ return;
542
+ const requestedServers = new Set(mcpServers.map((server) => server.name));
543
+ const authentication = authenticateMcpServers(host, sessionId, session.query, requestedServers)
544
+ .catch((error) => {
545
+ if (!session.abortController.signal.aborted) {
546
+ host.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`);
547
+ }
548
+ })
549
+ .finally(() => {
550
+ if (mcpAuthentications.get(session) === authentication) {
551
+ mcpAuthentications.delete(session);
552
+ }
553
+ });
554
+ mcpAuthentications.set(session, authentication);
555
+ }
556
+ async function authenticateMcpServers(host, sessionId, query, requestedServers) {
557
+ if (!supportsMcpOAuth(query)) {
558
+ host.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication.");
559
+ return;
560
+ }
561
+ const statuses = await query.mcpServerStatus();
562
+ for (const status of statuses) {
563
+ if (status.status !== "needs-auth" || !requestedServers.has(status.name))
564
+ continue;
565
+ try {
566
+ await authenticateMcpServer(host, sessionId, query, status.name);
567
+ }
568
+ catch (error) {
569
+ const session = host.sessions[sessionId];
570
+ if (session && !session.abortController.signal.aborted) {
571
+ host.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`);
572
+ }
573
+ }
574
+ }
575
+ }
576
+ /** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation.
577
+ * Claude opens and owns the localhost callback listener; the ACP client only
578
+ * needs to present the returned authorization URL. */
579
+ async function authenticateMcpServer(host, sessionId, query, serverName) {
580
+ const session = host.sessions[sessionId];
581
+ if (!session)
582
+ return;
583
+ const login = await query.mcpAuthenticate(serverName);
584
+ if (!login.requiresUserAction)
585
+ return;
586
+ if (!login.authUrl) {
587
+ throw new Error("Claude Code requested user action without returning an authorization URL");
588
+ }
589
+ const elicitationId = `mcp-oauth-${randomUUID()}`;
590
+ const flowAbort = new AbortController();
591
+ const abortFlow = () => flowAbort.abort(session.abortController.signal.reason);
592
+ session.abortController.signal.addEventListener("abort", abortFlow, { once: true });
593
+ if (session.abortController.signal.aborted)
594
+ abortFlow();
595
+ try {
596
+ const completed = waitForMcpAuthentication(host.sessions, sessionId, query, serverName, flowAbort.signal);
597
+ const elicitation = host.client.unstable_createElicitation({
598
+ mode: "url",
599
+ sessionId,
600
+ message: `Authenticate with MCP server ${serverName}`,
601
+ url: login.authUrl,
602
+ elicitationId,
603
+ }, flowAbort.signal);
604
+ const first = await Promise.race([
605
+ completed.then((authenticated) => ({ type: "completed", authenticated })),
606
+ elicitation.then((response) => ({ type: "elicitation", response })),
607
+ ]);
608
+ if (first.type === "elicitation" && !CreateElicitationResponse.isAccept(first.response)) {
609
+ return;
610
+ }
611
+ if (first.type === "elicitation") {
612
+ await completed;
613
+ }
614
+ try {
615
+ await host.client.unstable_completeElicitation({ elicitationId });
616
+ }
617
+ catch (error) {
618
+ if (!flowAbort.signal.aborted) {
619
+ host.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`);
620
+ }
621
+ }
622
+ }
623
+ finally {
624
+ flowAbort.abort();
625
+ session.abortController.signal.removeEventListener("abort", abortFlow);
626
+ }
627
+ }
628
+ async function waitForMcpAuthentication(sessions, sessionId, query, serverName, signal) {
629
+ const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS;
630
+ while (!signal.aborted && Date.now() < deadline) {
631
+ const session = sessions[sessionId];
632
+ if (!session || session.query !== query)
633
+ return false;
634
+ const status = (await query.mcpServerStatus()).find((server) => server.name === serverName);
635
+ if (!status || status.status === "failed" || status.status === "disabled")
636
+ return false;
637
+ if (status.status === "connected")
638
+ return true;
639
+ if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal)))
640
+ return false;
641
+ }
642
+ return false;
643
+ }
573
644
  export class ClaudeAcpAgent {
574
645
  sessions;
575
646
  client;
576
647
  clientCapabilities;
577
648
  logger;
649
+ sessionModes;
578
650
  gatewayAuthRequest;
579
651
  /** Set while ACP overrides the agent's native provider configuration. */
580
652
  providerConfig;
581
653
  /** Serializes provider changes while every open query is recreated between turns. */
582
654
  providerUpdate = null;
655
+ exitPlan;
583
656
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
584
657
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
585
658
  * tests can shrink it. */
@@ -588,6 +661,52 @@ export class ClaudeAcpAgent {
588
661
  this.sessions = {};
589
662
  this.client = client;
590
663
  this.logger = logger ?? console;
664
+ this.exitPlan = new ExitPlanCoordinator({
665
+ currentSession: (id) => this.sessions[id],
666
+ closeQueryStream: (session) => this.closeQueryStream(session),
667
+ restartSession: async (params, options) => {
668
+ await this.createSession(params, options);
669
+ const session = this.sessions[options.publicSessionId];
670
+ if (!session)
671
+ throw new Error("Fresh Claude context was not created");
672
+ return session;
673
+ },
674
+ applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
675
+ sessionUpdate: (notification) => this.client.sessionUpdate(notification),
676
+ ensureConsumer: (session, id) => this.ensureConsumer(session, id),
677
+ logError: (message, error) => this.logger.error(message, error),
678
+ destroyReplacement: (id, session) => {
679
+ disarmForceCancel(session);
680
+ session.cancelController?.abort();
681
+ this.closeQueryStream(session);
682
+ session.abortController.abort();
683
+ session.eagerToolCallSessions?.clear();
684
+ clearHookCallbacks(id);
685
+ session.nativeSubagentRuntime?.clear();
686
+ session.asyncTaskRuntime?.clear();
687
+ if (this.sessions[id] === session)
688
+ delete this.sessions[id];
689
+ },
690
+ settleCancelledTurn: (original, session, turn) => {
691
+ disarmForceCancel(session);
692
+ this.finishFileChangeAudit(session, turn, "cancelled");
693
+ turn.settled = true;
694
+ turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) });
695
+ },
696
+ settleFailedTurn: (session, turn, error) => {
697
+ disarmForceCancel(session);
698
+ this.finishFileChangeAudit(session, turn, "providerError");
699
+ turn.settled = true;
700
+ turn.reject(error);
701
+ },
702
+ });
703
+ this.sessionModes = new SessionModeManager({
704
+ getSession: (sessionId) => this.sessions[sessionId],
705
+ sessionEndedMessage: SESSION_ENDED_MESSAGE,
706
+ updateConfigOption: (sessionId, configId, value) => this.updateConfigOption(sessionId, configId, value),
707
+ sessionUpdate: (params) => this.client.sessionUpdate(params),
708
+ logError: (...args) => this.logger.error(...args),
709
+ });
591
710
  }
592
711
  async initialize(request) {
593
712
  this.clientCapabilities = request.clientCapabilities;
@@ -686,6 +805,15 @@ export class ClaudeAcpAgent {
686
805
  terminalAuthMethods.push(consoleLoginMethod);
687
806
  }
688
807
  }
808
+ const sessionCapabilities = {
809
+ additionalDirectories: {},
810
+ close: {},
811
+ delete: {},
812
+ fork: {},
813
+ list: {},
814
+ resume: {},
815
+ subagents: {},
816
+ };
689
817
  return {
690
818
  protocolVersion: 1,
691
819
  agentCapabilities: {
@@ -710,14 +838,7 @@ export class ClaudeAcpAgent {
710
838
  // capability prerequisite for the provider methods.
711
839
  providers: {},
712
840
  loadSession: true,
713
- sessionCapabilities: {
714
- additionalDirectories: {},
715
- close: {},
716
- delete: {},
717
- fork: {},
718
- list: {},
719
- resume: {},
720
- },
841
+ sessionCapabilities,
721
842
  },
722
843
  agentInfo: {
723
844
  name: packageJson.name,
@@ -732,7 +853,7 @@ export class ClaudeAcpAgent {
732
853
  // steering extension contract: advertises the `_session/steering` request
733
854
  // so clients know they may inject a follow-up into a running turn.
734
855
  _meta: {
735
- ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
856
+ ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY),
736
857
  steering: {
737
858
  supported: true,
738
859
  },
@@ -754,26 +875,17 @@ export class ClaudeAcpAgent {
754
875
  // Needs to happen after we return the session
755
876
  setTimeout(() => {
756
877
  this.sendAvailableCommandsUpdate(response.sessionId);
878
+ startMcpAuthentication(this, response.sessionId, params.mcpServers);
757
879
  }, 0);
758
880
  return response;
759
881
  }
760
882
  async unstable_forkSession(params) {
761
883
  if (this.providerUpdate)
762
884
  await this.providerUpdate;
763
- const response = await this.createSession({
764
- cwd: params.cwd,
765
- mcpServers: params.mcpServers ?? [],
766
- additionalDirectories: params.additionalDirectories,
767
- _meta: params._meta,
768
- }, {
769
- resume: params.sessionId,
770
- forkSession: true,
885
+ return forkSession(params, {
886
+ liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
887
+ messageIdForGrouping,
771
888
  });
772
- // Needs to happen after we return the session
773
- setTimeout(() => {
774
- this.sendAvailableCommandsUpdate(response.sessionId);
775
- }, 0);
776
- return response;
777
889
  }
778
890
  async resumeSession(params) {
779
891
  if (this.providerUpdate)
@@ -782,6 +894,7 @@ export class ClaudeAcpAgent {
782
894
  // Needs to happen after we return the session
783
895
  setTimeout(() => {
784
896
  this.sendAvailableCommandsUpdate(params.sessionId);
897
+ startMcpAuthentication(this, params.sessionId, params.mcpServers ?? []);
785
898
  }, 0);
786
899
  return result;
787
900
  }
@@ -793,6 +906,7 @@ export class ClaudeAcpAgent {
793
906
  // Send available commands after replay so it doesn't interleave with history
794
907
  setTimeout(() => {
795
908
  this.sendAvailableCommandsUpdate(params.sessionId);
909
+ startMcpAuthentication(this, params.sessionId, params.mcpServers ?? []);
796
910
  }, 0);
797
911
  return result;
798
912
  }
@@ -813,40 +927,6 @@ export class ClaudeAcpAgent {
813
927
  sessions,
814
928
  };
815
929
  }
816
- /** Read the SDK-maintained title for a session and, if it changed since the
817
- * last time we looked, notify the client with a `session_info_update`. The
818
- * SDK has no push event for the title it auto-generates in the background, so
819
- * we pull it at turn-end. A missing session file or read error is non-fatal:
820
- * the title is best-effort and another turn will retry. */
821
- async maybeUpdateSessionTitle(sessionId, session) {
822
- let info;
823
- try {
824
- info = await getSessionInfo(sessionId, { dir: session.cwd });
825
- }
826
- catch (error) {
827
- this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`);
828
- return;
829
- }
830
- // `customTitle` is a user-set `/rename`; `summary` is the auto-generated
831
- // title (or first prompt). Prefer the explicit title when present.
832
- const rawTitle = info?.customTitle ?? info?.summary;
833
- if (!rawTitle) {
834
- return;
835
- }
836
- const title = sanitizeTitle(rawTitle);
837
- if (title === session.lastTitle) {
838
- return;
839
- }
840
- session.lastTitle = title;
841
- await this.client.sessionUpdate({
842
- sessionId,
843
- update: {
844
- sessionUpdate: "session_info_update",
845
- title,
846
- updatedAt: new Date(info.lastModified).toISOString(),
847
- },
848
- });
849
- }
850
930
  async authenticate(_params) {
851
931
  if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
852
932
  this.gatewayAuthRequest = _params;
@@ -983,6 +1063,9 @@ export class ClaudeAcpAgent {
983
1063
  if (session.queryClosed) {
984
1064
  throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
985
1065
  }
1066
+ if (session.autoModeFallbackWarningPending) {
1067
+ await this.sessionModes.publishFallbackWarning(params.sessionId, session);
1068
+ }
986
1069
  if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
987
1070
  await this.publishTaskPlan(params.sessionId, session.taskState);
988
1071
  }
@@ -1002,6 +1085,7 @@ export class ClaudeAcpAgent {
1002
1085
  session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
1003
1086
  fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
1004
1087
  }
1088
+ session.titles.onPrompt(params.prompt);
1005
1089
  // Each prompt is a Turn whose deferred the persistent consumer settles once
1006
1090
  // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
1007
1091
  // turn, pushes the user message onto the streaming input, makes sure the
@@ -1109,11 +1193,13 @@ export class ClaudeAcpAgent {
1109
1193
  * an `SDKUserMessage` onto the same streaming input, which the SDK routes
1110
1194
  * into the in-flight turn. The injected message's echo carries a uuid that
1111
1195
  * matches no queued turn, so the consumer drops it as an unrelated replay
1112
- * without promoting/settling anything. It is delivered at {@link
1113
- * STEER_PRIORITY} (`now`) so it pre-empts the current generation (interrupting
1114
- * a single-shot response, or slotting in between a multi-step turn's tool
1115
- * calls). The steered message's own output streams via `session/update`, not
1116
- * this response.
1196
+ * without promoting/settling anything. It is normally delivered at priority
1197
+ * `now` so it pre-empts the current generation (interrupting a single-shot
1198
+ * response, or slotting in between a multi-step turn's tool calls). While a
1199
+ * permission or elicitation is awaiting user input it uses `later`, because
1200
+ * interrupting that SDK callback cancels the ACP request and can strand the
1201
+ * prompt (IJAI-1191). The steered message's own output streams via
1202
+ * `session/update`, not this response.
1117
1203
  *
1118
1204
  * Pre-empting means ABORTING: the interrupted cycle emits a `result` of its
1119
1205
  * own and the steered message runs as a second one, so the turn is marked
@@ -1165,7 +1251,8 @@ export class ClaudeAcpAgent {
1165
1251
  userMessage.uuid = steeredUuid;
1166
1252
  // Deliver into the running turn rather than queuing behind it as a fresh
1167
1253
  // prompt would.
1168
- userMessage.priority = STEER_PRIORITY;
1254
+ userMessage.priority =
1255
+ (session.pendingUserInputCount ?? 0) > 0 ? STEER_PRIORITY_LATER : STEER_PRIORITY_NOW;
1169
1256
  // Mark before the push and in the same synchronous section as the in-flight
1170
1257
  // check: the interrupt can have the CLI finalizing the aborted cycle by the
1171
1258
  // time the consumer next runs, and an unmarked result would settle the turn
@@ -1183,6 +1270,21 @@ export class ClaudeAcpAgent {
1183
1270
  await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
1184
1271
  return { outcome: "injected" };
1185
1272
  }
1273
+ async stopAsyncTask(params) {
1274
+ const session = this.sessions[params.sessionId];
1275
+ const asyncTasks = session?.asyncTaskRuntime;
1276
+ if (!session || !asyncTasks?.claimStop(params.asyncTaskId))
1277
+ return { stopped: false };
1278
+ try {
1279
+ await session.query.stopTask(params.asyncTaskId);
1280
+ await asyncTasks.taskStopped(params.asyncTaskId);
1281
+ return { stopped: true };
1282
+ }
1283
+ catch (error) {
1284
+ asyncTasks.releaseStop(params.asyncTaskId);
1285
+ throw error;
1286
+ }
1287
+ }
1186
1288
  /** Publish the audit terminal for every turn path that did not reach the
1187
1289
  * report tool. The support flips the turn state synchronously before its
1188
1290
  * transport await, so callers can stay fail-open and settle the ACP prompt
@@ -1271,8 +1373,27 @@ export class ClaudeAcpAgent {
1271
1373
  * recognizable by the `parentToolUseId` meta that toAcpNotifications
1272
1374
  * stamps from `parent_tool_use_id`, and never reach the top-level feed
1273
1375
  * as the turn's answer. */
1376
+ const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(clientSupportsSubagents(this.clientCapabilities), params.sessionId, session, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.logger));
1377
+ const asyncTasks = (session.asyncTaskRuntime ??= new AsyncTaskRuntime(clientSupportsAsyncTasks(this.clientCapabilities), params.sessionId, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification))));
1274
1378
  const sendUpdate = async (notification) => {
1275
1379
  const { update } = notification;
1380
+ const claudeMeta = update._meta?.claudeCode;
1381
+ const toolCallId = update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update"
1382
+ ? update.toolCallId
1383
+ : undefined;
1384
+ const eagerOwnerSessionId = toolCallId
1385
+ ? session.eagerToolCallSessions?.get(toolCallId)
1386
+ : undefined;
1387
+ const routedNotification = await subagents.route(notification, sendUpdate, eagerOwnerSessionId);
1388
+ if (!routedNotification) {
1389
+ // Native Agent/Task control calls are intentionally not transcript
1390
+ // tools. Do not let the mapper's pre-send de-duplication mark make a
1391
+ // later permission request believe that a suppressed call exists.
1392
+ if (toolCallId && isNativeSubagentControlUpdate(update)) {
1393
+ session.emittedToolCalls.delete(toolCallId);
1394
+ }
1395
+ return;
1396
+ }
1276
1397
  if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
1277
1398
  (update.sessionUpdate === "agent_message_chunk" ||
1278
1399
  update.sessionUpdate === "agent_thought_chunk" ||
@@ -1282,12 +1403,32 @@ export class ClaudeAcpAgent {
1282
1403
  return;
1283
1404
  }
1284
1405
  if (update.sessionUpdate === "agent_message_chunk") {
1285
- const claudeMeta = update._meta?.claudeCode;
1286
1406
  if (!claudeMeta?.parentToolUseId) {
1287
1407
  session.emittedAssistantText = true;
1408
+ session.titles.onAssistantText(update.content);
1288
1409
  }
1289
1410
  }
1290
- await this.client.sessionUpdate(notification);
1411
+ await this.client.sessionUpdate(routedNotification);
1412
+ if (toolCallId &&
1413
+ update.sessionUpdate === "tool_call_update" &&
1414
+ (update.status === "completed" || update.status === "failed")) {
1415
+ session.eagerToolCallSessions?.delete(toolCallId);
1416
+ }
1417
+ };
1418
+ // toAcpNotifications registers deferred tool hooks that publish through
1419
+ // the client passed to it. Keep those later updates on the same child-aware
1420
+ // routing path as the immediate notifications.
1421
+ const routedNotificationClient = { sessionUpdate: sendUpdate };
1422
+ session.nativeSubagentDeliver = sendUpdate;
1423
+ const finishLifecycle = async (nativeState, asyncState, context) => {
1424
+ await Promise.all([
1425
+ subagents
1426
+ .finishAll(nativeState, sendUpdate)
1427
+ .catch((error) => this.logger.error(`Session ${params.sessionId}: failed to publish terminal subagent state ${context}`, error)),
1428
+ asyncTasks
1429
+ .finishAll(asyncState)
1430
+ .catch((error) => this.logger.error(`Session ${params.sessionId}: failed to publish terminal async task state ${context}`, error)),
1431
+ ]);
1291
1432
  };
1292
1433
  let pendingWorkerShutdown = false;
1293
1434
  const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
@@ -1344,12 +1485,17 @@ export class ClaudeAcpAgent {
1344
1485
  // cleared when each consolidated message consumes it. #785 stopped
1345
1486
  // resetting the streamed-content tracking here but left this line.
1346
1487
  stopReason = "end_turn";
1347
- session.accumulatedUsage = {
1488
+ session.accumulatedUsage = session.activeTurn?.carriedUsage ?? {
1348
1489
  inputTokens: 0,
1349
1490
  outputTokens: 0,
1350
1491
  cachedReadTokens: 0,
1351
1492
  cachedWriteTokens: 0,
1352
1493
  };
1494
+ session.accumulatedModelUsage = session.activeTurn?.carriedModelUsage ?? {};
1495
+ if (session.activeTurn) {
1496
+ session.activeTurn.carriedUsage = undefined;
1497
+ session.activeTurn.carriedModelUsage = undefined;
1498
+ }
1353
1499
  };
1354
1500
  /** Promote a queued turn to active: it becomes the one output is attributed
1355
1501
  * to, and its scratch starts fresh. Clears the cancelled flag so a turn
@@ -1648,11 +1794,7 @@ export class ClaudeAcpAgent {
1648
1794
  return;
1649
1795
  }
1650
1796
  sessionFailures.recordActive(failure);
1651
- settleActive({
1652
- stopReason: "end_turn",
1653
- usage: sessionUsage(session),
1654
- _meta: sessionFailureMeta(failure),
1655
- }, "providerError");
1797
+ settleActive(turnOutcome(session, "end_turn", sessionFailureMeta(failure)), "providerError");
1656
1798
  };
1657
1799
  /** Reject every in-flight turn — used when the stream dies. */
1658
1800
  const failAllTurns = (error) => {
@@ -1724,7 +1866,9 @@ export class ClaudeAcpAgent {
1724
1866
  // spent would never drain (it would swallow an unrelated later
1725
1867
  // echo-less result instead).
1726
1868
  const active = session.activeTurn;
1727
- if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
1869
+ if (active.commandFinished === "completed" ||
1870
+ active.commandFinished === "discarded" ||
1871
+ active.commandFinished === "refused") {
1728
1872
  // Finished SDK-side; any result already passed. Nothing to
1729
1873
  // track.
1730
1874
  }
@@ -1749,7 +1893,7 @@ export class ClaudeAcpAgent {
1749
1893
  this.trackOrphanCommand(session, active.promptUuid, "started");
1750
1894
  }
1751
1895
  }
1752
- settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1896
+ settleActive(turnOutcome(session, "cancelled"));
1753
1897
  // The cancelled turn's result may never come (that's why the
1754
1898
  // backstop fired) — close its delivery stretch here so partial
1755
1899
  // streamed text can't suppress the next turn's issue-#453 fallback.
@@ -1803,9 +1947,10 @@ export class ClaudeAcpAgent {
1803
1947
  // a deferred turn's stored outcome (followup results never mutate
1804
1948
  // it), but the stored one is the authoritative source.
1805
1949
  const inFlight = session.activeTurn;
1950
+ await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "at end of stream");
1806
1951
  settleActive(session.cancelled
1807
- ? { stopReason: "cancelled", usage: sessionUsage(session) }
1808
- : (inFlight?.deferredSettle ?? { stopReason, usage: sessionUsage(session) }));
1952
+ ? turnOutcome(session, "cancelled")
1953
+ : (inFlight?.deferredSettle ?? turnOutcome(session, stopReason)));
1809
1954
  // Queued turns the SDK never started never ran, so reject them rather
1810
1955
  // than reporting a success (end_turn) — or a misleading "cancelled" —
1811
1956
  // for a prompt that produced no output. (A cancel already settled the
@@ -1835,7 +1980,7 @@ export class ClaudeAcpAgent {
1835
1980
  }
1836
1981
  // CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
1837
1982
  // uuid-stamped queued command (queued/started/completed/cancelled/
1838
- // discarded) as `command_lifecycle` frames — 2-3 per prompt, since
1983
+ // discarded/refused) as `command_lifecycle` frames — 2-3 per prompt, since
1839
1984
  // prompt() stamps a uuid on every message. The frame is @internal and
1840
1985
  // absent from the SDKMessage union, so handle it BEFORE the exhaustive
1841
1986
  // switch: it must not reach `unreachable`'s error log, and a `case`
@@ -1870,6 +2015,7 @@ export class ClaudeAcpAgent {
1870
2015
  }
1871
2016
  case "completed":
1872
2017
  case "discarded":
2018
+ case "refused":
1873
2019
  case "cancelled": {
1874
2020
  // Terminal frames. Latch the fate on a still-queued turn so a
1875
2021
  // later cancel() doesn't seed an orphan entry for a command
@@ -1892,6 +2038,7 @@ export class ClaudeAcpAgent {
1892
2038
  const state = session.orphanCommands?.get(frame.command_uuid);
1893
2039
  if (state === "pending") {
1894
2040
  session.orphanCommands?.delete(frame.command_uuid);
2041
+ session.pendingEmptyInterruptionDiagnosticCommands?.delete(frame.command_uuid);
1895
2042
  }
1896
2043
  else if (state === "started") {
1897
2044
  session.orphanCommands?.set(frame.command_uuid, "zombie");
@@ -1903,7 +2050,11 @@ export class ClaudeAcpAgent {
1903
2050
  // command folded into another turn whose result is attributed
1904
2051
  // elsewhere — either way no echo-less result remains to skip.
1905
2052
  // "discarded" = session ended with it still queued; no result.
2053
+ // "refused" (2.1.238+) = a cross-session peer message declined
2054
+ // by receive-side policy before dispatch; never a prompt-lane
2055
+ // command of ours, and no result will ever come.
1906
2056
  session.orphanCommands?.delete(frame.command_uuid);
2057
+ session.pendingEmptyInterruptionDiagnosticCommands?.delete(frame.command_uuid);
1907
2058
  break;
1908
2059
  }
1909
2060
  default:
@@ -2026,6 +2177,7 @@ export class ClaudeAcpAgent {
2026
2177
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
2027
2178
  lastAssistantUsage = null;
2028
2179
  lastAssistantTotalUsage = usedTokens ?? 0;
2180
+ session.contextUsedTokens = usedTokens ?? 0;
2029
2181
  await sendUpdate({
2030
2182
  sessionId: message.session_id,
2031
2183
  update: {
@@ -2079,7 +2231,7 @@ export class ClaudeAcpAgent {
2079
2231
  // the interrupted turn's tokens entirely (issue #844). Zero
2080
2232
  // when the cancel pre-empted the result (wedge/force-cancel).
2081
2233
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
2082
- settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
2234
+ settleActive(turnOutcome(session, "cancelled"));
2083
2235
  // An interrupt can pre-empt the turn's result entirely
2084
2236
  // (nothing ran the result-case `finally`), so close the
2085
2237
  // delivery stretch here: idle is the SDK's authoritative
@@ -2153,11 +2305,9 @@ export class ClaudeAcpAgent {
2153
2305
  `for the active turn; failing the in-flight prompt (issue #825)`);
2154
2306
  await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
2155
2307
  }
2156
- // The SDK generates the session title in a background task and
2157
- // persists it to the session file; `idle` is the turn-over
2158
- // signal, so it's the point at which a new title may have
2159
- // landed. Push it to the client if it changed.
2160
- await this.maybeUpdateSessionTitle(params.sessionId, session);
2308
+ // Turn-over is when a title may have landed or become
2309
+ // generatable; see SessionTitles.onTurnEnd.
2310
+ await session.titles.onTurnEnd(session);
2161
2311
  }
2162
2312
  break;
2163
2313
  }
@@ -2223,6 +2373,7 @@ export class ClaudeAcpAgent {
2223
2373
  break;
2224
2374
  }
2225
2375
  case "permission_denied": {
2376
+ unregisterHookCallback(message.tool_use_id);
2226
2377
  // A tool call was auto-denied (by a rule, the classifier,
2227
2378
  // dontAsk mode, etc.) before running. The tool_use block was
2228
2379
  // already emitted as a `tool_call`, so mark it failed with the
@@ -2250,6 +2401,16 @@ export class ClaudeAcpAgent {
2250
2401
  const parentToolUseId = message.agent_id
2251
2402
  ? session.liveBackgroundTasks.get(message.agent_id)?.parentToolUseId
2252
2403
  : undefined;
2404
+ const eagerOwnerSessionId = session.eagerToolCallSessions?.get(message.tool_use_id);
2405
+ if (message.agent_id &&
2406
+ !parentToolUseId &&
2407
+ !eagerOwnerSessionId &&
2408
+ clientSupportsSubagents(this.clientCapabilities)) {
2409
+ // An agent-scoped denial with missing lineage cannot safely
2410
+ // be presented in the root transcript. The matching hidden
2411
+ // child tool call was not announced there.
2412
+ break;
2413
+ }
2253
2414
  const reason = message.decision_reason ?? message.message;
2254
2415
  await sendUpdate({
2255
2416
  sessionId: message.session_id,
@@ -2304,7 +2465,15 @@ export class ClaudeAcpAgent {
2304
2465
  case "hook_progress":
2305
2466
  case "hook_response":
2306
2467
  case "files_persisted":
2468
+ break;
2307
2469
  case "task_progress":
2470
+ await asyncTasks.taskProgress({
2471
+ task_id: message.task_id,
2472
+ description: message.description,
2473
+ summary: message.summary,
2474
+ last_tool_name: message.last_tool_name,
2475
+ usage: message.usage,
2476
+ });
2308
2477
  break;
2309
2478
  case "task_started":
2310
2479
  // For subagent tasks `task_id` is the subagent's agent id (the
@@ -2329,6 +2498,23 @@ export class ClaudeAcpAgent {
2329
2498
  parentToolUseId: message.tool_use_id,
2330
2499
  isSubagent: !!message.subagent_type,
2331
2500
  });
2501
+ await subagents.taskStarted({
2502
+ taskId: message.task_id,
2503
+ toolUseId: message.tool_use_id,
2504
+ subagentType: message.subagent_type,
2505
+ description: message.description,
2506
+ prompt: message.prompt,
2507
+ }, sendUpdate);
2508
+ await asyncTasks.taskStarted({
2509
+ task_id: message.task_id,
2510
+ task_type: message.task_type,
2511
+ description: message.description,
2512
+ subagent_type: message.subagent_type,
2513
+ is_backgrounded: message.is_backgrounded,
2514
+ workflow_name: message.workflow_name,
2515
+ skip_transcript: message.skip_transcript,
2516
+ tool_use_id: message.tool_use_id,
2517
+ });
2332
2518
  if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
2333
2519
  (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
2334
2520
  }
@@ -2336,9 +2522,19 @@ export class ClaudeAcpAgent {
2336
2522
  case "task_notification":
2337
2523
  // The task settled — no further tool calls can originate
2338
2524
  // from it, so its registry entry can be dropped.
2525
+ await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id);
2526
+ await asyncTasks.taskNotification({
2527
+ task_id: message.task_id,
2528
+ status: message.status,
2529
+ summary: message.summary,
2530
+ output_file: message.output_file,
2531
+ });
2532
+ if (message.tool_use_id)
2533
+ subagents.discardPending(message.tool_use_id);
2339
2534
  session.liveBackgroundTasks.delete(message.task_id);
2340
2535
  break;
2341
2536
  case "task_updated":
2537
+ await asyncTasks.taskUpdated(message.task_id, message.patch);
2342
2538
  // terminal-status task_updated patch and a (deduplicated)
2343
2539
  // task_notification when a task settles, but only the patch is
2344
2540
  // guaranteed per transition — prune on it too so the registry
@@ -2347,6 +2543,7 @@ export class ClaudeAcpAgent {
2347
2543
  if (message.patch.status === "completed" ||
2348
2544
  message.patch.status === "failed" ||
2349
2545
  message.patch.status === "killed") {
2546
+ await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
2350
2547
  session.liveBackgroundTasks.delete(message.task_id);
2351
2548
  }
2352
2549
  break;
@@ -2482,6 +2679,7 @@ export class ClaudeAcpAgent {
2482
2679
  case "control_request_progress":
2483
2680
  break;
2484
2681
  case "background_tasks_changed":
2682
+ await asyncTasks.backgroundTasksChanged(message.tasks);
2485
2683
  // A level signal: the full live background-task set on every
2486
2684
  // membership change, with REPLACE semantics. Used only to
2487
2685
  // reconcile `liveBackgroundTasks` — dropping (or, for
@@ -2537,6 +2735,8 @@ export class ClaudeAcpAgent {
2537
2735
  // user-turn lifecycle (stop reason, settles, failActive,
2538
2736
  // slash-command output forwarding), though their cost is real.
2539
2737
  const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
2738
+ const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption;
2739
+ const pendingExitPlanContextReset = session.pendingExitPlanContextReset;
2540
2740
  try {
2541
2741
  // Reconcile the Fast mode toggle with the SDK's reported state.
2542
2742
  // Gated to user-driven turns like every other side effect below;
@@ -2637,6 +2837,18 @@ export class ClaudeAcpAgent {
2637
2837
  session.accumulatedUsage.cachedWriteTokens +=
2638
2838
  message.usage.cache_creation_input_tokens;
2639
2839
  }
2840
+ // The same tally split by model, for `_meta.quota.model_usage`.
2841
+ // `modelUsage` is a running total for the whole query() call, so
2842
+ // this result's own spend is what it added to the previous
2843
+ // reading. Advance the reading even for an autonomous result — it
2844
+ // is part of the running total the NEXT increment is measured
2845
+ // from — but leave the turn tally alone, exactly as above.
2846
+ const modelUsageReading = normalizeModelUsage(message.modelUsage);
2847
+ const resultModelUsage = modelUsageIncrement(modelUsageReading, session.lastModelUsageReading ?? {});
2848
+ session.lastModelUsageReading = modelUsageReading;
2849
+ if (!isAutonomousResult) {
2850
+ session.accumulatedModelUsage = addModelUsage(session.accumulatedModelUsage ?? {}, resultModelUsage);
2851
+ }
2640
2852
  const matchingModelUsage = lastAssistantModel
2641
2853
  ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
2642
2854
  : null;
@@ -2689,6 +2901,8 @@ export class ClaudeAcpAgent {
2689
2901
  });
2690
2902
  }
2691
2903
  if (session.cancelled) {
2904
+ session.pendingExitPlanModeInterruption = undefined;
2905
+ session.pendingExitPlanContextReset = undefined;
2692
2906
  if (!isAutonomousResult) {
2693
2907
  await clearFailuresFromEarlierTurns();
2694
2908
  stopReason = "cancelled";
@@ -2726,7 +2940,61 @@ export class ClaudeAcpAgent {
2726
2940
  }
2727
2941
  break;
2728
2942
  }
2943
+ // A cancel can race startup before the original prompt's echo.
2944
+ // The CLI then replays the cancelled prompt, its interruption
2945
+ // marker, and the replacement prompt before yielding this
2946
+ // error-shaped diagnostic for the OLD cycle. The replacement's
2947
+ // echo has already made it active, so treating the diagnostic as
2948
+ // its result rejects a healthy prompt and leaves its real output
2949
+ // arriving after session/prompt completed. Ignore only the
2950
+ // diagnostic's exact empty-user-interruption signature; ordinary
2951
+ // provider errors still follow the failure lanes below.
2952
+ if (message.is_error &&
2953
+ isEmptyUserInterruptionDiagnostic(message) &&
2954
+ (session.pendingEmptyInterruptionDiagnosticCommands?.size ?? 0) > 0) {
2955
+ const commandUuid = session
2956
+ .pendingEmptyInterruptionDiagnosticCommands.values()
2957
+ .next().value;
2958
+ if (commandUuid) {
2959
+ session.pendingEmptyInterruptionDiagnosticCommands.delete(commandUuid);
2960
+ }
2961
+ break;
2962
+ }
2963
+ // Some CLI versions omit the cancelled cycle's diagnostic. Do
2964
+ // not let an unused hand-off token swallow a later turn's real
2965
+ // interruption failure.
2966
+ session.pendingEmptyInterruptionDiagnosticCommands?.clear();
2729
2967
  await clearFailuresFromEarlierTurns();
2968
+ // `interrupt: true` is required to make "No, keep planning"
2969
+ // terminate the ACP turn. Claude represents that intentional
2970
+ // interrupt as an error-shaped diagnostic, so translate only a
2971
+ // diagnostic causally paired with the recorded ExitPlanMode
2972
+ // permission response.
2973
+ const diagnostic = executionDiagnostic(message);
2974
+ if (pendingExitPlanModeInterruption &&
2975
+ pendingExitPlanModeInterruption.toolResultSeen &&
2976
+ message.is_error &&
2977
+ diagnostic &&
2978
+ /(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
2979
+ /(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic)) {
2980
+ session.pendingExitPlanModeInterruption = undefined;
2981
+ if (pendingExitPlanContextReset &&
2982
+ pendingExitPlanContextReset.toolUseId ===
2983
+ pendingExitPlanModeInterruption.toolUseId) {
2984
+ await this.exitPlan.restart(params.sessionId, session, pendingExitPlanContextReset);
2985
+ return;
2986
+ }
2987
+ stopReason = "cancelled";
2988
+ settleOrDefer(turnOutcome(session, "cancelled"));
2989
+ break;
2990
+ }
2991
+ if (pendingExitPlanModeInterruption) {
2992
+ // This result ended the interrupted cycle without the exact
2993
+ // correlated cancellation shape. Never carry its marker into
2994
+ // a later turn, whether or not the tool result was observed.
2995
+ session.pendingExitPlanModeInterruption = undefined;
2996
+ session.pendingExitPlanContextReset = undefined;
2997
+ }
2730
2998
  // A refusal can arrive on any result subtype (and may even set
2731
2999
  // is_error), so handle it before the subtype switch — otherwise the
2732
3000
  // is_error throw below would surface it as an internal error. The
@@ -2749,7 +3017,7 @@ export class ClaudeAcpAgent {
2749
3017
  // settling it out from under them would strand their output
2750
3018
  // and permission requests out-of-turn (issue #866's deadlock,
2751
3019
  // through the refusal lane).
2752
- settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
3020
+ settleOrDefer(turnOutcome(session, "refusal"));
2753
3021
  break;
2754
3022
  }
2755
3023
  // A priority:'now' steer can make the SDK terminate the
@@ -2760,7 +3028,7 @@ export class ClaudeAcpAgent {
2760
3028
  if (message.is_error &&
2761
3029
  isSteering(session.activeTurn) &&
2762
3030
  session.activeTurn.steeredEchoes.size > 0) {
2763
- settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
3031
+ settleOrDefer(turnOutcome(session, "end_turn"));
2764
3032
  break;
2765
3033
  }
2766
3034
  if (!message.is_error && lastAssistantModel !== null) {
@@ -2803,7 +3071,7 @@ export class ClaudeAcpAgent {
2803
3071
  // prose can be injected into the feed.)
2804
3072
  if (session.activeTurn?.isLocalOnlyCommand ||
2805
3073
  (!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
2806
- for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
3074
+ for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
2807
3075
  await sendUpdate(notification);
2808
3076
  }
2809
3077
  }
@@ -2872,7 +3140,7 @@ export class ClaudeAcpAgent {
2872
3140
  // idle/abort path. settleActive is idempotent, so a duplicate
2873
3141
  // idle is a no-op.
2874
3142
  if (!session.cancelled) {
2875
- settleOrDefer({ stopReason, usage: sessionUsage(session) });
3143
+ settleOrDefer(turnOutcome(session, stopReason));
2876
3144
  }
2877
3145
  }
2878
3146
  finally {
@@ -2975,6 +3243,7 @@ export class ClaudeAcpAgent {
2975
3243
  const nextUsage = totalTokens(lastAssistantUsage);
2976
3244
  if (nextUsage !== lastAssistantTotalUsage) {
2977
3245
  lastAssistantTotalUsage = nextUsage;
3246
+ session.contextUsedTokens = nextUsage;
2978
3247
  await sendUpdate({
2979
3248
  sessionId: params.sessionId,
2980
3249
  update: {
@@ -3046,7 +3315,7 @@ export class ClaudeAcpAgent {
3046
3315
  session.owedTrailingIdles++;
3047
3316
  // Before activateTurn resets the accumulator, so the
3048
3317
  // usage still belongs to the cancelled turn.
3049
- settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
3318
+ settleActive(turnOutcome(session, "cancelled"));
3050
3319
  }
3051
3320
  else if (isHeldOpen(session.activeTurn)) {
3052
3321
  // A turn held open for its background subagents (see
@@ -3075,7 +3344,7 @@ export class ClaudeAcpAgent {
3075
3344
  settleActive(session.activeTurn.steeredSettle);
3076
3345
  }
3077
3346
  else {
3078
- settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
3347
+ settleActive(turnOutcome(session, "end_turn"));
3079
3348
  }
3080
3349
  }
3081
3350
  // Unlike the no-result teardown lanes, this hand-off must
@@ -3111,6 +3380,7 @@ export class ClaudeAcpAgent {
3111
3380
  if (message.type === "assistant" && message.parent_tool_use_id === null) {
3112
3381
  lastAssistantUsage = snapshotFromUsage(message.message.usage);
3113
3382
  lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
3383
+ session.contextUsedTokens = lastAssistantTotalUsage;
3114
3384
  lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
3115
3385
  if (message.error || lastAssistantWasUsageLimit) {
3116
3386
  lastAssistantFailureTitle = assistantMessageText(message.message);
@@ -3141,7 +3411,7 @@ export class ClaudeAcpAgent {
3141
3411
  message.message.content.includes("<local-command-stdout>")) {
3142
3412
  const stripped = stripLocalCommandMetadata(message.message.content);
3143
3413
  if (typeof stripped === "string") {
3144
- for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
3414
+ for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
3145
3415
  clientCapabilities: this.clientCapabilities,
3146
3416
  parentToolUseId: message.parent_tool_use_id,
3147
3417
  cwd: session.cwd,
@@ -3251,17 +3521,22 @@ export class ClaudeAcpAgent {
3251
3521
  }
3252
3522
  else if (message.type === "assistant" &&
3253
3523
  !(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
3254
- // Legacy clients don't understand nested transcripts. Keep the
3255
- // historical behavior for them: subagent text/thinking remains
3256
- // internal to the tool call instead of leaking into the top-level
3257
- // feed. Capable clients opt into the branch above unchanged, with
3258
- // `parentToolUseId` stamped by toAcpNotifications.
3524
+ // Legacy clients keep the flattened tool-call representation,
3525
+ // but nested text/thinking stays internal unless explicitly
3526
+ // requested through the historical transcript extension.
3259
3527
  content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
3260
3528
  }
3261
3529
  else {
3262
3530
  content = message.message.content;
3263
3531
  }
3264
- for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
3532
+ const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
3533
+ let backgroundBashTask;
3534
+ if (message.type === "user") {
3535
+ backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
3536
+ if (backgroundBashTask)
3537
+ await asyncTasks.taskBackgrounded(backgroundBashTask);
3538
+ }
3539
+ for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
3265
3540
  clientCapabilities: this.clientCapabilities,
3266
3541
  parentToolUseId: message.parent_tool_use_id,
3267
3542
  cwd: session.cwd,
@@ -3279,7 +3554,7 @@ export class ClaudeAcpAgent {
3279
3554
  // filtered out of `content` above; blocks that do pass through
3280
3555
  // (e.g. a subagent image) carry the stamped parentToolUseId
3281
3556
  // meta and are excluded there.
3282
- await sendUpdate(notification);
3557
+ await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundBashTask, asyncTasks.enabled));
3283
3558
  }
3284
3559
  break;
3285
3560
  }
@@ -3303,6 +3578,11 @@ export class ClaudeAcpAgent {
3303
3578
  if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
3304
3579
  break;
3305
3580
  }
3581
+ const subagentParentToolUseId = message.parent_tool_use_id
3582
+ ? [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id)
3583
+ ? message.parent_tool_use_id
3584
+ : undefined
3585
+ : undefined;
3306
3586
  await sendUpdate({
3307
3587
  sessionId: message.session_id,
3308
3588
  update: {
@@ -3312,6 +3592,9 @@ export class ClaudeAcpAgent {
3312
3592
  _meta: {
3313
3593
  claudeCode: {
3314
3594
  toolName: message.tool_name,
3595
+ ...(subagentParentToolUseId
3596
+ ? { parentToolUseId: subagentParentToolUseId }
3597
+ : {}),
3315
3598
  toolResponse: {
3316
3599
  elapsedTimeSeconds: message.elapsed_time_seconds,
3317
3600
  // For Agent/Task calls: the subagent's type, and — when
@@ -3351,8 +3634,17 @@ export class ClaudeAcpAgent {
3351
3634
  // and task store are independent of the previous transcript.
3352
3635
  // Clear both the in-memory snapshot and the client's visible plan
3353
3636
  // before any follow-up prompt can republish stale tasks.
3637
+ await finishLifecycle("failed", "failed", "during conversation reset");
3638
+ subagents.clear();
3639
+ asyncTasks.clear();
3640
+ session.eagerToolCallSessions?.clear();
3641
+ clearHookCallbacks(params.sessionId);
3354
3642
  session.taskState.clear();
3355
3643
  await this.publishTaskPlan(params.sessionId, session.taskState);
3644
+ // A reset mounts a fresh transcript (`new_conversation_id`), so our
3645
+ // cached title no longer describes the session: drop it and
3646
+ // re-evaluate at the next turn-end.
3647
+ session.titles.reset();
3356
3648
  break;
3357
3649
  }
3358
3650
  case "tool_use_summary":
@@ -3383,6 +3675,7 @@ export class ClaudeAcpAgent {
3383
3675
  message.includes("process exited with") ||
3384
3676
  message.includes("process terminated by signal") ||
3385
3677
  message.includes("Failed to write to process stdin"));
3678
+ await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "after stream error");
3386
3679
  if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
3387
3680
  if (!isHeldOpen(session.activeTurn)) {
3388
3681
  await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
@@ -3405,6 +3698,10 @@ export class ClaudeAcpAgent {
3405
3698
  this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
3406
3699
  failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
3407
3700
  this.closeQueryStream(session);
3701
+ session.eagerToolCallSessions?.clear();
3702
+ clearHookCallbacks(params.sessionId);
3703
+ session.nativeSubagentRuntime?.clear();
3704
+ session.asyncTaskRuntime?.clear();
3408
3705
  delete this.sessions[params.sessionId];
3409
3706
  }
3410
3707
  else {
@@ -3441,18 +3738,34 @@ export class ClaudeAcpAgent {
3441
3738
  }
3442
3739
  }
3443
3740
  async cancel(params) {
3741
+ this.exitPlan.cancel(params.sessionId);
3444
3742
  const session = this.sessions[params.sessionId];
3445
3743
  if (!session) {
3446
3744
  return;
3447
3745
  }
3746
+ session.cancelled = true;
3747
+ session.pendingExitPlanModeInterruption = undefined;
3748
+ session.pendingExitPlanContextReset = undefined;
3448
3749
  // The stream already ended (see closeQueryStream): every in-flight turn was
3449
3750
  // settled when it closed, and there is no live query to interrupt. Calling
3450
3751
  // query.interrupt() on a finished iterator could reject and surface from
3451
3752
  // this fire-and-forget notification, so there is nothing to do here.
3452
3753
  if (session.queryClosed) {
3754
+ session.eagerToolCallSessions?.clear();
3755
+ clearHookCallbacks(params.sessionId);
3453
3756
  return;
3454
3757
  }
3455
- session.cancelled = true;
3758
+ try {
3759
+ await session.nativeSubagentRuntime?.finishAll("cancelled", session.nativeSubagentDeliver ??
3760
+ (async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification))));
3761
+ }
3762
+ catch (error) {
3763
+ this.logger.error(`Session ${params.sessionId}: failed to publish cancelled subagent state`, error);
3764
+ }
3765
+ finally {
3766
+ session.eagerToolCallSessions?.clear();
3767
+ clearHookCallbacks(params.sessionId);
3768
+ }
3456
3769
  // A priority steer may still be queued in the SDK when cancellation
3457
3770
  // settles its owning turn. Its later echo matches no live turn, and its
3458
3771
  // result must be skipped rather than promoted onto the next prompt.
@@ -3496,7 +3809,9 @@ export class ClaudeAcpAgent {
3496
3809
  // never see lifecycle frames, so commandStarted/commandFinished stay
3497
3810
  // unset and every turn takes the plain-seed path below).
3498
3811
  for (const turn of orphanedTurns) {
3499
- if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
3812
+ if (turn.commandFinished === "completed" ||
3813
+ turn.commandFinished === "discarded" ||
3814
+ turn.commandFinished === "refused") {
3500
3815
  // The command already finished SDK-side and its terminal frame was
3501
3816
  // consumed while the turn sat queued — nothing is left to skip, and
3502
3817
  // a seeded entry would never drain.
@@ -3525,6 +3840,24 @@ export class ClaudeAcpAgent {
3525
3840
  }
3526
3841
  session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
3527
3842
  }
3843
+ const diagnosticTurns = orphanedTurns.filter((turn) => {
3844
+ if (turn.commandFinished === "completed" ||
3845
+ turn.commandFinished === "discarded" ||
3846
+ turn.commandFinished === "refused") {
3847
+ return false;
3848
+ }
3849
+ if (turn.commandStarted && turn.commandResultSeen)
3850
+ return false;
3851
+ if (turn.commandFinished === "cancelled" && !turn.commandStarted)
3852
+ return false;
3853
+ return true;
3854
+ });
3855
+ if (diagnosticTurns.length > 0) {
3856
+ session.pendingEmptyInterruptionDiagnosticCommands ??= new Set();
3857
+ for (const turn of diagnosticTurns) {
3858
+ session.pendingEmptyInterruptionDiagnosticCommands.add(turn.promptUuid);
3859
+ }
3860
+ }
3528
3861
  // A deferred active turn (see Turn.deferredSettle) already has its
3529
3862
  // result — it is only held open for its background subagents, which the
3530
3863
  // interrupt below tears down. Settle it "cancelled" NOW: during the hold
@@ -3571,7 +3904,14 @@ export class ClaudeAcpAgent {
3571
3904
  if (session.lastSessionState !== "idle") {
3572
3905
  session.owedTrailingIdles++;
3573
3906
  }
3574
- active.resolve({ stopReason: "cancelled", usage: active.deferredSettle.usage });
3907
+ // Carries the held outcome's `_meta` too: a deferred outcome's only
3908
+ // metadata is its quota breakdown, the counterpart of the usage taken
3909
+ // from it here.
3910
+ active.resolve({
3911
+ stopReason: "cancelled",
3912
+ usage: active.deferredSettle.usage,
3913
+ ...(active.deferredSettle._meta ? { _meta: active.deferredSettle._meta } : {}),
3914
+ });
3575
3915
  }
3576
3916
  }
3577
3917
  // A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
@@ -3622,6 +3962,11 @@ export class ClaudeAcpAgent {
3622
3962
  // activation-time self-heal.
3623
3963
  if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
3624
3964
  const stillQueued = new Set(receipt.still_queued);
3965
+ const droppedTurns = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid));
3966
+ const droppedCount = droppedTurns.length;
3967
+ for (const turn of droppedTurns) {
3968
+ session.pendingEmptyInterruptionDiagnosticCommands?.delete(turn.promptUuid);
3969
+ }
3625
3970
  if (lifecycleLane) {
3626
3971
  // Lifecycle lane: forget dropped orphans by uuid. Only entries still
3627
3972
  // "pending" — an orphan absent from `still_queued` because it was
@@ -3640,9 +3985,8 @@ export class ClaudeAcpAgent {
3640
3985
  }
3641
3986
  }
3642
3987
  else {
3643
- const dropped = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid)).length;
3644
- if (dropped > 0) {
3645
- session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
3988
+ if (droppedCount > 0) {
3989
+ session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - droppedCount);
3646
3990
  }
3647
3991
  }
3648
3992
  }
@@ -3683,7 +4027,12 @@ export class ClaudeAcpAgent {
3683
4027
  if (!session) {
3684
4028
  return;
3685
4029
  }
3686
- await this.cancel({ sessionId });
4030
+ try {
4031
+ await this.cancel({ sessionId });
4032
+ }
4033
+ catch (error) {
4034
+ this.logger.error(`Session ${sessionId}: cancellation failed during teardown`, error);
4035
+ }
3687
4036
  // cancel() arms the force-cancel floor and interrupts gracefully, but a
3688
4037
  // wedged consumer only wakes when `cancelController` aborts — closeQueryStream
3689
4038
  // below doesn't touch it. Since we're tearing the session down anyway, wake
@@ -3699,6 +4048,10 @@ export class ClaudeAcpAgent {
3699
4048
  // here the client has asked us to close the session, so signalling abort is
3700
4049
  // appropriate; query.close() above has already torn the subprocess down.
3701
4050
  session.abortController.abort();
4051
+ session.eagerToolCallSessions?.clear();
4052
+ clearHookCallbacks(sessionId);
4053
+ session.nativeSubagentRuntime?.clear();
4054
+ session.asyncTaskRuntime?.clear();
3702
4055
  delete this.sessions[sessionId];
3703
4056
  }
3704
4057
  /** Tear down all active sessions. Called when the ACP connection closes. */
@@ -3722,19 +4075,7 @@ export class ClaudeAcpAgent {
3722
4075
  return {};
3723
4076
  }
3724
4077
  async setSessionMode(params) {
3725
- const session = this.sessions[params.sessionId];
3726
- if (!session) {
3727
- throw new Error("Session not found");
3728
- }
3729
- // The SDK query stream already ended (see closeQueryStream); the session is
3730
- // a husk and `query.setPermissionMode` below would act on a closed query.
3731
- // Fail with the same clear message prompt()/cancel() give for a dead stream.
3732
- if (session.queryClosed) {
3733
- throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
3734
- }
3735
- await this.applySessionMode(params.sessionId, params.modeId);
3736
- await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId);
3737
- return {};
4078
+ return this.sessionModes.setSessionMode(params);
3738
4079
  }
3739
4080
  async setSessionConfigOption(params) {
3740
4081
  const session = this.sessions[params.sessionId];
@@ -3804,15 +4145,10 @@ export class ClaudeAcpAgent {
3804
4145
  // Use the canonical option value so downstream code always receives the
3805
4146
  // model ID rather than the caller-supplied alias.
3806
4147
  const resolvedValue = validValue.value;
4148
+ let effectiveValue = resolvedValue;
3807
4149
  if (params.configId === MODE_CONFIG_ID) {
3808
- await this.applySessionMode(params.sessionId, resolvedValue);
3809
- await this.client.sessionUpdate({
3810
- sessionId: params.sessionId,
3811
- update: {
3812
- sessionUpdate: "current_mode_update",
3813
- currentModeId: resolvedValue,
3814
- },
3815
- });
4150
+ effectiveValue = await this.sessionModes.selectMode(params.sessionId, resolvedValue);
4151
+ await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
3816
4152
  }
3817
4153
  else if (params.configId === MODEL_CONFIG_ID) {
3818
4154
  await this.sessions[params.sessionId].query.setModel(resolvedValue);
@@ -3820,44 +4156,9 @@ export class ClaudeAcpAgent {
3820
4156
  // Effort SDK sync is handled inside applyConfigOptionValue so that direct
3821
4157
  // effort changes and effort changes induced by a model switch go through
3822
4158
  // the same path.
3823
- await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
4159
+ await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
3824
4160
  return { configOptions: session.configOptions };
3825
4161
  }
3826
- async applySessionMode(sessionId, modeId) {
3827
- switch (modeId) {
3828
- case "auto":
3829
- case "default":
3830
- case "acceptEdits":
3831
- case "bypassPermissions":
3832
- case "dontAsk":
3833
- case "plan":
3834
- break;
3835
- default:
3836
- throw new Error("Invalid Mode");
3837
- }
3838
- const session = this.sessions[sessionId];
3839
- if (!session) {
3840
- throw new Error("Session not found");
3841
- }
3842
- if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
3843
- throw new Error(`Mode ${modeId} is not available in this session`);
3844
- }
3845
- try {
3846
- await session.query.setPermissionMode(modeId);
3847
- }
3848
- catch (error) {
3849
- if (error instanceof Error) {
3850
- if (!error.message) {
3851
- error.message = "Invalid Mode";
3852
- }
3853
- throw error;
3854
- }
3855
- else {
3856
- // eslint-disable-next-line preserve-caught-error
3857
- throw new Error("Invalid Mode");
3858
- }
3859
- }
3860
- }
3861
4162
  async replaySessionHistory(sessionId) {
3862
4163
  const toolUseCache = {};
3863
4164
  const messages = await getSessionMessages(sessionId);
@@ -3883,6 +4184,101 @@ export class ClaudeAcpAgent {
3883
4184
  // ends an incomplete audit lane.
3884
4185
  let replayingFileChangeAudit = false;
3885
4186
  const replayFileChangeAuditToolUseIds = new Set();
4187
+ const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
4188
+ const replayTerminalStates = new Map();
4189
+ const replayChildren = new Map();
4190
+ if (nativeReplayEnabled) {
4191
+ for (const message of messages) {
4192
+ const content = message.message
4193
+ ?.content;
4194
+ if (!Array.isArray(content))
4195
+ continue;
4196
+ const ownerToolUseId = parentToolUseIdOf(message);
4197
+ for (const block of content) {
4198
+ if (typeof block === "object" &&
4199
+ block !== null &&
4200
+ "type" in block &&
4201
+ (block.type === "tool_result" || block.type === "mcp_tool_result") &&
4202
+ "tool_use_id" in block &&
4203
+ typeof block.tool_use_id === "string") {
4204
+ replayTerminalStates.set(block.tool_use_id, replaySubagentTerminalState(block));
4205
+ }
4206
+ if (typeof block !== "object" ||
4207
+ block === null ||
4208
+ !("type" in block) ||
4209
+ !["tool_use", "server_tool_use", "mcp_tool_use"].includes(String(block.type)) ||
4210
+ !("id" in block) ||
4211
+ typeof block.id !== "string" ||
4212
+ !("name" in block) ||
4213
+ !isNativeSubagentControlTool(block.name)) {
4214
+ continue;
4215
+ }
4216
+ const input = "input" in block && typeof block.input === "object" && block.input !== null
4217
+ ? block.input
4218
+ : {};
4219
+ const task = [input.prompt, input.description]
4220
+ .find((value) => typeof value === "string" && value.trim().length > 0)
4221
+ ?.trim() ?? "Delegated task restored from session history";
4222
+ const name = [input.name, input.description, input.subagent_type]
4223
+ .find((value) => typeof value === "string" && value.trim().length > 0)
4224
+ ?.trim() ?? "Restored agent";
4225
+ replayChildren.set(block.id, {
4226
+ sessionId: `${sessionId}:replay-subagent:${block.id}`,
4227
+ ...(ownerToolUseId ? { parentToolUseId: ownerToolUseId } : {}),
4228
+ name,
4229
+ task,
4230
+ reconstructable: true,
4231
+ announced: false,
4232
+ terminalState: replayTerminalStates.get(block.id),
4233
+ });
4234
+ }
4235
+ }
4236
+ for (const [toolUseId, terminalState] of replayTerminalStates) {
4237
+ const child = replayChildren.get(toolUseId);
4238
+ if (child)
4239
+ child.terminalState = terminalState;
4240
+ }
4241
+ }
4242
+ const announceReplayChild = async (parentToolUseId, ancestors = new Set()) => {
4243
+ let child = replayChildren.get(parentToolUseId);
4244
+ if (!child) {
4245
+ child = {
4246
+ sessionId: `${sessionId}:replay-subagent:${parentToolUseId}`,
4247
+ name: "Disconnected agent",
4248
+ task: "Subagent restored without persisted launch metadata",
4249
+ reconstructable: false,
4250
+ announced: false,
4251
+ };
4252
+ replayChildren.set(parentToolUseId, child);
4253
+ }
4254
+ if (ancestors.has(parentToolUseId)) {
4255
+ child.reconstructable = false;
4256
+ child.terminalState = undefined;
4257
+ child.parentToolUseId = undefined;
4258
+ }
4259
+ if (child.parentToolUseId) {
4260
+ const nextAncestors = new Set(ancestors);
4261
+ nextAncestors.add(parentToolUseId);
4262
+ await announceReplayChild(child.parentToolUseId, nextAncestors);
4263
+ }
4264
+ if (!child.announced) {
4265
+ const parentSessionId = child.parentToolUseId
4266
+ ? (replayChildren.get(child.parentToolUseId)?.sessionId ?? sessionId)
4267
+ : sessionId;
4268
+ await this.client.sessionUpdate(asSdkSessionNotification({
4269
+ sessionId: parentSessionId,
4270
+ update: {
4271
+ sessionUpdate: "subagent_spawned",
4272
+ subagentSessionId: child.sessionId,
4273
+ name: child.name,
4274
+ task: child.task,
4275
+ capabilities: {},
4276
+ },
4277
+ }));
4278
+ child.announced = true;
4279
+ }
4280
+ return child.sessionId;
4281
+ };
3886
4282
  for (const message of messages) {
3887
4283
  if (message.type === "user" &&
3888
4284
  message.parent_tool_use_id === null &&
@@ -3923,7 +4319,13 @@ export class ClaudeAcpAgent {
3923
4319
  // @ts-expect-error - untyped in SDK but we handle all of these
3924
4320
  let content = message.message.content;
3925
4321
  const parentToolUseId = parentToolUseIdOf(message);
3926
- if (message.type === "assistant" && parentToolUseId && !forwardSubagentText) {
4322
+ const replayTargetSessionId = nativeReplayEnabled && parentToolUseId
4323
+ ? await announceReplayChild(parentToolUseId)
4324
+ : sessionId;
4325
+ if (message.type === "assistant" &&
4326
+ parentToolUseId &&
4327
+ !nativeReplayEnabled &&
4328
+ !forwardSubagentText) {
3927
4329
  content = stripSubagentTextAndThinking(content);
3928
4330
  }
3929
4331
  // @ts-expect-error - untyped in SDK but we handle all of these
@@ -3995,7 +4397,37 @@ export class ClaudeAcpAgent {
3995
4397
  messageId: replayMessageId,
3996
4398
  parentToolUseId,
3997
4399
  })) {
3998
- await this.client.sessionUpdate(notification);
4400
+ const toolName = notification.update._meta?.claudeCode?.toolName;
4401
+ if (nativeReplayEnabled &&
4402
+ (notification.update.sessionUpdate === "tool_call" ||
4403
+ notification.update.sessionUpdate === "tool_call_update") &&
4404
+ isNativeSubagentControlTool(toolName)) {
4405
+ continue;
4406
+ }
4407
+ await this.client.sessionUpdate({ ...notification, sessionId: replayTargetSessionId });
4408
+ }
4409
+ }
4410
+ if (nativeReplayEnabled) {
4411
+ // Claude history persists sidechain messages and Agent/Task tool uses,
4412
+ // but not task_started/task_updated lifecycle frames. Recover terminal
4413
+ // state from the launch tool_result. Missing results and malformed or
4414
+ // orphan lineage use the draft protocol's deterministic disconnected state.
4415
+ for (const child of [...replayChildren.values()].reverse()) {
4416
+ if (!child.announced)
4417
+ continue;
4418
+ const parentSessionId = child.parentToolUseId
4419
+ ? (replayChildren.get(child.parentToolUseId)?.sessionId ?? sessionId)
4420
+ : sessionId;
4421
+ await this.client.sessionUpdate(asSdkSessionNotification({
4422
+ sessionId: parentSessionId,
4423
+ update: {
4424
+ sessionUpdate: "subagent_state_update",
4425
+ subagentSessionId: child.sessionId,
4426
+ state: child.reconstructable
4427
+ ? (child.terminalState ?? "disconnected")
4428
+ : "disconnected",
4429
+ },
4430
+ }));
3999
4431
  }
4000
4432
  }
4001
4433
  }
@@ -4007,23 +4439,45 @@ export class ClaudeAcpAgent {
4007
4439
  const response = await this.client.writeTextFile(params);
4008
4440
  return response;
4009
4441
  }
4442
+ /** Mark a client request as blocking on user input for exactly the lifetime
4443
+ * of its promise. Steering consults this session-local count synchronously,
4444
+ * so a message arriving while any permission/elicitation card is open uses
4445
+ * non-interrupting SDK delivery. */
4446
+ async withPendingUserInput(sessionId, request) {
4447
+ const session = this.sessions[sessionId];
4448
+ if (!session)
4449
+ return request();
4450
+ session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
4451
+ try {
4452
+ return await request();
4453
+ }
4454
+ finally {
4455
+ session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
4456
+ }
4457
+ }
4010
4458
  /** Forward a permission request to the client, wiring the tool call's
4011
4459
  * `signal` through as a `cancellationSignal`. When the turn is cancelled
4012
4460
  * while the client's prompt is still open the signal aborts, the SDK sends
4013
- * `$/cancel_request`, and the client settles the request (a `cancelled`
4014
- * outcome or a `requestCancelled` rejection). Either way we surface the same
4015
- * "Tool use aborted" the callers already expect, so a cancelled dialog no
4016
- * longer leaves the `await` hanging. */
4017
- async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
4461
+ * `$/cancel_request`, and our local abort race settles even if the client
4462
+ * ignores it. A `cancelled` outcome, request rejection, and local abort all
4463
+ * surface the same "Tool use aborted" the callers already expect. */
4464
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId = params.sessionId) {
4465
+ if (signal.aborted)
4466
+ throw new Error("Tool use aborted");
4018
4467
  // The SDK may invoke `canUseTool` (and therefore this permission request)
4019
4468
  // before the assistant message's tool_use block streams to us. Some ACP clients
4020
4469
  // expect the `tool_call` a permission request references to already exist,
4021
4470
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
4022
4471
  // later refines it with a `tool_call_update` rather than emitting a
4023
4472
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
4024
- await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
4473
+ await this.ensureToolCallEmitted(ownerSessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal, params.sessionId);
4474
+ if (signal.aborted)
4475
+ throw new Error("Tool use aborted");
4476
+ // Do not rely on every ACP client settling requestPermission after the
4477
+ // cancellation signal. The local race guarantees that Claude's tool call
4478
+ // is released even when an older or broken client ignores $/cancel_request.
4025
4479
  try {
4026
- return await this.client.requestPermission(params, signal);
4480
+ return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
4027
4481
  }
4028
4482
  catch (error) {
4029
4483
  if (signal.aborted) {
@@ -4044,7 +4498,7 @@ export class ClaudeAcpAgent {
4044
4498
  * are resolved at tool_result time instead (see `toAcpNotifications`).
4045
4499
  * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
4046
4500
  * that spawned it, matching the streamed path's `_meta`. */
4047
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
4501
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal, notificationSessionId = sessionId) {
4048
4502
  const session = this.sessions[sessionId];
4049
4503
  if (!session) {
4050
4504
  return;
@@ -4053,6 +4507,7 @@ export class ClaudeAcpAgent {
4053
4507
  return;
4054
4508
  }
4055
4509
  session.emittedToolCalls.add(toolCallId);
4510
+ (session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
4056
4511
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
4057
4512
  const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
4058
4513
  if (parentToolUseId) {
@@ -4064,10 +4519,21 @@ export class ClaudeAcpAgent {
4064
4519
  },
4065
4520
  };
4066
4521
  }
4067
- await this.client.sessionUpdate({ sessionId, update });
4522
+ try {
4523
+ const emission = this.client.sessionUpdate({ sessionId: notificationSessionId, update });
4524
+ await (signal ? raceWithAbort(emission, signal) : emission);
4525
+ }
4526
+ catch (error) {
4527
+ // The set is also the de-duplication guard for the later streamed
4528
+ // tool_use. Keep it truthful: if emission failed, that path must still
4529
+ // be allowed to publish the tool call instead of refining a phantom one.
4530
+ session.emittedToolCalls.delete(toolCallId);
4531
+ session.eagerToolCallSessions?.delete(toolCallId);
4532
+ throw error;
4533
+ }
4068
4534
  }
4069
4535
  canUseTool(sessionId) {
4070
- return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
4536
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, }) => {
4071
4537
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
4072
4538
  const session = this.sessions[sessionId];
4073
4539
  if (!session) {
@@ -4103,6 +4569,17 @@ export class ClaudeAcpAgent {
4103
4569
  const parentToolUseId = agentID
4104
4570
  ? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
4105
4571
  : undefined;
4572
+ const permissionSessionId = clientSupportsSubagents(this.clientCapabilities) && agentID
4573
+ ? (() => {
4574
+ const child = session.nativeSubagentsByTaskId?.get(agentID);
4575
+ // A request must never target a child session before its
4576
+ // subagent_spawned notification. In the rare SDK ordering where
4577
+ // canUseTool beats the spawning Agent/Task frame, keep the
4578
+ // permission on the root session; the later frame will announce
4579
+ // the child with correct nested lineage.
4580
+ return child?.announced ? child.sessionId : sessionId;
4581
+ })()
4582
+ : sessionId;
4106
4583
  if (agentID && !parentToolUseId) {
4107
4584
  // The attribution rests on an undocumented SDK invariant
4108
4585
  // (task_started.task_id === canUseTool's agentID for subagent tasks;
@@ -4120,152 +4597,81 @@ export class ClaudeAcpAgent {
4120
4597
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
4121
4598
  // Like permission requests, the elicitation references this toolUseID, so
4122
4599
  // make sure the tool_call has surfaced to the client before we send it.
4123
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
4124
- return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
4600
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal, permissionSessionId);
4601
+ return this.handleAskUserQuestion(permissionSessionId, toolInput, toolUseID, signal);
4125
4602
  }
4126
- if (toolName === "ExitPlanMode") {
4127
- const optionsAll = [
4128
- { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
4129
- {
4130
- kind: "allow_always",
4131
- name: "Yes, and auto-accept edits",
4132
- optionId: "acceptEdits",
4133
- },
4134
- { kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
4135
- { kind: "reject_once", name: "No, keep planning", optionId: "plan" },
4136
- ];
4137
- if (ALLOW_BYPASS) {
4138
- optionsAll.unshift({
4139
- kind: "allow_always",
4140
- name: "Yes, and bypass permissions",
4141
- optionId: "bypassPermissions",
4142
- });
4143
- }
4144
- // Filter against the session's currently-advertised modes so we never
4145
- // present options the active model can't honor (e.g. `auto` on Haiku).
4146
- // `bypassPermissions` is already covered by `availableModes` via
4147
- // `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
4148
- // "keep planning" reject path; it's always present in `availableModes`.
4149
- const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
4150
- const response = await this.requestPermissionFromClient({
4151
- options,
4152
- sessionId,
4153
- toolCall: {
4154
- toolCallId: toolUseID,
4155
- rawInput: toolInput,
4156
- ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
4157
- // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
4158
- // so clients can rely on one shape everywhere.
4159
- ...(parentToolUseId
4160
- ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
4161
- : {}),
4162
- },
4163
- }, toolName, signal, parentToolUseId);
4164
- if (signal.aborted || response.outcome?.outcome === "cancelled") {
4165
- throw new Error("Tool use aborted");
4166
- }
4167
- const selectedMode = response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
4168
- const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
4169
- if (selectedModeWasOffered &&
4170
- (selectedMode === "default" ||
4171
- selectedMode === "acceptEdits" ||
4172
- selectedMode === "auto" ||
4173
- selectedMode === "bypassPermissions")) {
4174
- await this.client.sessionUpdate({
4175
- sessionId,
4176
- update: {
4177
- sessionUpdate: "current_mode_update",
4178
- currentModeId: selectedMode,
4179
- },
4180
- });
4181
- await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode);
4182
- return {
4183
- behavior: "allow",
4184
- updatedInput: toolInput,
4185
- updatedPermissions: suggestions ?? [
4186
- { type: "setMode", mode: selectedMode, destination: "session" },
4187
- ],
4188
- };
4189
- }
4190
- else {
4191
- return {
4192
- behavior: "deny",
4193
- message: "User rejected request to exit plan mode.",
4194
- };
4195
- }
4196
- }
4197
- // In bypass mode the CLI skips permission checks itself; the asks that
4198
- // still reach canUseTool are the ones it insists on prompting for even
4199
- // under --dangerously-skip-permissions. Keep auto-allowing those —
4200
- // bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
4201
- // user explicitly configured a permissions.ask rule for this tool, and
4202
- // the SDK's guidance is that hosts running auto-approval must treat such
4203
- // asks as a human prompt. Fall through to the normal request below.
4204
- if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
4205
- return {
4206
- behavior: "allow",
4207
- updatedInput: toolInput,
4208
- updatedPermissions: suggestions ?? [
4209
- { type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
4210
- ],
4603
+ // Do not auto-allow here based on the session's advertised mode. Claude
4604
+ // Code applies bypassPermissions before invoking canUseTool; a request
4605
+ // that still reaches this callback is deliberately bypass-immune (for
4606
+ // example a safety check, a tool requiring user interaction, or an
4607
+ // explicit ask rule). Re-applying bypass in the host would erase that
4608
+ // provider safety decision.
4609
+ const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, matchedAskRule !== undefined);
4610
+ const presentation = buildClaudePermissionPresentation({
4611
+ toolName,
4612
+ input: toolInput,
4613
+ toolUseID,
4614
+ cwd: session.cwd,
4615
+ supportsTerminalOutput,
4616
+ blockedPath,
4617
+ title,
4618
+ displayName,
4619
+ description,
4620
+ decisionReason,
4621
+ });
4622
+ if (parentToolUseId) {
4623
+ presentation.toolCall._meta = {
4624
+ claudeCode: { toolName, parentToolUseId },
4211
4625
  };
4212
4626
  }
4627
+ const permissionOptions = buildClaudePermissionOptions({
4628
+ toolName,
4629
+ displayName,
4630
+ input: toolInput,
4631
+ cwd: session.cwd,
4632
+ durableChangeSet,
4633
+ allowPersistentOptions: matchedAskRule === undefined,
4634
+ availableModes: this.sessionModes.availableModeIds(session.modes),
4635
+ contextUsedPercent: session.contextUsedTokens === undefined || session.contextWindowSize <= 0
4636
+ ? undefined
4637
+ : Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
4638
+ });
4213
4639
  const response = await this.requestPermissionFromClient({
4214
- options: [
4215
- { kind: "reject_once", name: "Deny", optionId: "reject" },
4216
- { kind: "allow_once", name: "Allow Once", optionId: "allow" },
4217
- {
4218
- kind: "allow_always",
4219
- name: "Always Allow",
4220
- optionId: "allow_always",
4221
- _meta: {
4222
- permission: permissionMetadataForAlwaysAllow(suggestions, toolName),
4223
- },
4224
- },
4225
- ],
4226
- sessionId,
4227
- toolCall: {
4228
- toolCallId: toolUseID,
4229
- rawInput: toolInput,
4230
- ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
4231
- // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
4232
- // so clients can rely on one shape everywhere.
4233
- ...(parentToolUseId
4234
- ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
4235
- : {}),
4236
- },
4237
- }, toolName, signal, parentToolUseId);
4238
- if (signal.aborted || response.outcome?.outcome === "cancelled") {
4640
+ ...presentation,
4641
+ options: permissionOptions,
4642
+ sessionId: permissionSessionId,
4643
+ }, toolName, signal, parentToolUseId, sessionId);
4644
+ if (signal.aborted)
4239
4645
  throw new Error("Tool use aborted");
4646
+ const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
4647
+ let permissionResult = decodedPermission.permissionResult;
4648
+ const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
4649
+ permissionResult = autoFallback.permissionResult;
4650
+ if (autoFallback.fallbackApplied) {
4651
+ await this.sessionModes.publishFallbackWarning(sessionId, session);
4240
4652
  }
4241
- if (response.outcome?.outcome === "selected" &&
4242
- (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")) {
4243
- // If Claude Code has suggestions, it will update their settings already
4244
- if (response.outcome.optionId === "allow_always") {
4245
- return {
4246
- behavior: "allow",
4247
- updatedInput: toolInput,
4248
- updatedPermissions: suggestions ?? [
4249
- {
4250
- type: "addRules",
4251
- rules: [{ toolName }],
4252
- behavior: "allow",
4253
- destination: "session",
4254
- },
4255
- ],
4256
- };
4257
- }
4258
- return {
4259
- behavior: "allow",
4260
- updatedInput: toolInput,
4653
+ const clearContextMode = decodedPermission.contextResetMode
4654
+ ? this.sessionModes.effectiveMode(session, decodedPermission.contextResetMode)
4655
+ : undefined;
4656
+ if (toolName === "ExitPlanMode" && clearContextMode) {
4657
+ const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : "";
4658
+ if (!plan)
4659
+ throw new Error("ExitPlanMode clear-context selection requires a plan");
4660
+ session.pendingExitPlanContextReset = {
4661
+ toolUseId: toolUseID,
4662
+ plan,
4663
+ mode: clearContextMode,
4261
4664
  };
4262
4665
  }
4263
- else {
4264
- return {
4265
- behavior: "deny",
4266
- message: "User refused permission to run tool",
4666
+ if (toolName === "ExitPlanMode" &&
4667
+ permissionResult.behavior === "deny" &&
4668
+ permissionResult.interrupt === true) {
4669
+ session.pendingExitPlanModeInterruption = {
4670
+ toolUseId: toolUseID,
4671
+ toolResultSeen: false,
4267
4672
  };
4268
4673
  }
4674
+ return permissionResult;
4269
4675
  };
4270
4676
  }
4271
4677
  /**
@@ -4284,7 +4690,7 @@ export class ClaudeAcpAgent {
4284
4690
  return { action: "decline" };
4285
4691
  }
4286
4692
  try {
4287
- const response = await this.client.unstable_createElicitation(createRequest, signal);
4693
+ const response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
4288
4694
  if (signal.aborted) {
4289
4695
  return { action: "cancel" };
4290
4696
  }
@@ -4314,7 +4720,7 @@ export class ClaudeAcpAgent {
4314
4720
  const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
4315
4721
  let response;
4316
4722
  try {
4317
- response = await this.client.unstable_createElicitation(createRequest, signal);
4723
+ response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
4318
4724
  }
4319
4725
  catch (error) {
4320
4726
  // A cancellation we requested (signal aborted) settles as an aborted tool
@@ -4354,7 +4760,7 @@ export class ClaudeAcpAgent {
4354
4760
  }
4355
4761
  let response;
4356
4762
  try {
4357
- response = await this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal);
4763
+ response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal));
4358
4764
  }
4359
4765
  catch (error) {
4360
4766
  // A cancellation we requested (signal aborted) is expected teardown;
@@ -4400,12 +4806,11 @@ export class ClaudeAcpAgent {
4400
4806
  }
4401
4807
  async applyConfigOptionValue(sessionId, session, configId, value) {
4402
4808
  if (configId === MODE_CONFIG_ID) {
4403
- session.modes = { ...session.modes, currentModeId: value };
4404
- session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
4809
+ this.sessionModes.syncConfig(session, value);
4405
4810
  }
4406
4811
  else if (configId === MODEL_CONFIG_ID) {
4407
- // `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
4408
- // modes below; its `displayName`/`description` also let us infer the
4812
+ // `ModelInfo.supportsAutoMode` is the canonical SDK signal for applying
4813
+ // the Auto fallback below; its `displayName`/`description` also let us infer the
4409
4814
  // context window for semantic aliases (e.g. `default`) whose ID alone
4410
4815
  // carries no "1m" token.
4411
4816
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
@@ -4427,41 +4832,7 @@ export class ClaudeAcpAgent {
4427
4832
  session.contextWindowAuthoritative = seeded.authoritative;
4428
4833
  }
4429
4834
  session.models = { ...session.models, currentModelId: value };
4430
- // Recompute availableModes for the new model and clamp the current
4431
- // mode if the SDK no longer offers it (today: "auto" on Haiku). An
4432
- // unknown model (an SDK-initiated refusal fallback to a model outside
4433
- // the user's `availableModels` allowlist — user-driven switches are
4434
- // validated against the options first) tells us nothing about its
4435
- // capabilities, so keep the current modes rather than spuriously
4436
- // downgrading (e.g. kicking the user out of "auto" for a model that
4437
- // does support it).
4438
- const newAvailableModes = newModelInfo
4439
- ? buildAvailableModes(newModelInfo)
4440
- : session.modes.availableModes;
4441
- // Capture BEFORE mutating session.modes so the log message reflects
4442
- // the invalidated mode rather than "default".
4443
- const previousModeId = session.modes.currentModeId;
4444
- let modeDowngraded = false;
4445
- if (!newAvailableModes.some((m) => m.id === previousModeId)) {
4446
- session.modes = {
4447
- availableModes: newAvailableModes,
4448
- currentModeId: "default",
4449
- };
4450
- try {
4451
- await session.query.setPermissionMode("default");
4452
- }
4453
- catch (err) {
4454
- // Failing the entire model switch over a bookkeeping sync error is
4455
- // worse UX than logging and continuing; the user explicitly asked
4456
- // to change models. The next setPermissionMode from the user will
4457
- // either succeed or surface a fresh error.
4458
- this.logger.error(`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, err);
4459
- }
4460
- modeDowngraded = true;
4461
- }
4462
- else {
4463
- session.modes = { ...session.modes, availableModes: newAvailableModes };
4464
- }
4835
+ const modeDowngraded = await this.sessionModes.reconcileForModel(session, newModelInfo);
4465
4836
  // `model_not_allowed` described the model we just left, so it must not
4466
4837
  // follow us onto the new one; the remaining reasons are account- or
4467
4838
  // environment-scoped and stay true across a switch. Either way the next
@@ -4496,13 +4867,7 @@ export class ClaudeAcpAgent {
4496
4867
  // still precedes the caller's config_option_update so order-sensitive
4497
4868
  // clients update currentModeId before re-rendering the option list.
4498
4869
  if (modeDowngraded) {
4499
- await this.client.sessionUpdate({
4500
- sessionId,
4501
- update: {
4502
- sessionUpdate: "current_mode_update",
4503
- currentModeId: "default",
4504
- },
4505
- });
4870
+ await this.sessionModes.publishFallbackState(sessionId, session);
4506
4871
  }
4507
4872
  }
4508
4873
  else if (configId === AGENT_CONFIG_ID) {
@@ -4703,7 +5068,10 @@ export class ClaudeAcpAgent {
4703
5068
  // We want to create a new session id unless it is resume,
4704
5069
  // but not resume + forkSession.
4705
5070
  let sessionId;
4706
- if (creationOpts.forkSession) {
5071
+ if (creationOpts.publicSessionId) {
5072
+ sessionId = creationOpts.publicSessionId;
5073
+ }
5074
+ else if (creationOpts.forkSession) {
4707
5075
  sessionId = randomUUID();
4708
5076
  }
4709
5077
  else if (creationOpts.resume) {
@@ -4762,10 +5130,12 @@ export class ClaudeAcpAgent {
4762
5130
  }
4763
5131
  }
4764
5132
  const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
5133
+ const initialPermissionMode = creationOpts.permissionMode ?? permissionMode;
4765
5134
  // Extract options from _meta if provided
4766
5135
  const sessionMeta = params._meta;
4767
5136
  const userProvidedOptions = sessionMeta?.claudeCode?.options;
4768
- const forwardSubagentText = supportsSubagentTranscript(this.clientCapabilities) ||
5137
+ const forwardSubagentText = clientSupportsSubagents(this.clientCapabilities) ||
5138
+ supportsSubagentTranscript(this.clientCapabilities) ||
4769
5139
  userProvidedOptions?.forwardSubagentText === true;
4770
5140
  // Configure thinking behavior from environment variable
4771
5141
  const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
@@ -4884,7 +5254,7 @@ export class ClaudeAcpAgent {
4884
5254
  // If we want bypassPermissions to be an option, we have to allow it here.
4885
5255
  // But it doesn't work in root mode, so we only activate it if it will work.
4886
5256
  allowDangerouslySkipPermissions: ALLOW_BYPASS,
4887
- permissionMode,
5257
+ permissionMode: initialPermissionMode,
4888
5258
  canUseTool: this.canUseTool(sessionId),
4889
5259
  // Forward MCP elicitation requests onto ACP elicitation. Only attached
4890
5260
  // when the client advertised support, so non-supporting clients keep the
@@ -4928,13 +5298,7 @@ export class ClaudeAcpAgent {
4928
5298
  hooks: [
4929
5299
  createPostToolUseHook({
4930
5300
  onEnterPlanMode: async () => {
4931
- await this.client.sessionUpdate({
4932
- sessionId,
4933
- update: {
4934
- sessionUpdate: "current_mode_update",
4935
- currentModeId: "plan",
4936
- },
4937
- });
5301
+ await this.sessionModes.publishCurrent(sessionId, "plan");
4938
5302
  await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
4939
5303
  },
4940
5304
  }),
@@ -4972,7 +5336,8 @@ export class ClaudeAcpAgent {
4972
5336
  },
4973
5337
  ],
4974
5338
  },
4975
- ...creationOpts,
5339
+ ...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
5340
+ ...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
4976
5341
  abortController,
4977
5342
  };
4978
5343
  // Prefer the official ACP `additionalDirectories` field. Fall back to the
@@ -4982,7 +5347,7 @@ export class ClaudeAcpAgent {
4982
5347
  options.additionalDirectories = additionalDirectories;
4983
5348
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
4984
5349
  // Set our own session id if not resuming an existing session.
4985
- options.sessionId = sessionId;
5350
+ options.sessionId = creationOpts.publicSessionId ? randomUUID() : sessionId;
4986
5351
  }
4987
5352
  // Handle abort controller from meta options
4988
5353
  if (abortController?.signal.aborted) {
@@ -5021,8 +5386,8 @@ export class ClaudeAcpAgent {
5021
5386
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
5022
5387
  : initializationResult.models;
5023
5388
  const { modelState: models, resumedContextWindow } = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
5024
- // Gate `auto` (and future model-specific modes) on the resolved model's
5025
- // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
5389
+ // Resolve the current model's capabilities separately from the stable
5390
+ // permission-mode catalog advertised to ACP clients.
5026
5391
  // A resumed session can be running a model outside the `availableModels`
5027
5392
  // allowlist (currentModelId is then the verbatim live id, see
5028
5393
  // `matchResumedModel`); its capabilities are still known to the SDK's
@@ -5056,38 +5421,12 @@ export class ClaudeAcpAgent {
5056
5421
  },
5057
5422
  ]
5058
5423
  : allowedModels;
5059
- const availableModes = buildAvailableModes(currentModelInfo);
5060
- // Clamp `permissionMode` if the resolved session does not offer it. The
5061
- // common case is `permissions.defaultMode: "auto"` resolving to a model
5062
- // that does not support auto mode (e.g. Haiku); without this clamp the
5063
- // SDK would later throw `"auto mode unavailable for this model"` from
5064
- // `setPermissionMode`. Keep `permissionMode` as the resolved user intent
5065
- // (matches what was passed into `options.permissionMode` above) and use
5066
- // `effectiveMode` for the post-clamp value the session actually runs in.
5067
- let effectiveMode = permissionMode;
5068
- if (!availableModes.some((m) => m.id === effectiveMode)) {
5069
- if (effectiveMode === "auto") {
5070
- this.logger.error(`permissions.defaultMode "auto" is not available for model ` +
5071
- `"${models.currentModelId}"; falling back to "default".`);
5072
- }
5073
- else {
5074
- this.logger.error(`permissions.defaultMode "${effectiveMode}" is not available in ` +
5075
- `this session; falling back to "default".`);
5076
- }
5077
- effectiveMode = "default";
5078
- // Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
5079
- // try/catch since failing here would abort session creation entirely.
5080
- try {
5081
- await q.setPermissionMode("default");
5082
- }
5083
- catch (err) {
5084
- this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
5085
- }
5086
- }
5087
- const modes = {
5088
- currentModeId: effectiveMode,
5089
- availableModes,
5090
- };
5424
+ const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
5425
+ query: q,
5426
+ requestedMode: initialPermissionMode,
5427
+ currentModelInfo,
5428
+ currentModelId: models.currentModelId,
5429
+ });
5091
5430
  const agents = await discoverCustomAgents(q);
5092
5431
  // Only adopt the requested agent as the selected value if it's one we
5093
5432
  // actually surface in the picker. A built-in (filtered out above) or
@@ -5165,15 +5504,20 @@ export class ClaudeAcpAgent {
5165
5504
  sessionFingerprint: computeSessionFingerprint(params),
5166
5505
  creationParams: params,
5167
5506
  settingsManager,
5507
+ titles: new SessionTitles(this, sessionId),
5168
5508
  accumulatedUsage: {
5169
5509
  inputTokens: 0,
5170
5510
  outputTokens: 0,
5171
5511
  cachedReadTokens: 0,
5172
5512
  cachedWriteTokens: 0,
5173
5513
  },
5514
+ accumulatedModelUsage: {},
5515
+ lastModelUsageReading: {},
5174
5516
  modes,
5175
5517
  models,
5176
5518
  modelInfos,
5519
+ autoModeFallbackWarningShown: false,
5520
+ autoModeFallbackWarningPending,
5177
5521
  configOptions,
5178
5522
  agents,
5179
5523
  currentAgent,
@@ -5188,7 +5532,11 @@ export class ClaudeAcpAgent {
5188
5532
  taskState,
5189
5533
  toolUseCache: {},
5190
5534
  emittedToolCalls: new Set(),
5535
+ eagerToolCallSessions: new Map(),
5191
5536
  liveBackgroundTasks: new Map(),
5537
+ nativeSubagentsByTaskId: new Map(),
5538
+ nativeSubagentTaskIdByToolUseId: new Map(),
5539
+ nativeSubagentParentByToolUseId: new Map(),
5192
5540
  emittedAssistantText: false,
5193
5541
  owedTrailingIdles: 0,
5194
5542
  messageIdToUuid: new Map(),
@@ -5253,12 +5601,125 @@ function sessionUsage(session) {
5253
5601
  outputTokens: session.accumulatedUsage.outputTokens,
5254
5602
  cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
5255
5603
  cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
5256
- totalTokens: session.accumulatedUsage.inputTokens +
5257
- session.accumulatedUsage.outputTokens +
5258
- session.accumulatedUsage.cachedReadTokens +
5259
- session.accumulatedUsage.cachedWriteTokens,
5604
+ totalTokens: tallyTotal(session.accumulatedUsage),
5605
+ };
5606
+ }
5607
+ /** The prompt response for a turn ending in `stopReason`: its usage and the
5608
+ * `_meta.quota` breakdown, both read off the session accumulators in the same
5609
+ * breath so a stored outcome (see Turn.deferredSettle / Turn.steeredSettle)
5610
+ * can't carry a usage and a quota from different moments. `extraMeta` merges
5611
+ * in alongside quota — a terminal session failure, today. */
5612
+ function turnOutcome(session, stopReason, extraMeta) {
5613
+ return {
5614
+ stopReason,
5615
+ usage: sessionUsage(session),
5616
+ _meta: { ...turnQuotaMeta(session), ...extraMeta },
5260
5617
  };
5261
5618
  }
5619
+ /** `_meta.quota` for a prompt response: what the turn spent, shaped like
5620
+ * codex-acp's (snake_case container keys, camelCase counters) so a client
5621
+ * reads one shape from either agent.
5622
+ *
5623
+ * The two halves have different scopes, and by design don't have to add up.
5624
+ * `token_count` mirrors the response's own `usage`, which the SDK reports for
5625
+ * the MAIN AGENT LOOP only. The `model_usage` rows come from
5626
+ * `result.modelUsage`, which also counts Task subagents, sidechains and
5627
+ * internal calls such as compaction — the accounting-grade figure per the SDK,
5628
+ * so the rows can total more than `token_count`. They are the fuller picture,
5629
+ * not a decomposition of it. */
5630
+ function turnQuotaMeta(session) {
5631
+ return {
5632
+ quota: {
5633
+ token_count: quotaTokenCount(session.accumulatedUsage),
5634
+ model_usage: Object.entries(session.accumulatedModelUsage ?? {}).map(([model, usage]) => ({
5635
+ model,
5636
+ token_count: quotaTokenCount(usage),
5637
+ })),
5638
+ },
5639
+ };
5640
+ }
5641
+ /** One `token_count` object. `cachedInputTokens` is cache reads, matching
5642
+ * codex's field; Claude also reports cache writes, which codex has no slot
5643
+ * for, so those ride along in an extra sibling (the same name the ACP `usage`
5644
+ * field already uses) and are counted in `totalTokens`. `reasoningOutputTokens`
5645
+ * is always 0: Claude bills thinking inside its output tokens and never breaks
5646
+ * it out — the key is kept so the shape stays uniform across agents. */
5647
+ function quotaTokenCount(usage) {
5648
+ return {
5649
+ totalTokens: tallyTotal(usage),
5650
+ inputTokens: usage.inputTokens,
5651
+ cachedInputTokens: usage.cachedReadTokens,
5652
+ cachedWriteTokens: usage.cachedWriteTokens,
5653
+ outputTokens: usage.outputTokens,
5654
+ reasoningOutputTokens: 0,
5655
+ };
5656
+ }
5657
+ /** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes
5658
+ * the cache counters, so this is not double-counting (see `totalTokens`). */
5659
+ function tallyTotal(usage) {
5660
+ return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens;
5661
+ }
5662
+ /** Project `result.modelUsage` into our tally shape. Counters are coerced the
5663
+ * way `snapshotFromUsage` coerces the stream's: third-party backends have been
5664
+ * observed omitting fields, and a missing or NaN counter must not reach the
5665
+ * wire as NaN (which `JSON.stringify` writes as `null`). */
5666
+ function normalizeModelUsage(modelUsage) {
5667
+ const tally = {};
5668
+ for (const [model, usage] of Object.entries(modelUsage ?? {})) {
5669
+ tally[model] = {
5670
+ inputTokens: finiteCount(usage?.inputTokens),
5671
+ outputTokens: finiteCount(usage?.outputTokens),
5672
+ cachedReadTokens: finiteCount(usage?.cacheReadInputTokens),
5673
+ cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens),
5674
+ };
5675
+ }
5676
+ return tally;
5677
+ }
5678
+ function finiteCount(value) {
5679
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
5680
+ }
5681
+ /** `current - previous` per model, dropping models with nothing to report so a
5682
+ * turn only lists the models it actually ran on. A reading that fell BELOW the
5683
+ * previous one means the running total restarted under us (a mid-session
5684
+ * /clear, a resumed session starting fresh, a zeroed crash result): there is no
5685
+ * usable reference left to subtract, so the reading itself is the increment. */
5686
+ function modelUsageIncrement(current, previous) {
5687
+ const increment = {};
5688
+ for (const [model, usage] of Object.entries(current)) {
5689
+ const base = previous[model];
5690
+ const subtracted = base
5691
+ ? {
5692
+ inputTokens: usage.inputTokens - base.inputTokens,
5693
+ outputTokens: usage.outputTokens - base.outputTokens,
5694
+ cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens,
5695
+ cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens,
5696
+ }
5697
+ : usage;
5698
+ const rewound = Object.values(subtracted).some((count) => count < 0);
5699
+ const resolved = rewound ? usage : subtracted;
5700
+ if (tallyTotal(resolved) > 0) {
5701
+ increment[model] = resolved;
5702
+ }
5703
+ }
5704
+ return increment;
5705
+ }
5706
+ /** Fold `increment` into `base` per model — the per-model counterpart of the
5707
+ * `+=` the turn's flat accumulator uses. */
5708
+ function addModelUsage(base, increment) {
5709
+ const merged = { ...base };
5710
+ for (const [model, usage] of Object.entries(increment)) {
5711
+ const existing = merged[model];
5712
+ merged[model] = existing
5713
+ ? {
5714
+ inputTokens: existing.inputTokens + usage.inputTokens,
5715
+ outputTokens: existing.outputTokens + usage.outputTokens,
5716
+ cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens,
5717
+ cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens,
5718
+ }
5719
+ : usage;
5720
+ }
5721
+ return merged;
5722
+ }
5262
5723
  /** Sum all four fields as a proxy for post-turn context occupancy: the current
5263
5724
  * turn's output becomes next turn's input. Per the Anthropic API, input_tokens
5264
5725
  * excludes cache tokens — cache_read and cache_creation are reported
@@ -5384,50 +5845,6 @@ function isValidBaseUrl(baseUrl) {
5384
5845
  }
5385
5846
  return parsed.protocol === "http:" || parsed.protocol === "https:";
5386
5847
  }
5387
- /**
5388
- * Build the list of permission modes the agent will advertise for the given
5389
- * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
5390
- * the SDK's model-level availability signal. `undefined`/`false` both exclude
5391
- * `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
5392
- */
5393
- function buildAvailableModes(modelInfo) {
5394
- const modes = [];
5395
- // Only advertise "auto" when the SDK reports the model supports it.
5396
- if (modelInfo?.supportsAutoMode === true) {
5397
- modes.push({
5398
- id: "auto",
5399
- name: "Auto",
5400
- description: "Use a model classifier to approve/deny permission prompts",
5401
- });
5402
- }
5403
- modes.push({
5404
- // Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces;
5405
- // the wire id stays "default" ("manual" is only an accepted input alias).
5406
- id: "default",
5407
- name: "Manual",
5408
- description: "Standard behavior, prompts for dangerous operations",
5409
- }, {
5410
- id: "acceptEdits",
5411
- name: "Accept Edits",
5412
- description: "Auto-accept file edit operations",
5413
- }, {
5414
- id: "plan",
5415
- name: "Plan Mode",
5416
- description: "Planning mode, no actual tool execution",
5417
- }, {
5418
- id: "dontAsk",
5419
- name: "Don't Ask",
5420
- description: "Don't prompt for permissions, deny if not pre-approved",
5421
- });
5422
- if (ALLOW_BYPASS) {
5423
- modes.push({
5424
- id: "bypassPermissions",
5425
- name: "Bypass Permissions",
5426
- description: "Bypass all permission checks",
5427
- });
5428
- }
5429
- return modes;
5430
- }
5431
5848
  // Translate a UI effort value into the flag-layer payload. The SDK
5432
5849
  // shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
5433
5850
  // and only clears a key when an explicit `null` is sent — see
@@ -5458,7 +5875,6 @@ export const BUILTIN_AGENT_NAMES = new Set([
5458
5875
  // reserved sentinel: a custom agent named exactly this would collide with it
5459
5876
  // (two options sharing the value, selection silently routing to `null`), so we
5460
5877
  // exclude that name from discovery.
5461
- export const DEFAULT_AGENT_ID = "default";
5462
5878
  /** Discover user/plugin/project-configured main-thread agents, excluding the
5463
5879
  * built-in subagents and the reserved "default" sentinel. Returns an empty
5464
5880
  * list if discovery fails so a flaky control request never blocks session
@@ -5476,9 +5892,8 @@ export async function discoverCustomAgents(q) {
5476
5892
  * Centralized so the option declarations in `buildConfigOptions` and the
5477
5893
  * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
5478
5894
  * same identifiers and can't drift apart. */
5479
- export const MODE_CONFIG_ID = "mode";
5895
+ export { MODE_CONFIG_ID };
5480
5896
  export const MODEL_CONFIG_ID = "model";
5481
- export const EFFORT_CONFIG_ID = "effort";
5482
5897
  export const AGENT_CONFIG_ID = "agent";
5483
5898
  export const FAST_MODE_CONFIG_ID = "fast";
5484
5899
  /** Select-fallback values used when the client has not opted into boolean
@@ -5577,19 +5992,7 @@ export function resolveFastModeEnabled(params) {
5577
5992
  }
5578
5993
  export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
5579
5994
  const options = [
5580
- {
5581
- id: MODE_CONFIG_ID,
5582
- name: "Mode",
5583
- description: "Session permission mode",
5584
- category: "mode",
5585
- type: "select",
5586
- currentValue: modes.currentModeId,
5587
- options: modes.availableModes.map((m) => ({
5588
- value: m.id,
5589
- name: m.name,
5590
- description: m.description,
5591
- })),
5592
- },
5995
+ SessionModeManager.configOption(modes),
5593
5996
  {
5594
5997
  id: MODEL_CONFIG_ID,
5595
5998
  name: "Model",
@@ -6258,6 +6661,46 @@ function claudeCodeMetaFromToolUse(toolUse, cwd) {
6258
6661
  ...(skillPath ? { skillPath } : {}),
6259
6662
  };
6260
6663
  }
6664
+ /**
6665
+ * Marks the Bash `tool_call_update` whose command detached into the background.
6666
+ *
6667
+ * A backgrounded Bash call returns as soon as the command is handed off, so the
6668
+ * card reaches `completed` while the command itself runs on for minutes. ACP has
6669
+ * no tool-call status for "still running elsewhere", so this marker is what lets
6670
+ * a client render the card as backgrounded work instead of finished work. It
6671
+ * rides the update the tool result already emits, so it costs no extra
6672
+ * notification and cannot arrive out of order.
6673
+ *
6674
+ * The command's own lifecycle -- progress, completion, the stop control -- is
6675
+ * published separately as an async task; this says only that the card has one.
6676
+ * Hence the AIR namespace rather than `claudeCode`: to a client without the
6677
+ * `asyncTasks` capability, which is never sent that lifecycle, the marker would
6678
+ * promise a card state it has no way to ever resolve.
6679
+ */
6680
+ function backgroundedBashToolCall(notification, task, asyncTasksSupported) {
6681
+ const update = notification.update;
6682
+ const toolCallId = task ? nonBlankTaskField(task.toolCallId ?? task.tool_use_id) : undefined;
6683
+ if (!asyncTasksSupported ||
6684
+ !toolCallId ||
6685
+ update.sessionUpdate !== "tool_call_update" ||
6686
+ update.toolCallId !== toolCallId) {
6687
+ return notification;
6688
+ }
6689
+ return {
6690
+ ...notification,
6691
+ update: {
6692
+ ...update,
6693
+ _meta: withAirMeta(update._meta, AIR_ASYNC_TASKS_CAPABILITY, { backgrounded: true }),
6694
+ },
6695
+ };
6696
+ }
6697
+ /** The task fields arrive as `unknown` off the wire; only non-blank strings carry a link. */
6698
+ function nonBlankTaskField(value) {
6699
+ if (typeof value !== "string")
6700
+ return undefined;
6701
+ const trimmed = value.trim();
6702
+ return trimmed ? trimmed : undefined;
6703
+ }
6261
6704
  /** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
6262
6705
  const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
6263
6706
  /**
@@ -6349,33 +6792,6 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
6349
6792
  ...(locations ? { locations } : {}),
6350
6793
  };
6351
6794
  }
6352
- /** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
6353
- * wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
6354
- * by-tool_use_id lookup. Each entry explains why an is_error tool_result
6355
- * carries harness prose instead of the tool's own output — "user-rejected",
6356
- * "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
6357
- * on the wire ahead of schema updates, so no enum check). Malformed entries
6358
- * are skipped rather than failing the message. */
6359
- function parseToolResultMeta(raw) {
6360
- if (!Array.isArray(raw)) {
6361
- return undefined;
6362
- }
6363
- let byToolUseId;
6364
- for (const entry of raw) {
6365
- if (typeof entry !== "object" || entry === null) {
6366
- continue;
6367
- }
6368
- const { id, non_execution_kind, user_feedback } = entry;
6369
- if (typeof id !== "string" || typeof non_execution_kind !== "string") {
6370
- continue;
6371
- }
6372
- (byToolUseId ??= new Map()).set(id, {
6373
- nonExecutionKind: non_execution_kind,
6374
- ...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
6375
- });
6376
- }
6377
- return byToolUseId;
6378
- }
6379
6795
  /**
6380
6796
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
6381
6797
  * Only handles text, image, and thinking chunks for now.
@@ -6523,6 +6939,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6523
6939
  claudeCode: {
6524
6940
  toolResponse,
6525
6941
  toolName,
6942
+ ...(options?.parentToolUseId
6943
+ ? { parentToolUseId: options.parentToolUseId }
6944
+ : {}),
6526
6945
  },
6527
6946
  },
6528
6947
  toolCallId: toolUseId,
@@ -6534,7 +6953,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6534
6953
  update,
6535
6954
  });
6536
6955
  },
6537
- });
6956
+ }, sessionId);
6538
6957
  }
6539
6958
  let rawInput;
6540
6959
  try {
@@ -6576,6 +6995,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6576
6995
  case "mcp_tool_result": {
6577
6996
  const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
6578
6997
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
6998
+ completeHookCallback(chunk.tool_use_id);
6579
6999
  // Why this is_error result carries harness prose instead of tool
6580
7000
  // output (user-rejected / interrupted / …), when the SDK said so.
6581
7001
  // Spread into the claudeCode meta of every update emitted below; the
@@ -6713,7 +7133,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6713
7133
  toolCallId: chunk.tool_use_id,
6714
7134
  sessionUpdate: "tool_call_update",
6715
7135
  status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
6716
- rawOutput: chunk.content,
7136
+ // terminal_output already carried the exact bytes in the preceding
7137
+ // update. Repeating them as rawOutput wastes bandwidth and lets a
7138
+ // client accidentally render the same output twice.
7139
+ ...(toolMeta?.terminal_output
7140
+ ? {}
7141
+ : { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
6717
7142
  ...toolUpdate,
6718
7143
  };
6719
7144
  }
@@ -6734,7 +7159,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6734
7159
  case "compaction":
6735
7160
  case "compaction_delta":
6736
7161
  case "advisor_tool_result":
6737
- case "mid_conv_system":
6738
7162
  case "fallback":
6739
7163
  break;
6740
7164
  default:
@@ -6922,6 +7346,7 @@ export function runAcp(logger) {
6922
7346
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
6923
7347
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
6924
7348
  .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
7349
+ .onRequest(ASYNC_TASK_STOP_METHOD, { parse: parseAsyncTaskStopRequest }, (ctx) => agent.stopAsyncTask(ctx.params))
6925
7350
  .onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
6926
7351
  .connect(stream);
6927
7352
  agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);