@agentclientprotocol/claude-agent-acp 0.70.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -9
- package/dist/acp-agent.d.ts +136 -49
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +1304 -724
- package/dist/acp-subagents.d.ts +82 -0
- package/dist/acp-subagents.d.ts.map +1 -0
- package/dist/acp-subagents.js +13 -0
- package/dist/air-extension.d.ts +23 -0
- package/dist/air-extension.d.ts.map +1 -0
- package/dist/air-extension.js +63 -0
- package/dist/async-tasks.d.ts +109 -0
- package/dist/async-tasks.d.ts.map +1 -0
- package/dist/async-tasks.js +613 -0
- package/dist/clear-context-coordinator.d.ts +60 -0
- package/dist/clear-context-coordinator.d.ts.map +1 -0
- package/dist/clear-context-coordinator.js +80 -0
- package/dist/exit-plan.d.ts +42 -0
- package/dist/exit-plan.d.ts.map +1 -0
- package/dist/exit-plan.js +154 -0
- package/dist/file-change-audit.d.ts.map +1 -1
- package/dist/file-change-audit.js +4 -33
- package/dist/fork-session.d.ts +12 -0
- package/dist/fork-session.d.ts.map +1 -0
- package/dist/fork-session.js +41 -0
- package/dist/native-subagents.d.ts +71 -0
- package/dist/native-subagents.d.ts.map +1 -0
- package/dist/native-subagents.js +422 -0
- package/dist/permissions/effects.d.ts +19 -0
- package/dist/permissions/effects.d.ts.map +1 -0
- package/dist/permissions/effects.js +166 -0
- package/dist/permissions/modes.d.ts +8 -0
- package/dist/permissions/modes.d.ts.map +1 -0
- package/dist/permissions/modes.js +41 -0
- package/dist/permissions/normalization.d.ts +6 -0
- package/dist/permissions/normalization.d.ts.map +1 -0
- package/dist/permissions/normalization.js +100 -0
- package/dist/permissions/options/filesystem.d.ts +9 -0
- package/dist/permissions/options/filesystem.d.ts.map +1 -0
- package/dist/permissions/options/filesystem.js +124 -0
- package/dist/permissions/options/shared.d.ts +39 -0
- package/dist/permissions/options/shared.d.ts.map +1 -0
- package/dist/permissions/options/shared.js +64 -0
- package/dist/permissions/options/shell.d.ts +5 -0
- package/dist/permissions/options/shell.d.ts.map +1 -0
- package/dist/permissions/options/shell.js +100 -0
- package/dist/permissions/options/tools.d.ts +11 -0
- package/dist/permissions/options/tools.d.ts.map +1 -0
- package/dist/permissions/options/tools.js +135 -0
- package/dist/permissions/options.d.ts +5 -0
- package/dist/permissions/options.d.ts.map +1 -0
- package/dist/permissions/options.js +60 -0
- package/dist/permissions/presentation.d.ts +15 -0
- package/dist/permissions/presentation.d.ts.map +1 -0
- package/dist/permissions/presentation.js +83 -0
- package/dist/permissions/response.d.ts +11 -0
- package/dist/permissions/response.d.ts.map +1 -0
- package/dist/permissions/response.js +19 -0
- package/dist/session-config-ids.d.ts +5 -0
- package/dist/session-config-ids.d.ts.map +1 -0
- package/dist/session-config-ids.js +4 -0
- package/dist/session-failure-extension.d.ts +2 -24
- package/dist/session-failure-extension.d.ts.map +1 -1
- package/dist/session-failure-extension.js +13 -40
- package/dist/session-mode.d.ts +66 -0
- package/dist/session-mode.d.ts.map +1 -0
- package/dist/session-mode.js +234 -0
- package/dist/session-titles.d.ts +77 -0
- package/dist/session-titles.d.ts.map +1 -0
- package/dist/session-titles.js +199 -0
- package/dist/tool-result-meta.d.ts +8 -0
- package/dist/tool-result-meta.d.ts.map +1 -0
- package/dist/tool-result-meta.js +19 -0
- package/dist/tools.d.ts +6 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +144 -20
- package/package.json +12 -12
package/dist/acp-agent.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
|
-
import { deleteSession,
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
const
|
|
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, {
|
|
@@ -558,28 +495,164 @@ class ClientConnection {
|
|
|
558
495
|
writeTextFile(params) {
|
|
559
496
|
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
|
560
497
|
}
|
|
561
|
-
|
|
498
|
+
createElicitation(params, signal) {
|
|
562
499
|
return this.ctx.request(methods.client.elicitation.create, params, {
|
|
563
500
|
cancellationSignal: signal,
|
|
564
501
|
});
|
|
565
502
|
}
|
|
566
|
-
|
|
503
|
+
completeElicitation(params) {
|
|
567
504
|
return this.ctx.notify(methods.client.elicitation.complete, params);
|
|
568
505
|
}
|
|
569
506
|
extNotification(method, params) {
|
|
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.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.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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
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 =
|
|
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(
|
|
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
|
|
@@ -1400,12 +1546,20 @@ export class ClaudeAcpAgent {
|
|
|
1400
1546
|
*
|
|
1401
1547
|
* But an echo-less result can also be an ORPHAN: cancel() settles+removes a
|
|
1402
1548
|
* queued turn whose user message was already pushed, so the SDK still runs
|
|
1403
|
-
* it and emits a result with no
|
|
1549
|
+
* it and emits a result with no echo to match. Promoting the head for an
|
|
1404
1550
|
* orphan would misattribute its stop reason/usage to an unrelated later
|
|
1405
1551
|
* prompt. `session.pendingOrphanResults` counts exactly how many such
|
|
1406
1552
|
* orphans are still expected (FIFO, they arrive before any live turn's
|
|
1407
|
-
* result), so we skip those and only promote once the count is drained.
|
|
1408
|
-
|
|
1553
|
+
* result), so we skip those and only promote once the count is drained.
|
|
1554
|
+
*
|
|
1555
|
+
* `resultUserMessageUuid` is the result's own join key (SDK 0.3.246+
|
|
1556
|
+
* echoes the triggering send's client uuid on results; absent on older
|
|
1557
|
+
* CLIs, synthetic/meta turns, and session-scoped failures). When present
|
|
1558
|
+
* it upgrades the map lane from positional heuristics to an exact match:
|
|
1559
|
+
* a stamp naming an orphaned command consumes the result outright, and a
|
|
1560
|
+
* stamp naming anything else positively refutes "this is a dead turn's
|
|
1561
|
+
* result", so the dup-over-loss one-skip must not eat it. */
|
|
1562
|
+
const ensureActiveTurn = (resultUserMessageUuid) => {
|
|
1409
1563
|
if (session.activeTurn) {
|
|
1410
1564
|
if (!isHeldOpen(session.activeTurn)) {
|
|
1411
1565
|
return;
|
|
@@ -1457,8 +1611,12 @@ export class ClaudeAcpAgent {
|
|
|
1457
1611
|
// double-consume it. The unexpected-transition logging in the frame
|
|
1458
1612
|
// handler is the tripwire for that class of drift.
|
|
1459
1613
|
if (session.orphanCommands?.size) {
|
|
1614
|
+
const stampedOrphan = resultUserMessageUuid !== undefined && session.orphanCommands.has(resultUserMessageUuid);
|
|
1460
1615
|
let consumedOrphanResult = false;
|
|
1461
1616
|
let oldestPending;
|
|
1617
|
+
// The started/zombie drain applies regardless of the stamp: commands
|
|
1618
|
+
// folded into the turn that emitted this result share it, and zombies'
|
|
1619
|
+
// late results have already passed (or never existed).
|
|
1462
1620
|
for (const [uuid, state] of session.orphanCommands) {
|
|
1463
1621
|
if (state === "started" || state === "zombie") {
|
|
1464
1622
|
consumedOrphanResult = true;
|
|
@@ -1468,17 +1626,33 @@ export class ClaudeAcpAgent {
|
|
|
1468
1626
|
oldestPending ??= uuid;
|
|
1469
1627
|
}
|
|
1470
1628
|
}
|
|
1471
|
-
if (
|
|
1629
|
+
if (stampedOrphan) {
|
|
1630
|
+
// Exact join: the result names an orphaned command. Delete the
|
|
1631
|
+
// matched entry even when it is still "pending" (its dispatch frame
|
|
1632
|
+
// was lost) and consume the result — no promotion.
|
|
1633
|
+
session.orphanCommands.delete(resultUserMessageUuid);
|
|
1472
1634
|
return;
|
|
1473
1635
|
}
|
|
1474
|
-
if (
|
|
1475
|
-
//
|
|
1476
|
-
// live turn's
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
|
|
1481
|
-
|
|
1636
|
+
if (resultUserMessageUuid !== undefined) {
|
|
1637
|
+
// The stamp names a send that is NOT in the orphan map, so this is
|
|
1638
|
+
// a live turn's result: skip both the consumed-return (its folded
|
|
1639
|
+
// orphans were drained above, but the result itself still needs a
|
|
1640
|
+
// turn) and the dup-over-loss one-skip the stamp refutes, and fall
|
|
1641
|
+
// through to promote the head.
|
|
1642
|
+
}
|
|
1643
|
+
else {
|
|
1644
|
+
if (consumedOrphanResult) {
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (oldestPending !== undefined) {
|
|
1648
|
+
// No dispatch was seen before this result, so it is very likely a
|
|
1649
|
+
// live turn's — but a lost "started" frame would mean it IS the
|
|
1650
|
+
// orphan's (dup-over-loss: prefer one wrong skip over
|
|
1651
|
+
// misattributing a dead turn's outcome to a live prompt). Grant
|
|
1652
|
+
// each pending entry exactly one skip, like the count lane did.
|
|
1653
|
+
session.orphanCommands.delete(oldestPending);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1482
1656
|
}
|
|
1483
1657
|
}
|
|
1484
1658
|
const head = firstUnsettledQueuedTurn();
|
|
@@ -1648,11 +1822,7 @@ export class ClaudeAcpAgent {
|
|
|
1648
1822
|
return;
|
|
1649
1823
|
}
|
|
1650
1824
|
sessionFailures.recordActive(failure);
|
|
1651
|
-
settleActive(
|
|
1652
|
-
stopReason: "end_turn",
|
|
1653
|
-
usage: sessionUsage(session),
|
|
1654
|
-
_meta: sessionFailureMeta(failure),
|
|
1655
|
-
}, "providerError");
|
|
1825
|
+
settleActive(turnOutcome(session, "end_turn", sessionFailureMeta(failure)), "providerError");
|
|
1656
1826
|
};
|
|
1657
1827
|
/** Reject every in-flight turn — used when the stream dies. */
|
|
1658
1828
|
const failAllTurns = (error) => {
|
|
@@ -1724,7 +1894,9 @@ export class ClaudeAcpAgent {
|
|
|
1724
1894
|
// spent would never drain (it would swallow an unrelated later
|
|
1725
1895
|
// echo-less result instead).
|
|
1726
1896
|
const active = session.activeTurn;
|
|
1727
|
-
if (active.commandFinished === "completed" ||
|
|
1897
|
+
if (active.commandFinished === "completed" ||
|
|
1898
|
+
active.commandFinished === "discarded" ||
|
|
1899
|
+
active.commandFinished === "refused") {
|
|
1728
1900
|
// Finished SDK-side; any result already passed. Nothing to
|
|
1729
1901
|
// track.
|
|
1730
1902
|
}
|
|
@@ -1749,7 +1921,7 @@ export class ClaudeAcpAgent {
|
|
|
1749
1921
|
this.trackOrphanCommand(session, active.promptUuid, "started");
|
|
1750
1922
|
}
|
|
1751
1923
|
}
|
|
1752
|
-
settleActive(
|
|
1924
|
+
settleActive(turnOutcome(session, "cancelled"));
|
|
1753
1925
|
// The cancelled turn's result may never come (that's why the
|
|
1754
1926
|
// backstop fired) — close its delivery stretch here so partial
|
|
1755
1927
|
// streamed text can't suppress the next turn's issue-#453 fallback.
|
|
@@ -1803,9 +1975,10 @@ export class ClaudeAcpAgent {
|
|
|
1803
1975
|
// a deferred turn's stored outcome (followup results never mutate
|
|
1804
1976
|
// it), but the stored one is the authoritative source.
|
|
1805
1977
|
const inFlight = session.activeTurn;
|
|
1978
|
+
await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "at end of stream");
|
|
1806
1979
|
settleActive(session.cancelled
|
|
1807
|
-
?
|
|
1808
|
-
: (inFlight?.deferredSettle ??
|
|
1980
|
+
? turnOutcome(session, "cancelled")
|
|
1981
|
+
: (inFlight?.deferredSettle ?? turnOutcome(session, stopReason)));
|
|
1809
1982
|
// Queued turns the SDK never started never ran, so reject them rather
|
|
1810
1983
|
// than reporting a success (end_turn) — or a misleading "cancelled" —
|
|
1811
1984
|
// for a prompt that produced no output. (A cancel already settled the
|
|
@@ -1835,7 +2008,7 @@ export class ClaudeAcpAgent {
|
|
|
1835
2008
|
}
|
|
1836
2009
|
// CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
|
|
1837
2010
|
// uuid-stamped queued command (queued/started/completed/cancelled/
|
|
1838
|
-
// discarded) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
2011
|
+
// discarded/refused) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
1839
2012
|
// prompt() stamps a uuid on every message. The frame is @internal and
|
|
1840
2013
|
// absent from the SDKMessage union, so handle it BEFORE the exhaustive
|
|
1841
2014
|
// switch: it must not reach `unreachable`'s error log, and a `case`
|
|
@@ -1870,6 +2043,7 @@ export class ClaudeAcpAgent {
|
|
|
1870
2043
|
}
|
|
1871
2044
|
case "completed":
|
|
1872
2045
|
case "discarded":
|
|
2046
|
+
case "refused":
|
|
1873
2047
|
case "cancelled": {
|
|
1874
2048
|
// Terminal frames. Latch the fate on a still-queued turn so a
|
|
1875
2049
|
// later cancel() doesn't seed an orphan entry for a command
|
|
@@ -1892,6 +2066,7 @@ export class ClaudeAcpAgent {
|
|
|
1892
2066
|
const state = session.orphanCommands?.get(frame.command_uuid);
|
|
1893
2067
|
if (state === "pending") {
|
|
1894
2068
|
session.orphanCommands?.delete(frame.command_uuid);
|
|
2069
|
+
session.pendingEmptyInterruptionDiagnosticCommands?.delete(frame.command_uuid);
|
|
1895
2070
|
}
|
|
1896
2071
|
else if (state === "started") {
|
|
1897
2072
|
session.orphanCommands?.set(frame.command_uuid, "zombie");
|
|
@@ -1903,7 +2078,11 @@ export class ClaudeAcpAgent {
|
|
|
1903
2078
|
// command folded into another turn whose result is attributed
|
|
1904
2079
|
// elsewhere — either way no echo-less result remains to skip.
|
|
1905
2080
|
// "discarded" = session ended with it still queued; no result.
|
|
2081
|
+
// "refused" (2.1.238+) = a cross-session peer message declined
|
|
2082
|
+
// by receive-side policy before dispatch; never a prompt-lane
|
|
2083
|
+
// command of ours, and no result will ever come.
|
|
1906
2084
|
session.orphanCommands?.delete(frame.command_uuid);
|
|
2085
|
+
session.pendingEmptyInterruptionDiagnosticCommands?.delete(frame.command_uuid);
|
|
1907
2086
|
break;
|
|
1908
2087
|
}
|
|
1909
2088
|
default:
|
|
@@ -2026,6 +2205,7 @@ export class ClaudeAcpAgent {
|
|
|
2026
2205
|
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
|
2027
2206
|
lastAssistantUsage = null;
|
|
2028
2207
|
lastAssistantTotalUsage = usedTokens ?? 0;
|
|
2208
|
+
session.contextUsedTokens = usedTokens ?? 0;
|
|
2029
2209
|
await sendUpdate({
|
|
2030
2210
|
sessionId: message.session_id,
|
|
2031
2211
|
update: {
|
|
@@ -2079,7 +2259,7 @@ export class ClaudeAcpAgent {
|
|
|
2079
2259
|
// the interrupted turn's tokens entirely (issue #844). Zero
|
|
2080
2260
|
// when the cancel pre-empted the result (wedge/force-cancel).
|
|
2081
2261
|
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
|
|
2082
|
-
settleActive(
|
|
2262
|
+
settleActive(turnOutcome(session, "cancelled"));
|
|
2083
2263
|
// An interrupt can pre-empt the turn's result entirely
|
|
2084
2264
|
// (nothing ran the result-case `finally`), so close the
|
|
2085
2265
|
// delivery stretch here: idle is the SDK's authoritative
|
|
@@ -2153,11 +2333,9 @@ export class ClaudeAcpAgent {
|
|
|
2153
2333
|
`for the active turn; failing the in-flight prompt (issue #825)`);
|
|
2154
2334
|
await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
|
|
2155
2335
|
}
|
|
2156
|
-
//
|
|
2157
|
-
//
|
|
2158
|
-
|
|
2159
|
-
// landed. Push it to the client if it changed.
|
|
2160
|
-
await this.maybeUpdateSessionTitle(params.sessionId, session);
|
|
2336
|
+
// Turn-over is when a title may have landed or become
|
|
2337
|
+
// generatable; see SessionTitles.onTurnEnd.
|
|
2338
|
+
await session.titles.onTurnEnd(session);
|
|
2161
2339
|
}
|
|
2162
2340
|
break;
|
|
2163
2341
|
}
|
|
@@ -2223,6 +2401,7 @@ export class ClaudeAcpAgent {
|
|
|
2223
2401
|
break;
|
|
2224
2402
|
}
|
|
2225
2403
|
case "permission_denied": {
|
|
2404
|
+
unregisterHookCallback(message.tool_use_id);
|
|
2226
2405
|
// A tool call was auto-denied (by a rule, the classifier,
|
|
2227
2406
|
// dontAsk mode, etc.) before running. The tool_use block was
|
|
2228
2407
|
// already emitted as a `tool_call`, so mark it failed with the
|
|
@@ -2250,6 +2429,16 @@ export class ClaudeAcpAgent {
|
|
|
2250
2429
|
const parentToolUseId = message.agent_id
|
|
2251
2430
|
? session.liveBackgroundTasks.get(message.agent_id)?.parentToolUseId
|
|
2252
2431
|
: undefined;
|
|
2432
|
+
const eagerOwnerSessionId = session.eagerToolCallSessions?.get(message.tool_use_id);
|
|
2433
|
+
if (message.agent_id &&
|
|
2434
|
+
!parentToolUseId &&
|
|
2435
|
+
!eagerOwnerSessionId &&
|
|
2436
|
+
clientSupportsSubagents(this.clientCapabilities)) {
|
|
2437
|
+
// An agent-scoped denial with missing lineage cannot safely
|
|
2438
|
+
// be presented in the root transcript. The matching hidden
|
|
2439
|
+
// child tool call was not announced there.
|
|
2440
|
+
break;
|
|
2441
|
+
}
|
|
2253
2442
|
const reason = message.decision_reason ?? message.message;
|
|
2254
2443
|
await sendUpdate({
|
|
2255
2444
|
sessionId: message.session_id,
|
|
@@ -2304,7 +2493,15 @@ export class ClaudeAcpAgent {
|
|
|
2304
2493
|
case "hook_progress":
|
|
2305
2494
|
case "hook_response":
|
|
2306
2495
|
case "files_persisted":
|
|
2496
|
+
break;
|
|
2307
2497
|
case "task_progress":
|
|
2498
|
+
await asyncTasks.taskProgress({
|
|
2499
|
+
task_id: message.task_id,
|
|
2500
|
+
description: message.description,
|
|
2501
|
+
summary: message.summary,
|
|
2502
|
+
last_tool_name: message.last_tool_name,
|
|
2503
|
+
usage: message.usage,
|
|
2504
|
+
});
|
|
2308
2505
|
break;
|
|
2309
2506
|
case "task_started":
|
|
2310
2507
|
// For subagent tasks `task_id` is the subagent's agent id (the
|
|
@@ -2329,6 +2526,23 @@ export class ClaudeAcpAgent {
|
|
|
2329
2526
|
parentToolUseId: message.tool_use_id,
|
|
2330
2527
|
isSubagent: !!message.subagent_type,
|
|
2331
2528
|
});
|
|
2529
|
+
await subagents.taskStarted({
|
|
2530
|
+
taskId: message.task_id,
|
|
2531
|
+
toolUseId: message.tool_use_id,
|
|
2532
|
+
subagentType: message.subagent_type,
|
|
2533
|
+
description: message.description,
|
|
2534
|
+
prompt: message.prompt,
|
|
2535
|
+
}, sendUpdate);
|
|
2536
|
+
await asyncTasks.taskStarted({
|
|
2537
|
+
task_id: message.task_id,
|
|
2538
|
+
task_type: message.task_type,
|
|
2539
|
+
description: message.description,
|
|
2540
|
+
subagent_type: message.subagent_type,
|
|
2541
|
+
is_backgrounded: message.is_backgrounded,
|
|
2542
|
+
workflow_name: message.workflow_name,
|
|
2543
|
+
skip_transcript: message.skip_transcript,
|
|
2544
|
+
tool_use_id: message.tool_use_id,
|
|
2545
|
+
});
|
|
2332
2546
|
if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
|
|
2333
2547
|
(session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
|
|
2334
2548
|
}
|
|
@@ -2336,9 +2550,19 @@ export class ClaudeAcpAgent {
|
|
|
2336
2550
|
case "task_notification":
|
|
2337
2551
|
// The task settled — no further tool calls can originate
|
|
2338
2552
|
// from it, so its registry entry can be dropped.
|
|
2553
|
+
await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id);
|
|
2554
|
+
await asyncTasks.taskNotification({
|
|
2555
|
+
task_id: message.task_id,
|
|
2556
|
+
status: message.status,
|
|
2557
|
+
summary: message.summary,
|
|
2558
|
+
output_file: message.output_file,
|
|
2559
|
+
});
|
|
2560
|
+
if (message.tool_use_id)
|
|
2561
|
+
subagents.discardPending(message.tool_use_id);
|
|
2339
2562
|
session.liveBackgroundTasks.delete(message.task_id);
|
|
2340
2563
|
break;
|
|
2341
2564
|
case "task_updated":
|
|
2565
|
+
await asyncTasks.taskUpdated(message.task_id, message.patch);
|
|
2342
2566
|
// terminal-status task_updated patch and a (deduplicated)
|
|
2343
2567
|
// task_notification when a task settles, but only the patch is
|
|
2344
2568
|
// guaranteed per transition — prune on it too so the registry
|
|
@@ -2347,6 +2571,7 @@ export class ClaudeAcpAgent {
|
|
|
2347
2571
|
if (message.patch.status === "completed" ||
|
|
2348
2572
|
message.patch.status === "failed" ||
|
|
2349
2573
|
message.patch.status === "killed") {
|
|
2574
|
+
await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
|
|
2350
2575
|
session.liveBackgroundTasks.delete(message.task_id);
|
|
2351
2576
|
}
|
|
2352
2577
|
break;
|
|
@@ -2366,7 +2591,7 @@ export class ClaudeAcpAgent {
|
|
|
2366
2591
|
// client supports url elicitation; ignore failures otherwise.
|
|
2367
2592
|
if (this.clientCapabilities?.elicitation?.url) {
|
|
2368
2593
|
try {
|
|
2369
|
-
await this.client.
|
|
2594
|
+
await this.client.completeElicitation({
|
|
2370
2595
|
elicitationId: message.elicitation_id,
|
|
2371
2596
|
});
|
|
2372
2597
|
}
|
|
@@ -2454,7 +2679,7 @@ export class ClaudeAcpAgent {
|
|
|
2454
2679
|
});
|
|
2455
2680
|
}
|
|
2456
2681
|
if (persistent) {
|
|
2457
|
-
await this.
|
|
2682
|
+
await this.syncModelAfterExternalSwitch(params.sessionId, session, message.fallback_model);
|
|
2458
2683
|
}
|
|
2459
2684
|
break;
|
|
2460
2685
|
}
|
|
@@ -2482,6 +2707,7 @@ export class ClaudeAcpAgent {
|
|
|
2482
2707
|
case "control_request_progress":
|
|
2483
2708
|
break;
|
|
2484
2709
|
case "background_tasks_changed":
|
|
2710
|
+
await asyncTasks.backgroundTasksChanged(message.tasks);
|
|
2485
2711
|
// A level signal: the full live background-task set on every
|
|
2486
2712
|
// membership change, with REPLACE semantics. Used only to
|
|
2487
2713
|
// reconcile `liveBackgroundTasks` — dropping (or, for
|
|
@@ -2537,6 +2763,8 @@ export class ClaudeAcpAgent {
|
|
|
2537
2763
|
// user-turn lifecycle (stop reason, settles, failActive,
|
|
2538
2764
|
// slash-command output forwarding), though their cost is real.
|
|
2539
2765
|
const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
|
|
2766
|
+
const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption;
|
|
2767
|
+
const pendingExitPlanContextReset = session.pendingExitPlanContextReset;
|
|
2540
2768
|
try {
|
|
2541
2769
|
// Reconcile the Fast mode toggle with the SDK's reported state.
|
|
2542
2770
|
// Gated to user-driven turns like every other side effect below;
|
|
@@ -2556,7 +2784,7 @@ export class ClaudeAcpAgent {
|
|
|
2556
2784
|
// the map in that case).
|
|
2557
2785
|
if (!isAutonomousResult) {
|
|
2558
2786
|
recordResultForOrphanCommands();
|
|
2559
|
-
ensureActiveTurn();
|
|
2787
|
+
ensureActiveTurn(message.user_message_uuid);
|
|
2560
2788
|
// Once the submitted goal command has produced its own result,
|
|
2561
2789
|
// no older runtime update can still precede it in the ordered
|
|
2562
2790
|
// SDK stream. Stop suppressing updates even when this runtime
|
|
@@ -2637,6 +2865,18 @@ export class ClaudeAcpAgent {
|
|
|
2637
2865
|
session.accumulatedUsage.cachedWriteTokens +=
|
|
2638
2866
|
message.usage.cache_creation_input_tokens;
|
|
2639
2867
|
}
|
|
2868
|
+
// The same tally split by model, for `_meta.quota.model_usage`.
|
|
2869
|
+
// `modelUsage` is a running total for the whole query() call, so
|
|
2870
|
+
// this result's own spend is what it added to the previous
|
|
2871
|
+
// reading. Advance the reading even for an autonomous result — it
|
|
2872
|
+
// is part of the running total the NEXT increment is measured
|
|
2873
|
+
// from — but leave the turn tally alone, exactly as above.
|
|
2874
|
+
const modelUsageReading = normalizeModelUsage(message.modelUsage);
|
|
2875
|
+
const resultModelUsage = modelUsageIncrement(modelUsageReading, session.lastModelUsageReading ?? {});
|
|
2876
|
+
session.lastModelUsageReading = modelUsageReading;
|
|
2877
|
+
if (!isAutonomousResult) {
|
|
2878
|
+
session.accumulatedModelUsage = addModelUsage(session.accumulatedModelUsage ?? {}, resultModelUsage);
|
|
2879
|
+
}
|
|
2640
2880
|
const matchingModelUsage = lastAssistantModel
|
|
2641
2881
|
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
2642
2882
|
: null;
|
|
@@ -2689,6 +2929,8 @@ export class ClaudeAcpAgent {
|
|
|
2689
2929
|
});
|
|
2690
2930
|
}
|
|
2691
2931
|
if (session.cancelled) {
|
|
2932
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2933
|
+
session.pendingExitPlanContextReset = undefined;
|
|
2692
2934
|
if (!isAutonomousResult) {
|
|
2693
2935
|
await clearFailuresFromEarlierTurns();
|
|
2694
2936
|
stopReason = "cancelled";
|
|
@@ -2726,7 +2968,76 @@ export class ClaudeAcpAgent {
|
|
|
2726
2968
|
}
|
|
2727
2969
|
break;
|
|
2728
2970
|
}
|
|
2971
|
+
// A cancel can race startup before the original prompt's echo.
|
|
2972
|
+
// The CLI then replays the cancelled prompt, its interruption
|
|
2973
|
+
// marker, and the replacement prompt before yielding this
|
|
2974
|
+
// error-shaped diagnostic for the OLD cycle. The replacement's
|
|
2975
|
+
// echo has already made it active, so treating the diagnostic as
|
|
2976
|
+
// its result rejects a healthy prompt and leaves its real output
|
|
2977
|
+
// arriving after session/prompt completed. Ignore only the
|
|
2978
|
+
// diagnostic's exact empty-user-interruption signature; ordinary
|
|
2979
|
+
// provider errors still follow the failure lanes below.
|
|
2980
|
+
if (message.is_error &&
|
|
2981
|
+
isEmptyUserInterruptionDiagnostic(message) &&
|
|
2982
|
+
(session.pendingEmptyInterruptionDiagnosticCommands?.size ?? 0) > 0) {
|
|
2983
|
+
const pending = session.pendingEmptyInterruptionDiagnosticCommands;
|
|
2984
|
+
const stamped = message.user_message_uuid;
|
|
2985
|
+
if (stamped === undefined) {
|
|
2986
|
+
// Older producer: no join key on the result, so consume an
|
|
2987
|
+
// arbitrary hand-off token like before.
|
|
2988
|
+
const commandUuid = pending.values().next().value;
|
|
2989
|
+
if (commandUuid) {
|
|
2990
|
+
pending.delete(commandUuid);
|
|
2991
|
+
}
|
|
2992
|
+
break;
|
|
2993
|
+
}
|
|
2994
|
+
if (pending.delete(stamped)) {
|
|
2995
|
+
// Exact join (SDK 0.3.246+ echoes the triggering send's
|
|
2996
|
+
// uuid on error results): the diagnostic names a cancelled
|
|
2997
|
+
// command we handed a token for — swallow it.
|
|
2998
|
+
break;
|
|
2999
|
+
}
|
|
3000
|
+
// Stamped but unmatched: this diagnostic belongs to a send we
|
|
3001
|
+
// did NOT cancel — a live turn's real failure. Don't let a
|
|
3002
|
+
// stale token eat it; fall through to the ordinary failure
|
|
3003
|
+
// lanes (the clear below retires the stale tokens, same as
|
|
3004
|
+
// every other non-diagnostic result path).
|
|
3005
|
+
}
|
|
3006
|
+
// Some CLI versions omit the cancelled cycle's diagnostic. Do
|
|
3007
|
+
// not let an unused hand-off token swallow a later turn's real
|
|
3008
|
+
// interruption failure.
|
|
3009
|
+
session.pendingEmptyInterruptionDiagnosticCommands?.clear();
|
|
2729
3010
|
await clearFailuresFromEarlierTurns();
|
|
3011
|
+
// `interrupt: true` is required to make "No, keep planning"
|
|
3012
|
+
// terminate the ACP turn. Claude represents that intentional
|
|
3013
|
+
// interrupt as an error-shaped diagnostic, so translate only a
|
|
3014
|
+
// diagnostic causally paired with the recorded ExitPlanMode
|
|
3015
|
+
// permission response.
|
|
3016
|
+
const diagnostic = executionDiagnostic(message);
|
|
3017
|
+
if (pendingExitPlanModeInterruption &&
|
|
3018
|
+
pendingExitPlanModeInterruption.toolResultSeen &&
|
|
3019
|
+
message.is_error &&
|
|
3020
|
+
diagnostic &&
|
|
3021
|
+
/(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
|
|
3022
|
+
/(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic)) {
|
|
3023
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
3024
|
+
if (pendingExitPlanContextReset &&
|
|
3025
|
+
pendingExitPlanContextReset.toolUseId ===
|
|
3026
|
+
pendingExitPlanModeInterruption.toolUseId) {
|
|
3027
|
+
await this.exitPlan.restart(params.sessionId, session, pendingExitPlanContextReset);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
stopReason = "cancelled";
|
|
3031
|
+
settleOrDefer(turnOutcome(session, "cancelled"));
|
|
3032
|
+
break;
|
|
3033
|
+
}
|
|
3034
|
+
if (pendingExitPlanModeInterruption) {
|
|
3035
|
+
// This result ended the interrupted cycle without the exact
|
|
3036
|
+
// correlated cancellation shape. Never carry its marker into
|
|
3037
|
+
// a later turn, whether or not the tool result was observed.
|
|
3038
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
3039
|
+
session.pendingExitPlanContextReset = undefined;
|
|
3040
|
+
}
|
|
2730
3041
|
// A refusal can arrive on any result subtype (and may even set
|
|
2731
3042
|
// is_error), so handle it before the subtype switch — otherwise the
|
|
2732
3043
|
// is_error throw below would surface it as an internal error. The
|
|
@@ -2749,7 +3060,7 @@ export class ClaudeAcpAgent {
|
|
|
2749
3060
|
// settling it out from under them would strand their output
|
|
2750
3061
|
// and permission requests out-of-turn (issue #866's deadlock,
|
|
2751
3062
|
// through the refusal lane).
|
|
2752
|
-
settleOrDefer(
|
|
3063
|
+
settleOrDefer(turnOutcome(session, "refusal"));
|
|
2753
3064
|
break;
|
|
2754
3065
|
}
|
|
2755
3066
|
// A priority:'now' steer can make the SDK terminate the
|
|
@@ -2760,7 +3071,7 @@ export class ClaudeAcpAgent {
|
|
|
2760
3071
|
if (message.is_error &&
|
|
2761
3072
|
isSteering(session.activeTurn) &&
|
|
2762
3073
|
session.activeTurn.steeredEchoes.size > 0) {
|
|
2763
|
-
settleOrDefer(
|
|
3074
|
+
settleOrDefer(turnOutcome(session, "end_turn"));
|
|
2764
3075
|
break;
|
|
2765
3076
|
}
|
|
2766
3077
|
if (!message.is_error && lastAssistantModel !== null) {
|
|
@@ -2803,7 +3114,7 @@ export class ClaudeAcpAgent {
|
|
|
2803
3114
|
// prose can be injected into the feed.)
|
|
2804
3115
|
if (session.activeTurn?.isLocalOnlyCommand ||
|
|
2805
3116
|
(!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
|
|
2806
|
-
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache,
|
|
3117
|
+
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
|
|
2807
3118
|
await sendUpdate(notification);
|
|
2808
3119
|
}
|
|
2809
3120
|
}
|
|
@@ -2872,7 +3183,7 @@ export class ClaudeAcpAgent {
|
|
|
2872
3183
|
// idle/abort path. settleActive is idempotent, so a duplicate
|
|
2873
3184
|
// idle is a no-op.
|
|
2874
3185
|
if (!session.cancelled) {
|
|
2875
|
-
settleOrDefer(
|
|
3186
|
+
settleOrDefer(turnOutcome(session, stopReason));
|
|
2876
3187
|
}
|
|
2877
3188
|
}
|
|
2878
3189
|
finally {
|
|
@@ -2975,6 +3286,7 @@ export class ClaudeAcpAgent {
|
|
|
2975
3286
|
const nextUsage = totalTokens(lastAssistantUsage);
|
|
2976
3287
|
if (nextUsage !== lastAssistantTotalUsage) {
|
|
2977
3288
|
lastAssistantTotalUsage = nextUsage;
|
|
3289
|
+
session.contextUsedTokens = nextUsage;
|
|
2978
3290
|
await sendUpdate({
|
|
2979
3291
|
sessionId: params.sessionId,
|
|
2980
3292
|
update: {
|
|
@@ -3046,7 +3358,7 @@ export class ClaudeAcpAgent {
|
|
|
3046
3358
|
session.owedTrailingIdles++;
|
|
3047
3359
|
// Before activateTurn resets the accumulator, so the
|
|
3048
3360
|
// usage still belongs to the cancelled turn.
|
|
3049
|
-
settleActive(
|
|
3361
|
+
settleActive(turnOutcome(session, "cancelled"));
|
|
3050
3362
|
}
|
|
3051
3363
|
else if (isHeldOpen(session.activeTurn)) {
|
|
3052
3364
|
// A turn held open for its background subagents (see
|
|
@@ -3075,7 +3387,7 @@ export class ClaudeAcpAgent {
|
|
|
3075
3387
|
settleActive(session.activeTurn.steeredSettle);
|
|
3076
3388
|
}
|
|
3077
3389
|
else {
|
|
3078
|
-
settleActive(
|
|
3390
|
+
settleActive(turnOutcome(session, "end_turn"));
|
|
3079
3391
|
}
|
|
3080
3392
|
}
|
|
3081
3393
|
// Unlike the no-result teardown lanes, this hand-off must
|
|
@@ -3111,6 +3423,7 @@ export class ClaudeAcpAgent {
|
|
|
3111
3423
|
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
3112
3424
|
lastAssistantUsage = snapshotFromUsage(message.message.usage);
|
|
3113
3425
|
lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
|
|
3426
|
+
session.contextUsedTokens = lastAssistantTotalUsage;
|
|
3114
3427
|
lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
|
|
3115
3428
|
if (message.error || lastAssistantWasUsageLimit) {
|
|
3116
3429
|
lastAssistantFailureTitle = assistantMessageText(message.message);
|
|
@@ -3141,7 +3454,7 @@ export class ClaudeAcpAgent {
|
|
|
3141
3454
|
message.message.content.includes("<local-command-stdout>")) {
|
|
3142
3455
|
const stripped = stripLocalCommandMetadata(message.message.content);
|
|
3143
3456
|
if (typeof stripped === "string") {
|
|
3144
|
-
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache,
|
|
3457
|
+
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
|
|
3145
3458
|
clientCapabilities: this.clientCapabilities,
|
|
3146
3459
|
parentToolUseId: message.parent_tool_use_id,
|
|
3147
3460
|
cwd: session.cwd,
|
|
@@ -3251,17 +3564,22 @@ export class ClaudeAcpAgent {
|
|
|
3251
3564
|
}
|
|
3252
3565
|
else if (message.type === "assistant" &&
|
|
3253
3566
|
!(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
|
|
3254
|
-
// Legacy clients
|
|
3255
|
-
//
|
|
3256
|
-
//
|
|
3257
|
-
// feed. Capable clients opt into the branch above unchanged, with
|
|
3258
|
-
// `parentToolUseId` stamped by toAcpNotifications.
|
|
3567
|
+
// Legacy clients keep the flattened tool-call representation,
|
|
3568
|
+
// but nested text/thinking stays internal unless explicitly
|
|
3569
|
+
// requested through the historical transcript extension.
|
|
3259
3570
|
content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
|
|
3260
3571
|
}
|
|
3261
3572
|
else {
|
|
3262
3573
|
content = message.message.content;
|
|
3263
3574
|
}
|
|
3264
|
-
|
|
3575
|
+
const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
|
|
3576
|
+
let backgroundBashTask;
|
|
3577
|
+
if (message.type === "user") {
|
|
3578
|
+
backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
|
|
3579
|
+
if (backgroundBashTask)
|
|
3580
|
+
await asyncTasks.taskBackgrounded(backgroundBashTask);
|
|
3581
|
+
}
|
|
3582
|
+
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
|
|
3265
3583
|
clientCapabilities: this.clientCapabilities,
|
|
3266
3584
|
parentToolUseId: message.parent_tool_use_id,
|
|
3267
3585
|
cwd: session.cwd,
|
|
@@ -3279,7 +3597,7 @@ export class ClaudeAcpAgent {
|
|
|
3279
3597
|
// filtered out of `content` above; blocks that do pass through
|
|
3280
3598
|
// (e.g. a subagent image) carry the stamped parentToolUseId
|
|
3281
3599
|
// meta and are excluded there.
|
|
3282
|
-
await sendUpdate(notification);
|
|
3600
|
+
await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundBashTask, asyncTasks.enabled));
|
|
3283
3601
|
}
|
|
3284
3602
|
break;
|
|
3285
3603
|
}
|
|
@@ -3303,6 +3621,11 @@ export class ClaudeAcpAgent {
|
|
|
3303
3621
|
if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
|
|
3304
3622
|
break;
|
|
3305
3623
|
}
|
|
3624
|
+
const subagentParentToolUseId = message.parent_tool_use_id
|
|
3625
|
+
? [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id)
|
|
3626
|
+
? message.parent_tool_use_id
|
|
3627
|
+
: undefined
|
|
3628
|
+
: undefined;
|
|
3306
3629
|
await sendUpdate({
|
|
3307
3630
|
sessionId: message.session_id,
|
|
3308
3631
|
update: {
|
|
@@ -3312,6 +3635,9 @@ export class ClaudeAcpAgent {
|
|
|
3312
3635
|
_meta: {
|
|
3313
3636
|
claudeCode: {
|
|
3314
3637
|
toolName: message.tool_name,
|
|
3638
|
+
...(subagentParentToolUseId
|
|
3639
|
+
? { parentToolUseId: subagentParentToolUseId }
|
|
3640
|
+
: {}),
|
|
3315
3641
|
toolResponse: {
|
|
3316
3642
|
elapsedTimeSeconds: message.elapsed_time_seconds,
|
|
3317
3643
|
// For Agent/Task calls: the subagent's type, and — when
|
|
@@ -3351,8 +3677,17 @@ export class ClaudeAcpAgent {
|
|
|
3351
3677
|
// and task store are independent of the previous transcript.
|
|
3352
3678
|
// Clear both the in-memory snapshot and the client's visible plan
|
|
3353
3679
|
// before any follow-up prompt can republish stale tasks.
|
|
3680
|
+
await finishLifecycle("failed", "failed", "during conversation reset");
|
|
3681
|
+
subagents.clear();
|
|
3682
|
+
asyncTasks.clear();
|
|
3683
|
+
session.eagerToolCallSessions?.clear();
|
|
3684
|
+
clearHookCallbacks(params.sessionId);
|
|
3354
3685
|
session.taskState.clear();
|
|
3355
3686
|
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
3687
|
+
// A reset mounts a fresh transcript (`new_conversation_id`), so our
|
|
3688
|
+
// cached title no longer describes the session: drop it and
|
|
3689
|
+
// re-evaluate at the next turn-end.
|
|
3690
|
+
session.titles.reset();
|
|
3356
3691
|
break;
|
|
3357
3692
|
}
|
|
3358
3693
|
case "tool_use_summary":
|
|
@@ -3383,6 +3718,7 @@ export class ClaudeAcpAgent {
|
|
|
3383
3718
|
message.includes("process exited with") ||
|
|
3384
3719
|
message.includes("process terminated by signal") ||
|
|
3385
3720
|
message.includes("Failed to write to process stdin"));
|
|
3721
|
+
await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "after stream error");
|
|
3386
3722
|
if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
|
|
3387
3723
|
if (!isHeldOpen(session.activeTurn)) {
|
|
3388
3724
|
await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
|
|
@@ -3405,6 +3741,10 @@ export class ClaudeAcpAgent {
|
|
|
3405
3741
|
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
|
|
3406
3742
|
failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
|
|
3407
3743
|
this.closeQueryStream(session);
|
|
3744
|
+
session.eagerToolCallSessions?.clear();
|
|
3745
|
+
clearHookCallbacks(params.sessionId);
|
|
3746
|
+
session.nativeSubagentRuntime?.clear();
|
|
3747
|
+
session.asyncTaskRuntime?.clear();
|
|
3408
3748
|
delete this.sessions[params.sessionId];
|
|
3409
3749
|
}
|
|
3410
3750
|
else {
|
|
@@ -3441,18 +3781,34 @@ export class ClaudeAcpAgent {
|
|
|
3441
3781
|
}
|
|
3442
3782
|
}
|
|
3443
3783
|
async cancel(params) {
|
|
3784
|
+
this.exitPlan.cancel(params.sessionId);
|
|
3444
3785
|
const session = this.sessions[params.sessionId];
|
|
3445
3786
|
if (!session) {
|
|
3446
3787
|
return;
|
|
3447
3788
|
}
|
|
3789
|
+
session.cancelled = true;
|
|
3790
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
3791
|
+
session.pendingExitPlanContextReset = undefined;
|
|
3448
3792
|
// The stream already ended (see closeQueryStream): every in-flight turn was
|
|
3449
3793
|
// settled when it closed, and there is no live query to interrupt. Calling
|
|
3450
3794
|
// query.interrupt() on a finished iterator could reject and surface from
|
|
3451
3795
|
// this fire-and-forget notification, so there is nothing to do here.
|
|
3452
3796
|
if (session.queryClosed) {
|
|
3797
|
+
session.eagerToolCallSessions?.clear();
|
|
3798
|
+
clearHookCallbacks(params.sessionId);
|
|
3453
3799
|
return;
|
|
3454
3800
|
}
|
|
3455
|
-
|
|
3801
|
+
try {
|
|
3802
|
+
await session.nativeSubagentRuntime?.finishAll("cancelled", session.nativeSubagentDeliver ??
|
|
3803
|
+
(async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification))));
|
|
3804
|
+
}
|
|
3805
|
+
catch (error) {
|
|
3806
|
+
this.logger.error(`Session ${params.sessionId}: failed to publish cancelled subagent state`, error);
|
|
3807
|
+
}
|
|
3808
|
+
finally {
|
|
3809
|
+
session.eagerToolCallSessions?.clear();
|
|
3810
|
+
clearHookCallbacks(params.sessionId);
|
|
3811
|
+
}
|
|
3456
3812
|
// A priority steer may still be queued in the SDK when cancellation
|
|
3457
3813
|
// settles its owning turn. Its later echo matches no live turn, and its
|
|
3458
3814
|
// result must be skipped rather than promoted onto the next prompt.
|
|
@@ -3486,8 +3842,10 @@ export class ClaudeAcpAgent {
|
|
|
3486
3842
|
}
|
|
3487
3843
|
// Each removed queued turn's user message was already pushed to the SDK,
|
|
3488
3844
|
// which processes input FIFO and will still emit a result for it with no
|
|
3489
|
-
//
|
|
3490
|
-
//
|
|
3845
|
+
// user echo to match (0.3.246+ CLIs do stamp results with the
|
|
3846
|
+
// triggering send's user_message_uuid, which ensureActiveTurn uses as
|
|
3847
|
+
// an exact join when present). Track those so the consumer skips them
|
|
3848
|
+
// (see ensureActiveTurn) rather than misattributing them to the head.
|
|
3491
3849
|
// msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
|
|
3492
3850
|
// own terminal lifecycle frame — exact under command coalescing, where
|
|
3493
3851
|
// N queued commands fold into ONE turn emitting one result and a plain
|
|
@@ -3496,7 +3854,9 @@ export class ClaudeAcpAgent {
|
|
|
3496
3854
|
// never see lifecycle frames, so commandStarted/commandFinished stay
|
|
3497
3855
|
// unset and every turn takes the plain-seed path below).
|
|
3498
3856
|
for (const turn of orphanedTurns) {
|
|
3499
|
-
if (turn.commandFinished === "completed" ||
|
|
3857
|
+
if (turn.commandFinished === "completed" ||
|
|
3858
|
+
turn.commandFinished === "discarded" ||
|
|
3859
|
+
turn.commandFinished === "refused") {
|
|
3500
3860
|
// The command already finished SDK-side and its terminal frame was
|
|
3501
3861
|
// consumed while the turn sat queued — nothing is left to skip, and
|
|
3502
3862
|
// a seeded entry would never drain.
|
|
@@ -3525,6 +3885,24 @@ export class ClaudeAcpAgent {
|
|
|
3525
3885
|
}
|
|
3526
3886
|
session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
|
|
3527
3887
|
}
|
|
3888
|
+
const diagnosticTurns = orphanedTurns.filter((turn) => {
|
|
3889
|
+
if (turn.commandFinished === "completed" ||
|
|
3890
|
+
turn.commandFinished === "discarded" ||
|
|
3891
|
+
turn.commandFinished === "refused") {
|
|
3892
|
+
return false;
|
|
3893
|
+
}
|
|
3894
|
+
if (turn.commandStarted && turn.commandResultSeen)
|
|
3895
|
+
return false;
|
|
3896
|
+
if (turn.commandFinished === "cancelled" && !turn.commandStarted)
|
|
3897
|
+
return false;
|
|
3898
|
+
return true;
|
|
3899
|
+
});
|
|
3900
|
+
if (diagnosticTurns.length > 0) {
|
|
3901
|
+
session.pendingEmptyInterruptionDiagnosticCommands ??= new Set();
|
|
3902
|
+
for (const turn of diagnosticTurns) {
|
|
3903
|
+
session.pendingEmptyInterruptionDiagnosticCommands.add(turn.promptUuid);
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3528
3906
|
// A deferred active turn (see Turn.deferredSettle) already has its
|
|
3529
3907
|
// result — it is only held open for its background subagents, which the
|
|
3530
3908
|
// interrupt below tears down. Settle it "cancelled" NOW: during the hold
|
|
@@ -3571,7 +3949,14 @@ export class ClaudeAcpAgent {
|
|
|
3571
3949
|
if (session.lastSessionState !== "idle") {
|
|
3572
3950
|
session.owedTrailingIdles++;
|
|
3573
3951
|
}
|
|
3574
|
-
|
|
3952
|
+
// Carries the held outcome's `_meta` too: a deferred outcome's only
|
|
3953
|
+
// metadata is its quota breakdown, the counterpart of the usage taken
|
|
3954
|
+
// from it here.
|
|
3955
|
+
active.resolve({
|
|
3956
|
+
stopReason: "cancelled",
|
|
3957
|
+
usage: active.deferredSettle.usage,
|
|
3958
|
+
...(active.deferredSettle._meta ? { _meta: active.deferredSettle._meta } : {}),
|
|
3959
|
+
});
|
|
3575
3960
|
}
|
|
3576
3961
|
}
|
|
3577
3962
|
// A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
|
|
@@ -3622,6 +4007,11 @@ export class ClaudeAcpAgent {
|
|
|
3622
4007
|
// activation-time self-heal.
|
|
3623
4008
|
if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
|
|
3624
4009
|
const stillQueued = new Set(receipt.still_queued);
|
|
4010
|
+
const droppedTurns = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid));
|
|
4011
|
+
const droppedCount = droppedTurns.length;
|
|
4012
|
+
for (const turn of droppedTurns) {
|
|
4013
|
+
session.pendingEmptyInterruptionDiagnosticCommands?.delete(turn.promptUuid);
|
|
4014
|
+
}
|
|
3625
4015
|
if (lifecycleLane) {
|
|
3626
4016
|
// Lifecycle lane: forget dropped orphans by uuid. Only entries still
|
|
3627
4017
|
// "pending" — an orphan absent from `still_queued` because it was
|
|
@@ -3640,9 +4030,8 @@ export class ClaudeAcpAgent {
|
|
|
3640
4030
|
}
|
|
3641
4031
|
}
|
|
3642
4032
|
else {
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
|
|
4033
|
+
if (droppedCount > 0) {
|
|
4034
|
+
session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - droppedCount);
|
|
3646
4035
|
}
|
|
3647
4036
|
}
|
|
3648
4037
|
}
|
|
@@ -3683,7 +4072,12 @@ export class ClaudeAcpAgent {
|
|
|
3683
4072
|
if (!session) {
|
|
3684
4073
|
return;
|
|
3685
4074
|
}
|
|
3686
|
-
|
|
4075
|
+
try {
|
|
4076
|
+
await this.cancel({ sessionId });
|
|
4077
|
+
}
|
|
4078
|
+
catch (error) {
|
|
4079
|
+
this.logger.error(`Session ${sessionId}: cancellation failed during teardown`, error);
|
|
4080
|
+
}
|
|
3687
4081
|
// cancel() arms the force-cancel floor and interrupts gracefully, but a
|
|
3688
4082
|
// wedged consumer only wakes when `cancelController` aborts — closeQueryStream
|
|
3689
4083
|
// below doesn't touch it. Since we're tearing the session down anyway, wake
|
|
@@ -3699,6 +4093,10 @@ export class ClaudeAcpAgent {
|
|
|
3699
4093
|
// here the client has asked us to close the session, so signalling abort is
|
|
3700
4094
|
// appropriate; query.close() above has already torn the subprocess down.
|
|
3701
4095
|
session.abortController.abort();
|
|
4096
|
+
session.eagerToolCallSessions?.clear();
|
|
4097
|
+
clearHookCallbacks(sessionId);
|
|
4098
|
+
session.nativeSubagentRuntime?.clear();
|
|
4099
|
+
session.asyncTaskRuntime?.clear();
|
|
3702
4100
|
delete this.sessions[sessionId];
|
|
3703
4101
|
}
|
|
3704
4102
|
/** Tear down all active sessions. Called when the ACP connection closes. */
|
|
@@ -3722,19 +4120,7 @@ export class ClaudeAcpAgent {
|
|
|
3722
4120
|
return {};
|
|
3723
4121
|
}
|
|
3724
4122
|
async setSessionMode(params) {
|
|
3725
|
-
|
|
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 {};
|
|
4123
|
+
return this.sessionModes.setSessionMode(params);
|
|
3738
4124
|
}
|
|
3739
4125
|
async setSessionConfigOption(params) {
|
|
3740
4126
|
const session = this.sessions[params.sessionId];
|
|
@@ -3804,15 +4190,10 @@ export class ClaudeAcpAgent {
|
|
|
3804
4190
|
// Use the canonical option value so downstream code always receives the
|
|
3805
4191
|
// model ID rather than the caller-supplied alias.
|
|
3806
4192
|
const resolvedValue = validValue.value;
|
|
4193
|
+
let effectiveValue = resolvedValue;
|
|
3807
4194
|
if (params.configId === MODE_CONFIG_ID) {
|
|
3808
|
-
await this.
|
|
3809
|
-
await this.
|
|
3810
|
-
sessionId: params.sessionId,
|
|
3811
|
-
update: {
|
|
3812
|
-
sessionUpdate: "current_mode_update",
|
|
3813
|
-
currentModeId: resolvedValue,
|
|
3814
|
-
},
|
|
3815
|
-
});
|
|
4195
|
+
effectiveValue = await this.sessionModes.selectMode(params.sessionId, resolvedValue);
|
|
4196
|
+
await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
|
|
3816
4197
|
}
|
|
3817
4198
|
else if (params.configId === MODEL_CONFIG_ID) {
|
|
3818
4199
|
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
|
@@ -3820,44 +4201,9 @@ export class ClaudeAcpAgent {
|
|
|
3820
4201
|
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
|
3821
4202
|
// effort changes and effort changes induced by a model switch go through
|
|
3822
4203
|
// the same path.
|
|
3823
|
-
await this.applyConfigOptionValue(params.sessionId, session, params.configId,
|
|
4204
|
+
await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
|
|
3824
4205
|
return { configOptions: session.configOptions };
|
|
3825
4206
|
}
|
|
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
4207
|
async replaySessionHistory(sessionId) {
|
|
3862
4208
|
const toolUseCache = {};
|
|
3863
4209
|
const messages = await getSessionMessages(sessionId);
|
|
@@ -3883,6 +4229,101 @@ export class ClaudeAcpAgent {
|
|
|
3883
4229
|
// ends an incomplete audit lane.
|
|
3884
4230
|
let replayingFileChangeAudit = false;
|
|
3885
4231
|
const replayFileChangeAuditToolUseIds = new Set();
|
|
4232
|
+
const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
|
|
4233
|
+
const replayTerminalStates = new Map();
|
|
4234
|
+
const replayChildren = new Map();
|
|
4235
|
+
if (nativeReplayEnabled) {
|
|
4236
|
+
for (const message of messages) {
|
|
4237
|
+
const content = message.message
|
|
4238
|
+
?.content;
|
|
4239
|
+
if (!Array.isArray(content))
|
|
4240
|
+
continue;
|
|
4241
|
+
const ownerToolUseId = parentToolUseIdOf(message);
|
|
4242
|
+
for (const block of content) {
|
|
4243
|
+
if (typeof block === "object" &&
|
|
4244
|
+
block !== null &&
|
|
4245
|
+
"type" in block &&
|
|
4246
|
+
(block.type === "tool_result" || block.type === "mcp_tool_result") &&
|
|
4247
|
+
"tool_use_id" in block &&
|
|
4248
|
+
typeof block.tool_use_id === "string") {
|
|
4249
|
+
replayTerminalStates.set(block.tool_use_id, replaySubagentTerminalState(block));
|
|
4250
|
+
}
|
|
4251
|
+
if (typeof block !== "object" ||
|
|
4252
|
+
block === null ||
|
|
4253
|
+
!("type" in block) ||
|
|
4254
|
+
!["tool_use", "server_tool_use", "mcp_tool_use"].includes(String(block.type)) ||
|
|
4255
|
+
!("id" in block) ||
|
|
4256
|
+
typeof block.id !== "string" ||
|
|
4257
|
+
!("name" in block) ||
|
|
4258
|
+
!isNativeSubagentControlTool(block.name)) {
|
|
4259
|
+
continue;
|
|
4260
|
+
}
|
|
4261
|
+
const input = "input" in block && typeof block.input === "object" && block.input !== null
|
|
4262
|
+
? block.input
|
|
4263
|
+
: {};
|
|
4264
|
+
const task = [input.prompt, input.description]
|
|
4265
|
+
.find((value) => typeof value === "string" && value.trim().length > 0)
|
|
4266
|
+
?.trim() ?? "Delegated task restored from session history";
|
|
4267
|
+
const name = [input.name, input.description, input.subagent_type]
|
|
4268
|
+
.find((value) => typeof value === "string" && value.trim().length > 0)
|
|
4269
|
+
?.trim() ?? "Restored agent";
|
|
4270
|
+
replayChildren.set(block.id, {
|
|
4271
|
+
sessionId: `${sessionId}:replay-subagent:${block.id}`,
|
|
4272
|
+
...(ownerToolUseId ? { parentToolUseId: ownerToolUseId } : {}),
|
|
4273
|
+
name,
|
|
4274
|
+
task,
|
|
4275
|
+
reconstructable: true,
|
|
4276
|
+
announced: false,
|
|
4277
|
+
terminalState: replayTerminalStates.get(block.id),
|
|
4278
|
+
});
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
for (const [toolUseId, terminalState] of replayTerminalStates) {
|
|
4282
|
+
const child = replayChildren.get(toolUseId);
|
|
4283
|
+
if (child)
|
|
4284
|
+
child.terminalState = terminalState;
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
const announceReplayChild = async (parentToolUseId, ancestors = new Set()) => {
|
|
4288
|
+
let child = replayChildren.get(parentToolUseId);
|
|
4289
|
+
if (!child) {
|
|
4290
|
+
child = {
|
|
4291
|
+
sessionId: `${sessionId}:replay-subagent:${parentToolUseId}`,
|
|
4292
|
+
name: "Disconnected agent",
|
|
4293
|
+
task: "Subagent restored without persisted launch metadata",
|
|
4294
|
+
reconstructable: false,
|
|
4295
|
+
announced: false,
|
|
4296
|
+
};
|
|
4297
|
+
replayChildren.set(parentToolUseId, child);
|
|
4298
|
+
}
|
|
4299
|
+
if (ancestors.has(parentToolUseId)) {
|
|
4300
|
+
child.reconstructable = false;
|
|
4301
|
+
child.terminalState = undefined;
|
|
4302
|
+
child.parentToolUseId = undefined;
|
|
4303
|
+
}
|
|
4304
|
+
if (child.parentToolUseId) {
|
|
4305
|
+
const nextAncestors = new Set(ancestors);
|
|
4306
|
+
nextAncestors.add(parentToolUseId);
|
|
4307
|
+
await announceReplayChild(child.parentToolUseId, nextAncestors);
|
|
4308
|
+
}
|
|
4309
|
+
if (!child.announced) {
|
|
4310
|
+
const parentSessionId = child.parentToolUseId
|
|
4311
|
+
? (replayChildren.get(child.parentToolUseId)?.sessionId ?? sessionId)
|
|
4312
|
+
: sessionId;
|
|
4313
|
+
await this.client.sessionUpdate(asSdkSessionNotification({
|
|
4314
|
+
sessionId: parentSessionId,
|
|
4315
|
+
update: {
|
|
4316
|
+
sessionUpdate: "subagent_spawned",
|
|
4317
|
+
subagentSessionId: child.sessionId,
|
|
4318
|
+
name: child.name,
|
|
4319
|
+
task: child.task,
|
|
4320
|
+
capabilities: {},
|
|
4321
|
+
},
|
|
4322
|
+
}));
|
|
4323
|
+
child.announced = true;
|
|
4324
|
+
}
|
|
4325
|
+
return child.sessionId;
|
|
4326
|
+
};
|
|
3886
4327
|
for (const message of messages) {
|
|
3887
4328
|
if (message.type === "user" &&
|
|
3888
4329
|
message.parent_tool_use_id === null &&
|
|
@@ -3923,7 +4364,13 @@ export class ClaudeAcpAgent {
|
|
|
3923
4364
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3924
4365
|
let content = message.message.content;
|
|
3925
4366
|
const parentToolUseId = parentToolUseIdOf(message);
|
|
3926
|
-
|
|
4367
|
+
const replayTargetSessionId = nativeReplayEnabled && parentToolUseId
|
|
4368
|
+
? await announceReplayChild(parentToolUseId)
|
|
4369
|
+
: sessionId;
|
|
4370
|
+
if (message.type === "assistant" &&
|
|
4371
|
+
parentToolUseId &&
|
|
4372
|
+
!nativeReplayEnabled &&
|
|
4373
|
+
!forwardSubagentText) {
|
|
3927
4374
|
content = stripSubagentTextAndThinking(content);
|
|
3928
4375
|
}
|
|
3929
4376
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
@@ -3995,7 +4442,37 @@ export class ClaudeAcpAgent {
|
|
|
3995
4442
|
messageId: replayMessageId,
|
|
3996
4443
|
parentToolUseId,
|
|
3997
4444
|
})) {
|
|
3998
|
-
|
|
4445
|
+
const toolName = notification.update._meta?.claudeCode?.toolName;
|
|
4446
|
+
if (nativeReplayEnabled &&
|
|
4447
|
+
(notification.update.sessionUpdate === "tool_call" ||
|
|
4448
|
+
notification.update.sessionUpdate === "tool_call_update") &&
|
|
4449
|
+
isNativeSubagentControlTool(toolName)) {
|
|
4450
|
+
continue;
|
|
4451
|
+
}
|
|
4452
|
+
await this.client.sessionUpdate({ ...notification, sessionId: replayTargetSessionId });
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
if (nativeReplayEnabled) {
|
|
4456
|
+
// Claude history persists sidechain messages and Agent/Task tool uses,
|
|
4457
|
+
// but not task_started/task_updated lifecycle frames. Recover terminal
|
|
4458
|
+
// state from the launch tool_result. Missing results and malformed or
|
|
4459
|
+
// orphan lineage use the draft protocol's deterministic disconnected state.
|
|
4460
|
+
for (const child of [...replayChildren.values()].reverse()) {
|
|
4461
|
+
if (!child.announced)
|
|
4462
|
+
continue;
|
|
4463
|
+
const parentSessionId = child.parentToolUseId
|
|
4464
|
+
? (replayChildren.get(child.parentToolUseId)?.sessionId ?? sessionId)
|
|
4465
|
+
: sessionId;
|
|
4466
|
+
await this.client.sessionUpdate(asSdkSessionNotification({
|
|
4467
|
+
sessionId: parentSessionId,
|
|
4468
|
+
update: {
|
|
4469
|
+
sessionUpdate: "subagent_state_update",
|
|
4470
|
+
subagentSessionId: child.sessionId,
|
|
4471
|
+
state: child.reconstructable
|
|
4472
|
+
? (child.terminalState ?? "disconnected")
|
|
4473
|
+
: "disconnected",
|
|
4474
|
+
},
|
|
4475
|
+
}));
|
|
3999
4476
|
}
|
|
4000
4477
|
}
|
|
4001
4478
|
}
|
|
@@ -4007,23 +4484,45 @@ export class ClaudeAcpAgent {
|
|
|
4007
4484
|
const response = await this.client.writeTextFile(params);
|
|
4008
4485
|
return response;
|
|
4009
4486
|
}
|
|
4487
|
+
/** Mark a client request as blocking on user input for exactly the lifetime
|
|
4488
|
+
* of its promise. Steering consults this session-local count synchronously,
|
|
4489
|
+
* so a message arriving while any permission/elicitation card is open uses
|
|
4490
|
+
* non-interrupting SDK delivery. */
|
|
4491
|
+
async withPendingUserInput(sessionId, request) {
|
|
4492
|
+
const session = this.sessions[sessionId];
|
|
4493
|
+
if (!session)
|
|
4494
|
+
return request();
|
|
4495
|
+
session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
|
|
4496
|
+
try {
|
|
4497
|
+
return await request();
|
|
4498
|
+
}
|
|
4499
|
+
finally {
|
|
4500
|
+
session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4010
4503
|
/** Forward a permission request to the client, wiring the tool call's
|
|
4011
4504
|
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
|
4012
4505
|
* while the client's prompt is still open the signal aborts, the SDK sends
|
|
4013
|
-
* `$/cancel_request`, and
|
|
4014
|
-
*
|
|
4015
|
-
* "Tool use aborted" the callers already expect
|
|
4016
|
-
|
|
4017
|
-
|
|
4506
|
+
* `$/cancel_request`, and our local abort race settles even if the client
|
|
4507
|
+
* ignores it. A `cancelled` outcome, request rejection, and local abort all
|
|
4508
|
+
* surface the same "Tool use aborted" the callers already expect. */
|
|
4509
|
+
async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId = params.sessionId) {
|
|
4510
|
+
if (signal.aborted)
|
|
4511
|
+
throw new Error("Tool use aborted");
|
|
4018
4512
|
// The SDK may invoke `canUseTool` (and therefore this permission request)
|
|
4019
4513
|
// before the assistant message's tool_use block streams to us. Some ACP clients
|
|
4020
4514
|
// expect the `tool_call` a permission request references to already exist,
|
|
4021
4515
|
// so emit it now if it hasn't been sent yet. The streamed tool_use chunk
|
|
4022
4516
|
// later refines it with a `tool_call_update` rather than emitting a
|
|
4023
4517
|
// duplicate (see `emittedToolCalls` in `toAcpNotifications`).
|
|
4024
|
-
await this.ensureToolCallEmitted(
|
|
4518
|
+
await this.ensureToolCallEmitted(ownerSessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal, params.sessionId);
|
|
4519
|
+
if (signal.aborted)
|
|
4520
|
+
throw new Error("Tool use aborted");
|
|
4521
|
+
// Do not rely on every ACP client settling requestPermission after the
|
|
4522
|
+
// cancellation signal. The local race guarantees that Claude's tool call
|
|
4523
|
+
// is released even when an older or broken client ignores $/cancel_request.
|
|
4025
4524
|
try {
|
|
4026
|
-
return await this.client.requestPermission(params, signal);
|
|
4525
|
+
return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
|
|
4027
4526
|
}
|
|
4028
4527
|
catch (error) {
|
|
4029
4528
|
if (signal.aborted) {
|
|
@@ -4044,7 +4543,7 @@ export class ClaudeAcpAgent {
|
|
|
4044
4543
|
* are resolved at tool_result time instead (see `toAcpNotifications`).
|
|
4045
4544
|
* `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
|
|
4046
4545
|
* that spawned it, matching the streamed path's `_meta`. */
|
|
4047
|
-
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
|
|
4546
|
+
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal, notificationSessionId = sessionId) {
|
|
4048
4547
|
const session = this.sessions[sessionId];
|
|
4049
4548
|
if (!session) {
|
|
4050
4549
|
return;
|
|
@@ -4053,6 +4552,7 @@ export class ClaudeAcpAgent {
|
|
|
4053
4552
|
return;
|
|
4054
4553
|
}
|
|
4055
4554
|
session.emittedToolCalls.add(toolCallId);
|
|
4555
|
+
(session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
|
|
4056
4556
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
4057
4557
|
const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
|
|
4058
4558
|
if (parentToolUseId) {
|
|
@@ -4064,10 +4564,21 @@ export class ClaudeAcpAgent {
|
|
|
4064
4564
|
},
|
|
4065
4565
|
};
|
|
4066
4566
|
}
|
|
4067
|
-
|
|
4567
|
+
try {
|
|
4568
|
+
const emission = this.client.sessionUpdate({ sessionId: notificationSessionId, update });
|
|
4569
|
+
await (signal ? raceWithAbort(emission, signal) : emission);
|
|
4570
|
+
}
|
|
4571
|
+
catch (error) {
|
|
4572
|
+
// The set is also the de-duplication guard for the later streamed
|
|
4573
|
+
// tool_use. Keep it truthful: if emission failed, that path must still
|
|
4574
|
+
// be allowed to publish the tool call instead of refining a phantom one.
|
|
4575
|
+
session.emittedToolCalls.delete(toolCallId);
|
|
4576
|
+
session.eagerToolCallSessions?.delete(toolCallId);
|
|
4577
|
+
throw error;
|
|
4578
|
+
}
|
|
4068
4579
|
}
|
|
4069
4580
|
canUseTool(sessionId) {
|
|
4070
|
-
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
|
|
4581
|
+
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, }) => {
|
|
4071
4582
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
4072
4583
|
const session = this.sessions[sessionId];
|
|
4073
4584
|
if (!session) {
|
|
@@ -4103,6 +4614,17 @@ export class ClaudeAcpAgent {
|
|
|
4103
4614
|
const parentToolUseId = agentID
|
|
4104
4615
|
? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
|
|
4105
4616
|
: undefined;
|
|
4617
|
+
const permissionSessionId = clientSupportsSubagents(this.clientCapabilities) && agentID
|
|
4618
|
+
? (() => {
|
|
4619
|
+
const child = session.nativeSubagentsByTaskId?.get(agentID);
|
|
4620
|
+
// A request must never target a child session before its
|
|
4621
|
+
// subagent_spawned notification. In the rare SDK ordering where
|
|
4622
|
+
// canUseTool beats the spawning Agent/Task frame, keep the
|
|
4623
|
+
// permission on the root session; the later frame will announce
|
|
4624
|
+
// the child with correct nested lineage.
|
|
4625
|
+
return child?.announced ? child.sessionId : sessionId;
|
|
4626
|
+
})()
|
|
4627
|
+
: sessionId;
|
|
4106
4628
|
if (agentID && !parentToolUseId) {
|
|
4107
4629
|
// The attribution rests on an undocumented SDK invariant
|
|
4108
4630
|
// (task_started.task_id === canUseTool's agentID for subagent tasks;
|
|
@@ -4120,152 +4642,81 @@ export class ClaudeAcpAgent {
|
|
|
4120
4642
|
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
|
4121
4643
|
// Like permission requests, the elicitation references this toolUseID, so
|
|
4122
4644
|
// 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(
|
|
4645
|
+
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal, permissionSessionId);
|
|
4646
|
+
return this.handleAskUserQuestion(permissionSessionId, toolInput, toolUseID, signal);
|
|
4125
4647
|
}
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
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
|
-
],
|
|
4648
|
+
// Do not auto-allow here based on the session's advertised mode. Claude
|
|
4649
|
+
// Code applies bypassPermissions before invoking canUseTool; a request
|
|
4650
|
+
// that still reaches this callback is deliberately bypass-immune (for
|
|
4651
|
+
// example a safety check, a tool requiring user interaction, or an
|
|
4652
|
+
// explicit ask rule). Re-applying bypass in the host would erase that
|
|
4653
|
+
// provider safety decision.
|
|
4654
|
+
const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, matchedAskRule !== undefined);
|
|
4655
|
+
const presentation = buildClaudePermissionPresentation({
|
|
4656
|
+
toolName,
|
|
4657
|
+
input: toolInput,
|
|
4658
|
+
toolUseID,
|
|
4659
|
+
cwd: session.cwd,
|
|
4660
|
+
supportsTerminalOutput,
|
|
4661
|
+
blockedPath,
|
|
4662
|
+
title,
|
|
4663
|
+
displayName,
|
|
4664
|
+
description,
|
|
4665
|
+
decisionReason,
|
|
4666
|
+
});
|
|
4667
|
+
if (parentToolUseId) {
|
|
4668
|
+
presentation.toolCall._meta = {
|
|
4669
|
+
claudeCode: { toolName, parentToolUseId },
|
|
4211
4670
|
};
|
|
4212
4671
|
}
|
|
4672
|
+
const permissionOptions = buildClaudePermissionOptions({
|
|
4673
|
+
toolName,
|
|
4674
|
+
displayName,
|
|
4675
|
+
input: toolInput,
|
|
4676
|
+
cwd: session.cwd,
|
|
4677
|
+
durableChangeSet,
|
|
4678
|
+
allowPersistentOptions: matchedAskRule === undefined,
|
|
4679
|
+
availableModes: this.sessionModes.availableModeIds(session.modes),
|
|
4680
|
+
contextUsedPercent: session.contextUsedTokens === undefined || session.contextWindowSize <= 0
|
|
4681
|
+
? undefined
|
|
4682
|
+
: Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
|
|
4683
|
+
});
|
|
4213
4684
|
const response = await this.requestPermissionFromClient({
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
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") {
|
|
4685
|
+
...presentation,
|
|
4686
|
+
options: permissionOptions,
|
|
4687
|
+
sessionId: permissionSessionId,
|
|
4688
|
+
}, toolName, signal, parentToolUseId, sessionId);
|
|
4689
|
+
if (signal.aborted)
|
|
4239
4690
|
throw new Error("Tool use aborted");
|
|
4691
|
+
const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
|
|
4692
|
+
let permissionResult = decodedPermission.permissionResult;
|
|
4693
|
+
const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
|
|
4694
|
+
permissionResult = autoFallback.permissionResult;
|
|
4695
|
+
if (autoFallback.fallbackApplied) {
|
|
4696
|
+
await this.sessionModes.publishFallbackWarning(sessionId, session);
|
|
4240
4697
|
}
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
behavior: "allow",
|
|
4253
|
-
destination: "session",
|
|
4254
|
-
},
|
|
4255
|
-
],
|
|
4256
|
-
};
|
|
4257
|
-
}
|
|
4258
|
-
return {
|
|
4259
|
-
behavior: "allow",
|
|
4260
|
-
updatedInput: toolInput,
|
|
4698
|
+
const clearContextMode = decodedPermission.contextResetMode
|
|
4699
|
+
? this.sessionModes.effectiveMode(session, decodedPermission.contextResetMode)
|
|
4700
|
+
: undefined;
|
|
4701
|
+
if (toolName === "ExitPlanMode" && clearContextMode) {
|
|
4702
|
+
const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : "";
|
|
4703
|
+
if (!plan)
|
|
4704
|
+
throw new Error("ExitPlanMode clear-context selection requires a plan");
|
|
4705
|
+
session.pendingExitPlanContextReset = {
|
|
4706
|
+
toolUseId: toolUseID,
|
|
4707
|
+
plan,
|
|
4708
|
+
mode: clearContextMode,
|
|
4261
4709
|
};
|
|
4262
4710
|
}
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4711
|
+
if (toolName === "ExitPlanMode" &&
|
|
4712
|
+
permissionResult.behavior === "deny" &&
|
|
4713
|
+
permissionResult.interrupt === true) {
|
|
4714
|
+
session.pendingExitPlanModeInterruption = {
|
|
4715
|
+
toolUseId: toolUseID,
|
|
4716
|
+
toolResultSeen: false,
|
|
4267
4717
|
};
|
|
4268
4718
|
}
|
|
4719
|
+
return permissionResult;
|
|
4269
4720
|
};
|
|
4270
4721
|
}
|
|
4271
4722
|
/**
|
|
@@ -4284,7 +4735,7 @@ export class ClaudeAcpAgent {
|
|
|
4284
4735
|
return { action: "decline" };
|
|
4285
4736
|
}
|
|
4286
4737
|
try {
|
|
4287
|
-
const response = await this.client.
|
|
4738
|
+
const response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal));
|
|
4288
4739
|
if (signal.aborted) {
|
|
4289
4740
|
return { action: "cancel" };
|
|
4290
4741
|
}
|
|
@@ -4314,7 +4765,7 @@ export class ClaudeAcpAgent {
|
|
|
4314
4765
|
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
|
4315
4766
|
let response;
|
|
4316
4767
|
try {
|
|
4317
|
-
response = await this.client.
|
|
4768
|
+
response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(createRequest, signal));
|
|
4318
4769
|
}
|
|
4319
4770
|
catch (error) {
|
|
4320
4771
|
// A cancellation we requested (signal aborted) settles as an aborted tool
|
|
@@ -4354,7 +4805,7 @@ export class ClaudeAcpAgent {
|
|
|
4354
4805
|
}
|
|
4355
4806
|
let response;
|
|
4356
4807
|
try {
|
|
4357
|
-
response = await this.client.
|
|
4808
|
+
response = await this.withPendingUserInput(sessionId, () => this.client.createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal));
|
|
4358
4809
|
}
|
|
4359
4810
|
catch (error) {
|
|
4360
4811
|
// A cancellation we requested (signal aborted) is expected teardown;
|
|
@@ -4400,12 +4851,11 @@ export class ClaudeAcpAgent {
|
|
|
4400
4851
|
}
|
|
4401
4852
|
async applyConfigOptionValue(sessionId, session, configId, value) {
|
|
4402
4853
|
if (configId === MODE_CONFIG_ID) {
|
|
4403
|
-
|
|
4404
|
-
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
4854
|
+
this.sessionModes.syncConfig(session, value);
|
|
4405
4855
|
}
|
|
4406
4856
|
else if (configId === MODEL_CONFIG_ID) {
|
|
4407
|
-
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for
|
|
4408
|
-
//
|
|
4857
|
+
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for applying
|
|
4858
|
+
// the Auto fallback below; its `displayName`/`description` also let us infer the
|
|
4409
4859
|
// context window for semantic aliases (e.g. `default`) whose ID alone
|
|
4410
4860
|
// carries no "1m" token.
|
|
4411
4861
|
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
@@ -4427,41 +4877,7 @@ export class ClaudeAcpAgent {
|
|
|
4427
4877
|
session.contextWindowAuthoritative = seeded.authoritative;
|
|
4428
4878
|
}
|
|
4429
4879
|
session.models = { ...session.models, currentModelId: value };
|
|
4430
|
-
|
|
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
|
-
}
|
|
4880
|
+
const modeDowngraded = await this.sessionModes.reconcileForModel(session, newModelInfo);
|
|
4465
4881
|
// `model_not_allowed` described the model we just left, so it must not
|
|
4466
4882
|
// follow us onto the new one; the remaining reasons are account- or
|
|
4467
4883
|
// environment-scoped and stay true across a switch. Either way the next
|
|
@@ -4469,10 +4885,21 @@ export class ClaudeAcpAgent {
|
|
|
4469
4885
|
if (session.fastModeDisabledReason === "model_not_allowed") {
|
|
4470
4886
|
session.fastModeDisabledReason = undefined;
|
|
4471
4887
|
}
|
|
4472
|
-
// Rebuild config options since effort levels depend on the selected
|
|
4888
|
+
// Rebuild config options since effort levels depend on the selected
|
|
4889
|
+
// model. The effort seed depends on who owns the choice: a user pin
|
|
4890
|
+
// made through the ACP picker this session lives at the SDK's flag
|
|
4891
|
+
// layer and follows the session across switches, so carry it forward.
|
|
4892
|
+
// Otherwise the CLI resolves effort itself from the persisted settings
|
|
4893
|
+
// — the NEW model's `modelSettings` entry first (the CLI persists
|
|
4894
|
+
// /effort per model), then the legacy top-level value — so display
|
|
4895
|
+
// what it will actually run instead of dragging the old model's value
|
|
4896
|
+
// along.
|
|
4473
4897
|
const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
|
|
4474
4898
|
const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
|
4475
|
-
|
|
4899
|
+
const seedEffort = session.effortPinnedByUser
|
|
4900
|
+
? currentEffort
|
|
4901
|
+
: settingsEffortForModel(session.settingsManager.getSettings(), newModelInfo, value);
|
|
4902
|
+
session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, seedEffort, session.agents, session.currentAgent, {
|
|
4476
4903
|
// The toggle follows the newly selected model: it disappears when the
|
|
4477
4904
|
// model lacks fast support and reappears (with the retained user
|
|
4478
4905
|
// intent) when a supporting model is selected again.
|
|
@@ -4481,13 +4908,24 @@ export class ClaudeAcpAgent {
|
|
|
4481
4908
|
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
4482
4909
|
disabledReason: session.fastModeDisabledReason,
|
|
4483
4910
|
});
|
|
4484
|
-
// Sync effort with the SDK
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4911
|
+
// Sync effort with the SDK only when a user pin changed across the
|
|
4912
|
+
// switch — i.e. the new model clamped it away (buildConfigOptions
|
|
4913
|
+
// validated the seed against the new model's levels), where the flag
|
|
4914
|
+
// must be cleared too or the SDK would keep running the old pin
|
|
4915
|
+
// invisibly. Settings-derived seeds are display-only: the CLI resolves
|
|
4916
|
+
// persisted effort itself, and pinning it at the flag layer would
|
|
4917
|
+
// shadow the per-model values on every later switch.
|
|
4918
|
+
if (session.effortPinnedByUser) {
|
|
4919
|
+
const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
|
|
4920
|
+
const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
|
4921
|
+
if (newEffort !== currentEffort) {
|
|
4922
|
+
await session.query.applyFlagSettings({
|
|
4923
|
+
effortLevel: toSdkEffortLevel(newEffort),
|
|
4924
|
+
});
|
|
4925
|
+
if (newEffort === undefined || newEffort === "default") {
|
|
4926
|
+
session.effortPinnedByUser = false;
|
|
4927
|
+
}
|
|
4928
|
+
}
|
|
4491
4929
|
}
|
|
4492
4930
|
// Emit current_mode_update only after session.modes AND
|
|
4493
4931
|
// session.configOptions have been fully reconciled. This way, a failure
|
|
@@ -4496,13 +4934,7 @@ export class ClaudeAcpAgent {
|
|
|
4496
4934
|
// still precedes the caller's config_option_update so order-sensitive
|
|
4497
4935
|
// clients update currentModeId before re-rendering the option list.
|
|
4498
4936
|
if (modeDowngraded) {
|
|
4499
|
-
await this.
|
|
4500
|
-
sessionId,
|
|
4501
|
-
update: {
|
|
4502
|
-
sessionUpdate: "current_mode_update",
|
|
4503
|
-
currentModeId: "default",
|
|
4504
|
-
},
|
|
4505
|
-
});
|
|
4937
|
+
await this.sessionModes.publishFallbackState(sessionId, session);
|
|
4506
4938
|
}
|
|
4507
4939
|
}
|
|
4508
4940
|
else if (configId === AGENT_CONFIG_ID) {
|
|
@@ -4523,37 +4955,43 @@ export class ClaudeAcpAgent {
|
|
|
4523
4955
|
await session.query.applyFlagSettings({
|
|
4524
4956
|
effortLevel: toSdkEffortLevel(value),
|
|
4525
4957
|
});
|
|
4958
|
+
// "Default" clears the flag layer (toSdkEffortLevel → null), handing
|
|
4959
|
+
// effort back to the CLI's persisted per-model resolution — so it
|
|
4960
|
+
// un-pins; any other pick pins effort for the session.
|
|
4961
|
+
session.effortPinnedByUser = value !== "default";
|
|
4526
4962
|
}
|
|
4527
4963
|
}
|
|
4528
4964
|
}
|
|
4529
|
-
/** Reconcile adapter model state after the SDK
|
|
4530
|
-
*
|
|
4531
|
-
*
|
|
4532
|
-
*
|
|
4533
|
-
*
|
|
4534
|
-
*
|
|
4535
|
-
|
|
4965
|
+
/** Reconcile adapter model state after the SDK switched the session's
|
|
4966
|
+
* model out from under us — a refusal fallback, or any switch reported by
|
|
4967
|
+
* the PostModelSwitch hook that the adapter didn't drive (e.g. a `/model`
|
|
4968
|
+
* command typed as a prompt). The SDK already made the switch, so this
|
|
4969
|
+
* must NOT call `query.setModel` — it only updates our bookkeeping
|
|
4970
|
+
* (currentModelId, context window, mode clamping, effort/Fast-mode
|
|
4971
|
+
* options) via the same `applyConfigOptionValue` path a user-driven model
|
|
4972
|
+
* change takes, then notifies the client. */
|
|
4973
|
+
async syncModelAfterExternalSwitch(sessionId, session, switchedModel) {
|
|
4536
4974
|
// Map the SDK-reported model onto one of the session's model options
|
|
4537
|
-
// (handles display names and `resolvedModel` ids). The
|
|
4538
|
-
// not be among the options — e.g. excluded by the user's
|
|
4975
|
+
// (handles display names and `resolvedModel` ids). The switched-to model
|
|
4976
|
+
// may not be among the options — e.g. excluded by the user's
|
|
4539
4977
|
// `availableModels` allowlist — in which case we track the raw id: the
|
|
4540
4978
|
// picker shows no selection, but the model-dependent bookkeeping and any
|
|
4541
4979
|
// later `setModel` round-trip stay truthful to what the SDK is running.
|
|
4542
|
-
const resolved = resolveModelPreference(session.modelInfos,
|
|
4543
|
-
const value = resolved?.value ??
|
|
4980
|
+
const resolved = resolveModelPreference(session.modelInfos, switchedModel);
|
|
4981
|
+
const value = resolved?.value ?? switchedModel;
|
|
4544
4982
|
if (session.models.currentModelId === value)
|
|
4545
4983
|
return;
|
|
4546
4984
|
try {
|
|
4547
4985
|
await this.updateConfigOption(sessionId, MODEL_CONFIG_ID, value);
|
|
4548
4986
|
}
|
|
4549
4987
|
catch (err) {
|
|
4550
|
-
// This runs on the consumer loop
|
|
4551
|
-
//
|
|
4552
|
-
//
|
|
4553
|
-
//
|
|
4554
|
-
// move on, matching the
|
|
4555
|
-
// applyConfigOptionValue.
|
|
4556
|
-
this.logger.error(`Failed to reconcile model state after
|
|
4988
|
+
// This runs on the consumer loop (or detached from a hook callback): a
|
|
4989
|
+
// throw here tears down the query stream (failAllTurns +
|
|
4990
|
+
// closeQueryStream) and bricks the session — far worse than stale
|
|
4991
|
+
// bookkeeping. The user-driven RPC path lets the same errors propagate
|
|
4992
|
+
// to fail just that request; here we log and move on, matching the
|
|
4993
|
+
// setPermissionMode containment inside applyConfigOptionValue.
|
|
4994
|
+
this.logger.error(`Failed to reconcile model state after external switch to "${switchedModel}":`, err);
|
|
4557
4995
|
}
|
|
4558
4996
|
}
|
|
4559
4997
|
/** Replace the Fast mode option in `session.configOptions` so it reflects
|
|
@@ -4703,7 +5141,10 @@ export class ClaudeAcpAgent {
|
|
|
4703
5141
|
// We want to create a new session id unless it is resume,
|
|
4704
5142
|
// but not resume + forkSession.
|
|
4705
5143
|
let sessionId;
|
|
4706
|
-
if (creationOpts.
|
|
5144
|
+
if (creationOpts.publicSessionId) {
|
|
5145
|
+
sessionId = creationOpts.publicSessionId;
|
|
5146
|
+
}
|
|
5147
|
+
else if (creationOpts.forkSession) {
|
|
4707
5148
|
sessionId = randomUUID();
|
|
4708
5149
|
}
|
|
4709
5150
|
else if (creationOpts.resume) {
|
|
@@ -4762,10 +5203,12 @@ export class ClaudeAcpAgent {
|
|
|
4762
5203
|
}
|
|
4763
5204
|
}
|
|
4764
5205
|
const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
|
|
5206
|
+
const initialPermissionMode = creationOpts.permissionMode ?? permissionMode;
|
|
4765
5207
|
// Extract options from _meta if provided
|
|
4766
5208
|
const sessionMeta = params._meta;
|
|
4767
5209
|
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
4768
|
-
const forwardSubagentText =
|
|
5210
|
+
const forwardSubagentText = clientSupportsSubagents(this.clientCapabilities) ||
|
|
5211
|
+
supportsSubagentTranscript(this.clientCapabilities) ||
|
|
4769
5212
|
userProvidedOptions?.forwardSubagentText === true;
|
|
4770
5213
|
// Configure thinking behavior from environment variable
|
|
4771
5214
|
const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
|
|
@@ -4884,7 +5327,7 @@ export class ClaudeAcpAgent {
|
|
|
4884
5327
|
// If we want bypassPermissions to be an option, we have to allow it here.
|
|
4885
5328
|
// But it doesn't work in root mode, so we only activate it if it will work.
|
|
4886
5329
|
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
4887
|
-
permissionMode,
|
|
5330
|
+
permissionMode: initialPermissionMode,
|
|
4888
5331
|
canUseTool: this.canUseTool(sessionId),
|
|
4889
5332
|
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
|
4890
5333
|
// when the client advertised support, so non-supporting clients keep the
|
|
@@ -4928,19 +5371,50 @@ export class ClaudeAcpAgent {
|
|
|
4928
5371
|
hooks: [
|
|
4929
5372
|
createPostToolUseHook({
|
|
4930
5373
|
onEnterPlanMode: async () => {
|
|
4931
|
-
await this.
|
|
4932
|
-
sessionId,
|
|
4933
|
-
update: {
|
|
4934
|
-
sessionUpdate: "current_mode_update",
|
|
4935
|
-
currentModeId: "plan",
|
|
4936
|
-
},
|
|
4937
|
-
});
|
|
5374
|
+
await this.sessionModes.publishCurrent(sessionId, "plan");
|
|
4938
5375
|
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
|
|
4939
5376
|
},
|
|
4940
5377
|
}),
|
|
4941
5378
|
],
|
|
4942
5379
|
},
|
|
4943
5380
|
],
|
|
5381
|
+
// Mirror model switches the adapter didn't drive into the ACP picker
|
|
5382
|
+
// and the model-dependent bookkeeping (context window, mode clamping,
|
|
5383
|
+
// effort/Fast-mode options). Without this, a `/model <name>` command
|
|
5384
|
+
// typed as a prompt — which the CLI executes as a local command —
|
|
5385
|
+
// switches the session's model with no refusal-fallback frame, and
|
|
5386
|
+
// the client's picker silently drifts. `source: 'sdk'` is the
|
|
5387
|
+
// adapter's own setModel (applyConfigOptionValue already synced it),
|
|
5388
|
+
// and 'resume' restores are read back via readResumedLiveModel, so
|
|
5389
|
+
// only the remaining sources sync here (CLI 2.1.251+; older CLIs
|
|
5390
|
+
// never fire the hook and keep the pre-hook behavior).
|
|
5391
|
+
PostModelSwitch: [
|
|
5392
|
+
...(userProvidedOptions?.hooks?.PostModelSwitch || []),
|
|
5393
|
+
{
|
|
5394
|
+
hooks: [
|
|
5395
|
+
async (input) => {
|
|
5396
|
+
if (input.hook_event_name === "PostModelSwitch" &&
|
|
5397
|
+
input.source !== "sdk" &&
|
|
5398
|
+
input.source !== "resume") {
|
|
5399
|
+
// Don't run the sync before answering the hook: the sync
|
|
5400
|
+
// can issue applyFlagSettings (an outgoing control request)
|
|
5401
|
+
// while the CLI is awaiting this hook's response, and SDK
|
|
5402
|
+
// control requests are serialized over one channel.
|
|
5403
|
+
// syncModelAfterExternalSwitch contains its own errors, so
|
|
5404
|
+
// the detached call can't surface an unhandled rejection.
|
|
5405
|
+
const toModel = input.to_model;
|
|
5406
|
+
setImmediate(() => {
|
|
5407
|
+
const live = this.sessions[sessionId];
|
|
5408
|
+
if (!live)
|
|
5409
|
+
return;
|
|
5410
|
+
void this.syncModelAfterExternalSwitch(sessionId, live, toModel);
|
|
5411
|
+
});
|
|
5412
|
+
}
|
|
5413
|
+
return { continue: true };
|
|
5414
|
+
},
|
|
5415
|
+
],
|
|
5416
|
+
},
|
|
5417
|
+
],
|
|
4944
5418
|
...(fileChangeAuditSupport
|
|
4945
5419
|
? {
|
|
4946
5420
|
Stop: [
|
|
@@ -4972,7 +5446,8 @@ export class ClaudeAcpAgent {
|
|
|
4972
5446
|
},
|
|
4973
5447
|
],
|
|
4974
5448
|
},
|
|
4975
|
-
...creationOpts,
|
|
5449
|
+
...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
|
|
5450
|
+
...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
|
|
4976
5451
|
abortController,
|
|
4977
5452
|
};
|
|
4978
5453
|
// Prefer the official ACP `additionalDirectories` field. Fall back to the
|
|
@@ -4982,7 +5457,7 @@ export class ClaudeAcpAgent {
|
|
|
4982
5457
|
options.additionalDirectories = additionalDirectories;
|
|
4983
5458
|
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
|
4984
5459
|
// Set our own session id if not resuming an existing session.
|
|
4985
|
-
options.sessionId = sessionId;
|
|
5460
|
+
options.sessionId = creationOpts.publicSessionId ? randomUUID() : sessionId;
|
|
4986
5461
|
}
|
|
4987
5462
|
// Handle abort controller from meta options
|
|
4988
5463
|
if (abortController?.signal.aborted) {
|
|
@@ -5021,8 +5496,8 @@ export class ClaudeAcpAgent {
|
|
|
5021
5496
|
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
|
|
5022
5497
|
: initializationResult.models;
|
|
5023
5498
|
const { modelState: models, resumedContextWindow } = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
5024
|
-
//
|
|
5025
|
-
//
|
|
5499
|
+
// Resolve the current model's capabilities separately from the stable
|
|
5500
|
+
// permission-mode catalog advertised to ACP clients.
|
|
5026
5501
|
// A resumed session can be running a model outside the `availableModels`
|
|
5027
5502
|
// allowlist (currentModelId is then the verbatim live id, see
|
|
5028
5503
|
// `matchResumedModel`); its capabilities are still known to the SDK's
|
|
@@ -5042,7 +5517,7 @@ export class ClaudeAcpAgent {
|
|
|
5042
5517
|
// (current and future); the identity fields are overridden because the
|
|
5043
5518
|
// fuzzy-matched sibling's resolvedModel/displayName/description can
|
|
5044
5519
|
// describe a different context lane and would poison later resolvedModel
|
|
5045
|
-
// matching (
|
|
5520
|
+
// matching (syncModelAfterExternalSwitch) and context-window inference
|
|
5046
5521
|
// (applyConfigOptionValue) if they traveled under this id.
|
|
5047
5522
|
const modelInfos = fallbackModelInfo
|
|
5048
5523
|
? [
|
|
@@ -5056,38 +5531,12 @@ export class ClaudeAcpAgent {
|
|
|
5056
5531
|
},
|
|
5057
5532
|
]
|
|
5058
5533
|
: allowedModels;
|
|
5059
|
-
const
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
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
|
-
};
|
|
5534
|
+
const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
|
|
5535
|
+
query: q,
|
|
5536
|
+
requestedMode: initialPermissionMode,
|
|
5537
|
+
currentModelInfo,
|
|
5538
|
+
currentModelId: models.currentModelId,
|
|
5539
|
+
});
|
|
5091
5540
|
const agents = await discoverCustomAgents(q);
|
|
5092
5541
|
// Only adopt the requested agent as the selected value if it's one we
|
|
5093
5542
|
// actually surface in the picker. A built-in (filtered out above) or
|
|
@@ -5117,16 +5566,15 @@ export class ClaudeAcpAgent {
|
|
|
5117
5566
|
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
5118
5567
|
disabledReason: fastModeDisabledReason,
|
|
5119
5568
|
};
|
|
5120
|
-
|
|
5121
|
-
//
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
}
|
|
5569
|
+
// The Effort picker seed mirrors what the CLI itself resolves from the
|
|
5570
|
+
// persisted settings — the current model's `modelSettings` entry first
|
|
5571
|
+
// (the CLI persists /effort per model), then the legacy top-level value.
|
|
5572
|
+
// Display-only: no applyFlagSettings here. The CLI reads the same
|
|
5573
|
+
// settings, so pinning the seed at the flag layer was always redundant —
|
|
5574
|
+
// and it would override the CLI's own per-model restore on every later
|
|
5575
|
+
// model switch. Only a user's explicit ACP picker choice pins the flag
|
|
5576
|
+
// (see applyConfigOptionValue / Session.effortPinnedByUser).
|
|
5577
|
+
const configOptions = buildConfigOptions(modes, models, modelInfos, settingsEffortForModel(settingsManager.getSettings(), currentModelInfo), agents, currentAgent, fastMode);
|
|
5130
5578
|
// Seed the context window WITHOUT any extra IPC on the session/new path.
|
|
5131
5579
|
// On session/load, the resumed session's own `getContextUsage` report — a
|
|
5132
5580
|
// response `getAvailableModels` already awaited to learn the live model
|
|
@@ -5165,15 +5613,20 @@ export class ClaudeAcpAgent {
|
|
|
5165
5613
|
sessionFingerprint: computeSessionFingerprint(params),
|
|
5166
5614
|
creationParams: params,
|
|
5167
5615
|
settingsManager,
|
|
5616
|
+
titles: new SessionTitles(this, sessionId),
|
|
5168
5617
|
accumulatedUsage: {
|
|
5169
5618
|
inputTokens: 0,
|
|
5170
5619
|
outputTokens: 0,
|
|
5171
5620
|
cachedReadTokens: 0,
|
|
5172
5621
|
cachedWriteTokens: 0,
|
|
5173
5622
|
},
|
|
5623
|
+
accumulatedModelUsage: {},
|
|
5624
|
+
lastModelUsageReading: {},
|
|
5174
5625
|
modes,
|
|
5175
5626
|
models,
|
|
5176
5627
|
modelInfos,
|
|
5628
|
+
autoModeFallbackWarningShown: false,
|
|
5629
|
+
autoModeFallbackWarningPending,
|
|
5177
5630
|
configOptions,
|
|
5178
5631
|
agents,
|
|
5179
5632
|
currentAgent,
|
|
@@ -5188,7 +5641,11 @@ export class ClaudeAcpAgent {
|
|
|
5188
5641
|
taskState,
|
|
5189
5642
|
toolUseCache: {},
|
|
5190
5643
|
emittedToolCalls: new Set(),
|
|
5644
|
+
eagerToolCallSessions: new Map(),
|
|
5191
5645
|
liveBackgroundTasks: new Map(),
|
|
5646
|
+
nativeSubagentsByTaskId: new Map(),
|
|
5647
|
+
nativeSubagentTaskIdByToolUseId: new Map(),
|
|
5648
|
+
nativeSubagentParentByToolUseId: new Map(),
|
|
5192
5649
|
emittedAssistantText: false,
|
|
5193
5650
|
owedTrailingIdles: 0,
|
|
5194
5651
|
messageIdToUuid: new Map(),
|
|
@@ -5253,12 +5710,125 @@ function sessionUsage(session) {
|
|
|
5253
5710
|
outputTokens: session.accumulatedUsage.outputTokens,
|
|
5254
5711
|
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
|
|
5255
5712
|
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
|
|
5256
|
-
totalTokens: session.accumulatedUsage
|
|
5257
|
-
session.accumulatedUsage.outputTokens +
|
|
5258
|
-
session.accumulatedUsage.cachedReadTokens +
|
|
5259
|
-
session.accumulatedUsage.cachedWriteTokens,
|
|
5713
|
+
totalTokens: tallyTotal(session.accumulatedUsage),
|
|
5260
5714
|
};
|
|
5261
5715
|
}
|
|
5716
|
+
/** The prompt response for a turn ending in `stopReason`: its usage and the
|
|
5717
|
+
* `_meta.quota` breakdown, both read off the session accumulators in the same
|
|
5718
|
+
* breath so a stored outcome (see Turn.deferredSettle / Turn.steeredSettle)
|
|
5719
|
+
* can't carry a usage and a quota from different moments. `extraMeta` merges
|
|
5720
|
+
* in alongside quota — a terminal session failure, today. */
|
|
5721
|
+
function turnOutcome(session, stopReason, extraMeta) {
|
|
5722
|
+
return {
|
|
5723
|
+
stopReason,
|
|
5724
|
+
usage: sessionUsage(session),
|
|
5725
|
+
_meta: { ...turnQuotaMeta(session), ...extraMeta },
|
|
5726
|
+
};
|
|
5727
|
+
}
|
|
5728
|
+
/** `_meta.quota` for a prompt response: what the turn spent, shaped like
|
|
5729
|
+
* codex-acp's (snake_case container keys, camelCase counters) so a client
|
|
5730
|
+
* reads one shape from either agent.
|
|
5731
|
+
*
|
|
5732
|
+
* The two halves have different scopes, and by design don't have to add up.
|
|
5733
|
+
* `token_count` mirrors the response's own `usage`, which the SDK reports for
|
|
5734
|
+
* the MAIN AGENT LOOP only. The `model_usage` rows come from
|
|
5735
|
+
* `result.modelUsage`, which also counts Task subagents, sidechains and
|
|
5736
|
+
* internal calls such as compaction — the accounting-grade figure per the SDK,
|
|
5737
|
+
* so the rows can total more than `token_count`. They are the fuller picture,
|
|
5738
|
+
* not a decomposition of it. */
|
|
5739
|
+
function turnQuotaMeta(session) {
|
|
5740
|
+
return {
|
|
5741
|
+
quota: {
|
|
5742
|
+
token_count: quotaTokenCount(session.accumulatedUsage),
|
|
5743
|
+
model_usage: Object.entries(session.accumulatedModelUsage ?? {}).map(([model, usage]) => ({
|
|
5744
|
+
model,
|
|
5745
|
+
token_count: quotaTokenCount(usage),
|
|
5746
|
+
})),
|
|
5747
|
+
},
|
|
5748
|
+
};
|
|
5749
|
+
}
|
|
5750
|
+
/** One `token_count` object. `cachedInputTokens` is cache reads, matching
|
|
5751
|
+
* codex's field; Claude also reports cache writes, which codex has no slot
|
|
5752
|
+
* for, so those ride along in an extra sibling (the same name the ACP `usage`
|
|
5753
|
+
* field already uses) and are counted in `totalTokens`. `reasoningOutputTokens`
|
|
5754
|
+
* is always 0: Claude bills thinking inside its output tokens and never breaks
|
|
5755
|
+
* it out — the key is kept so the shape stays uniform across agents. */
|
|
5756
|
+
function quotaTokenCount(usage) {
|
|
5757
|
+
return {
|
|
5758
|
+
totalTokens: tallyTotal(usage),
|
|
5759
|
+
inputTokens: usage.inputTokens,
|
|
5760
|
+
cachedInputTokens: usage.cachedReadTokens,
|
|
5761
|
+
cachedWriteTokens: usage.cachedWriteTokens,
|
|
5762
|
+
outputTokens: usage.outputTokens,
|
|
5763
|
+
reasoningOutputTokens: 0,
|
|
5764
|
+
};
|
|
5765
|
+
}
|
|
5766
|
+
/** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes
|
|
5767
|
+
* the cache counters, so this is not double-counting (see `totalTokens`). */
|
|
5768
|
+
function tallyTotal(usage) {
|
|
5769
|
+
return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens;
|
|
5770
|
+
}
|
|
5771
|
+
/** Project `result.modelUsage` into our tally shape. Counters are coerced the
|
|
5772
|
+
* way `snapshotFromUsage` coerces the stream's: third-party backends have been
|
|
5773
|
+
* observed omitting fields, and a missing or NaN counter must not reach the
|
|
5774
|
+
* wire as NaN (which `JSON.stringify` writes as `null`). */
|
|
5775
|
+
function normalizeModelUsage(modelUsage) {
|
|
5776
|
+
const tally = {};
|
|
5777
|
+
for (const [model, usage] of Object.entries(modelUsage ?? {})) {
|
|
5778
|
+
tally[model] = {
|
|
5779
|
+
inputTokens: finiteCount(usage?.inputTokens),
|
|
5780
|
+
outputTokens: finiteCount(usage?.outputTokens),
|
|
5781
|
+
cachedReadTokens: finiteCount(usage?.cacheReadInputTokens),
|
|
5782
|
+
cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens),
|
|
5783
|
+
};
|
|
5784
|
+
}
|
|
5785
|
+
return tally;
|
|
5786
|
+
}
|
|
5787
|
+
function finiteCount(value) {
|
|
5788
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
5789
|
+
}
|
|
5790
|
+
/** `current - previous` per model, dropping models with nothing to report so a
|
|
5791
|
+
* turn only lists the models it actually ran on. A reading that fell BELOW the
|
|
5792
|
+
* previous one means the running total restarted under us (a mid-session
|
|
5793
|
+
* /clear, a resumed session starting fresh, a zeroed crash result): there is no
|
|
5794
|
+
* usable reference left to subtract, so the reading itself is the increment. */
|
|
5795
|
+
function modelUsageIncrement(current, previous) {
|
|
5796
|
+
const increment = {};
|
|
5797
|
+
for (const [model, usage] of Object.entries(current)) {
|
|
5798
|
+
const base = previous[model];
|
|
5799
|
+
const subtracted = base
|
|
5800
|
+
? {
|
|
5801
|
+
inputTokens: usage.inputTokens - base.inputTokens,
|
|
5802
|
+
outputTokens: usage.outputTokens - base.outputTokens,
|
|
5803
|
+
cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens,
|
|
5804
|
+
cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens,
|
|
5805
|
+
}
|
|
5806
|
+
: usage;
|
|
5807
|
+
const rewound = Object.values(subtracted).some((count) => count < 0);
|
|
5808
|
+
const resolved = rewound ? usage : subtracted;
|
|
5809
|
+
if (tallyTotal(resolved) > 0) {
|
|
5810
|
+
increment[model] = resolved;
|
|
5811
|
+
}
|
|
5812
|
+
}
|
|
5813
|
+
return increment;
|
|
5814
|
+
}
|
|
5815
|
+
/** Fold `increment` into `base` per model — the per-model counterpart of the
|
|
5816
|
+
* `+=` the turn's flat accumulator uses. */
|
|
5817
|
+
function addModelUsage(base, increment) {
|
|
5818
|
+
const merged = { ...base };
|
|
5819
|
+
for (const [model, usage] of Object.entries(increment)) {
|
|
5820
|
+
const existing = merged[model];
|
|
5821
|
+
merged[model] = existing
|
|
5822
|
+
? {
|
|
5823
|
+
inputTokens: existing.inputTokens + usage.inputTokens,
|
|
5824
|
+
outputTokens: existing.outputTokens + usage.outputTokens,
|
|
5825
|
+
cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens,
|
|
5826
|
+
cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens,
|
|
5827
|
+
}
|
|
5828
|
+
: usage;
|
|
5829
|
+
}
|
|
5830
|
+
return merged;
|
|
5831
|
+
}
|
|
5262
5832
|
/** Sum all four fields as a proxy for post-turn context occupancy: the current
|
|
5263
5833
|
* turn's output becomes next turn's input. Per the Anthropic API, input_tokens
|
|
5264
5834
|
* excludes cache tokens — cache_read and cache_creation are reported
|
|
@@ -5384,50 +5954,6 @@ function isValidBaseUrl(baseUrl) {
|
|
|
5384
5954
|
}
|
|
5385
5955
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
5386
5956
|
}
|
|
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
5957
|
// Translate a UI effort value into the flag-layer payload. The SDK
|
|
5432
5958
|
// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
|
|
5433
5959
|
// and only clears a key when an explicit `null` is sent — see
|
|
@@ -5440,6 +5966,27 @@ function buildAvailableModes(modelInfo) {
|
|
|
5440
5966
|
function toSdkEffortLevel(value) {
|
|
5441
5967
|
return value === undefined || value === "default" ? null : value;
|
|
5442
5968
|
}
|
|
5969
|
+
/** The effort level the CLI itself resolves for a model from the persisted
|
|
5970
|
+
* settings: the per-model entry (`modelSettings`, keyed by canonical model
|
|
5971
|
+
* name — the CLI persists /effort per model since 2.1.243) wins over the
|
|
5972
|
+
* legacy top-level `effortLevel`. Alias picker rows are looked up by their
|
|
5973
|
+
* resolved model id first, then the row value, then the raw config value
|
|
5974
|
+
* for models not in the picker. Exact keys only — the CLI's canonical-form
|
|
5975
|
+
* fallback matching isn't replicated, so a miss simply falls back to the
|
|
5976
|
+
* top-level value (best-effort display; `buildConfigOptions` still
|
|
5977
|
+
* validates the result against the model's supported levels). */
|
|
5978
|
+
export function settingsEffortForModel(settings, modelInfo, modelId) {
|
|
5979
|
+
const modelSettings = settings.modelSettings;
|
|
5980
|
+
if (modelSettings) {
|
|
5981
|
+
for (const key of [modelInfo?.resolvedModel, modelInfo?.value, modelId]) {
|
|
5982
|
+
const perModel = key !== undefined ? modelSettings[key]?.effortLevel : undefined;
|
|
5983
|
+
if (typeof perModel === "string") {
|
|
5984
|
+
return perModel;
|
|
5985
|
+
}
|
|
5986
|
+
}
|
|
5987
|
+
}
|
|
5988
|
+
return settings.effortLevel;
|
|
5989
|
+
}
|
|
5443
5990
|
// `supportedAgents()` always returns Claude Code's built-in subagents — the
|
|
5444
5991
|
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
|
|
5445
5992
|
// has configured none of their own. Those aren't meaningful *main-thread*
|
|
@@ -5458,7 +6005,6 @@ export const BUILTIN_AGENT_NAMES = new Set([
|
|
|
5458
6005
|
// reserved sentinel: a custom agent named exactly this would collide with it
|
|
5459
6006
|
// (two options sharing the value, selection silently routing to `null`), so we
|
|
5460
6007
|
// exclude that name from discovery.
|
|
5461
|
-
export const DEFAULT_AGENT_ID = "default";
|
|
5462
6008
|
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
|
5463
6009
|
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
|
5464
6010
|
* list if discovery fails so a flaky control request never blocks session
|
|
@@ -5476,9 +6022,8 @@ export async function discoverCustomAgents(q) {
|
|
|
5476
6022
|
* Centralized so the option declarations in `buildConfigOptions` and the
|
|
5477
6023
|
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
|
|
5478
6024
|
* same identifiers and can't drift apart. */
|
|
5479
|
-
export
|
|
6025
|
+
export { MODE_CONFIG_ID };
|
|
5480
6026
|
export const MODEL_CONFIG_ID = "model";
|
|
5481
|
-
export const EFFORT_CONFIG_ID = "effort";
|
|
5482
6027
|
export const AGENT_CONFIG_ID = "agent";
|
|
5483
6028
|
export const FAST_MODE_CONFIG_ID = "fast";
|
|
5484
6029
|
/** Select-fallback values used when the client has not opted into boolean
|
|
@@ -5577,19 +6122,7 @@ export function resolveFastModeEnabled(params) {
|
|
|
5577
6122
|
}
|
|
5578
6123
|
export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
|
|
5579
6124
|
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
|
-
},
|
|
6125
|
+
SessionModeManager.configOption(modes),
|
|
5593
6126
|
{
|
|
5594
6127
|
id: MODEL_CONFIG_ID,
|
|
5595
6128
|
name: "Model",
|
|
@@ -5825,7 +6358,7 @@ export function resolveModelPreference(models, preference) {
|
|
|
5825
6358
|
* 4. `resolveModelPreference` over the picker entries.
|
|
5826
6359
|
* 5. A model with no picker counterpart (e.g. excluded by an
|
|
5827
6360
|
* `availableModels` allowlist) is tracked verbatim, mirroring
|
|
5828
|
-
* `
|
|
6361
|
+
* `syncModelAfterExternalSwitch`: the picker shows no selection, but the
|
|
5829
6362
|
* model-dependent bookkeeping stays truthful to what the SDK is running. */
|
|
5830
6363
|
export function matchResumedModel(models, liveModel) {
|
|
5831
6364
|
const live = canonicalizeModelId(liveModel);
|
|
@@ -5942,6 +6475,14 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
|
|
|
5942
6475
|
* #596). Best-effort: a control-request failure is logged and returns nulls
|
|
5943
6476
|
* so callers keep their current choice; failing the whole session/load over
|
|
5944
6477
|
* an unreadable report would be worse. */
|
|
6478
|
+
/** Whether a rejected `setModel` was vetoed by a user-configured
|
|
6479
|
+
* PreModelSwitch hook. The CLI's rejection message is the stable,
|
|
6480
|
+
* distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
|
|
6481
|
+
* <reason>"); a format change makes this stop matching and the failure
|
|
6482
|
+
* falls back to the ordinary fail-loud path, no worse than before. */
|
|
6483
|
+
function isPreModelSwitchHookBlock(error) {
|
|
6484
|
+
return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook");
|
|
6485
|
+
}
|
|
5945
6486
|
async function readResumedLiveModel(query, models, logger) {
|
|
5946
6487
|
try {
|
|
5947
6488
|
const usage = await query.getContextUsage();
|
|
@@ -6023,12 +6564,29 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
|
|
|
6023
6564
|
// containment in createSession). The SDK then stayed on the
|
|
6024
6565
|
// transcript's model, so read that back rather than reporting the
|
|
6025
6566
|
// pin the session isn't running.
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6567
|
+
//
|
|
6568
|
+
// One fresh-session failure is advisory, not defining: a
|
|
6569
|
+
// user-configured PreModelSwitch hook can veto the pin (CLI 2.1.251+;
|
|
6570
|
+
// 'deny', or 'ask' — which headless sessions refuse), and the SDK
|
|
6571
|
+
// rejects setModel with "Model switch blocked by a PreModelSwitch
|
|
6572
|
+
// hook: …". Terminal Claude Code never lets a hook veto its startup
|
|
6573
|
+
// model (the spawn model isn't a switch), so failing session/new here
|
|
6574
|
+
// would make the same hook config fatal only over ACP. The session
|
|
6575
|
+
// stays on the SDK's own default — report that. We can't read the
|
|
6576
|
+
// live model back on this path: getContextUsage isn't serviced on a
|
|
6577
|
+
// fresh session until the first prompt turn (issues #886/#880).
|
|
6578
|
+
if (!isResumedSession) {
|
|
6579
|
+
if (!isPreModelSwitchHookBlock(error))
|
|
6580
|
+
throw error;
|
|
6581
|
+
logger.error(`Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`, error);
|
|
6582
|
+
currentModel = models[0];
|
|
6583
|
+
}
|
|
6584
|
+
else {
|
|
6585
|
+
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
|
|
6586
|
+
const live = await readResumedLiveModel(query, models, logger);
|
|
6587
|
+
currentModel = live.model ?? currentModel;
|
|
6588
|
+
resumedContextWindow = live.contextWindow;
|
|
6589
|
+
}
|
|
6032
6590
|
}
|
|
6033
6591
|
}
|
|
6034
6592
|
return {
|
|
@@ -6258,6 +6816,46 @@ function claudeCodeMetaFromToolUse(toolUse, cwd) {
|
|
|
6258
6816
|
...(skillPath ? { skillPath } : {}),
|
|
6259
6817
|
};
|
|
6260
6818
|
}
|
|
6819
|
+
/**
|
|
6820
|
+
* Marks the Bash `tool_call_update` whose command detached into the background.
|
|
6821
|
+
*
|
|
6822
|
+
* A backgrounded Bash call returns as soon as the command is handed off, so the
|
|
6823
|
+
* card reaches `completed` while the command itself runs on for minutes. ACP has
|
|
6824
|
+
* no tool-call status for "still running elsewhere", so this marker is what lets
|
|
6825
|
+
* a client render the card as backgrounded work instead of finished work. It
|
|
6826
|
+
* rides the update the tool result already emits, so it costs no extra
|
|
6827
|
+
* notification and cannot arrive out of order.
|
|
6828
|
+
*
|
|
6829
|
+
* The command's own lifecycle -- progress, completion, the stop control -- is
|
|
6830
|
+
* published separately as an async task; this says only that the card has one.
|
|
6831
|
+
* Hence the AIR namespace rather than `claudeCode`: to a client without the
|
|
6832
|
+
* `asyncTasks` capability, which is never sent that lifecycle, the marker would
|
|
6833
|
+
* promise a card state it has no way to ever resolve.
|
|
6834
|
+
*/
|
|
6835
|
+
function backgroundedBashToolCall(notification, task, asyncTasksSupported) {
|
|
6836
|
+
const update = notification.update;
|
|
6837
|
+
const toolCallId = task ? nonBlankTaskField(task.toolCallId ?? task.tool_use_id) : undefined;
|
|
6838
|
+
if (!asyncTasksSupported ||
|
|
6839
|
+
!toolCallId ||
|
|
6840
|
+
update.sessionUpdate !== "tool_call_update" ||
|
|
6841
|
+
update.toolCallId !== toolCallId) {
|
|
6842
|
+
return notification;
|
|
6843
|
+
}
|
|
6844
|
+
return {
|
|
6845
|
+
...notification,
|
|
6846
|
+
update: {
|
|
6847
|
+
...update,
|
|
6848
|
+
_meta: withAirMeta(update._meta, AIR_ASYNC_TASKS_CAPABILITY, { backgrounded: true }),
|
|
6849
|
+
},
|
|
6850
|
+
};
|
|
6851
|
+
}
|
|
6852
|
+
/** The task fields arrive as `unknown` off the wire; only non-blank strings carry a link. */
|
|
6853
|
+
function nonBlankTaskField(value) {
|
|
6854
|
+
if (typeof value !== "string")
|
|
6855
|
+
return undefined;
|
|
6856
|
+
const trimmed = value.trim();
|
|
6857
|
+
return trimmed ? trimmed : undefined;
|
|
6858
|
+
}
|
|
6261
6859
|
/** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
|
|
6262
6860
|
const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
|
|
6263
6861
|
/**
|
|
@@ -6349,33 +6947,6 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
6349
6947
|
...(locations ? { locations } : {}),
|
|
6350
6948
|
};
|
|
6351
6949
|
}
|
|
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
6950
|
/**
|
|
6380
6951
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
6381
6952
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -6523,6 +7094,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6523
7094
|
claudeCode: {
|
|
6524
7095
|
toolResponse,
|
|
6525
7096
|
toolName,
|
|
7097
|
+
...(options?.parentToolUseId
|
|
7098
|
+
? { parentToolUseId: options.parentToolUseId }
|
|
7099
|
+
: {}),
|
|
6526
7100
|
},
|
|
6527
7101
|
},
|
|
6528
7102
|
toolCallId: toolUseId,
|
|
@@ -6534,7 +7108,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6534
7108
|
update,
|
|
6535
7109
|
});
|
|
6536
7110
|
},
|
|
6537
|
-
});
|
|
7111
|
+
}, sessionId);
|
|
6538
7112
|
}
|
|
6539
7113
|
let rawInput;
|
|
6540
7114
|
try {
|
|
@@ -6576,6 +7150,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6576
7150
|
case "mcp_tool_result": {
|
|
6577
7151
|
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
|
|
6578
7152
|
options?.emittedToolCalls?.delete(chunk.tool_use_id);
|
|
7153
|
+
completeHookCallback(chunk.tool_use_id);
|
|
6579
7154
|
// Why this is_error result carries harness prose instead of tool
|
|
6580
7155
|
// output (user-rejected / interrupted / …), when the SDK said so.
|
|
6581
7156
|
// Spread into the claudeCode meta of every update emitted below; the
|
|
@@ -6713,7 +7288,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6713
7288
|
toolCallId: chunk.tool_use_id,
|
|
6714
7289
|
sessionUpdate: "tool_call_update",
|
|
6715
7290
|
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
|
6716
|
-
|
|
7291
|
+
// terminal_output already carried the exact bytes in the preceding
|
|
7292
|
+
// update. Repeating them as rawOutput wastes bandwidth and lets a
|
|
7293
|
+
// client accidentally render the same output twice.
|
|
7294
|
+
...(toolMeta?.terminal_output
|
|
7295
|
+
? {}
|
|
7296
|
+
: { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
|
|
6717
7297
|
...toolUpdate,
|
|
6718
7298
|
};
|
|
6719
7299
|
}
|
|
@@ -6734,7 +7314,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6734
7314
|
case "compaction":
|
|
6735
7315
|
case "compaction_delta":
|
|
6736
7316
|
case "advisor_tool_result":
|
|
6737
|
-
case "mid_conv_system":
|
|
6738
7317
|
case "fallback":
|
|
6739
7318
|
break;
|
|
6740
7319
|
default:
|
|
@@ -6922,6 +7501,7 @@ export function runAcp(logger) {
|
|
|
6922
7501
|
.onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
|
|
6923
7502
|
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
|
6924
7503
|
.onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
|
|
7504
|
+
.onRequest(ASYNC_TASK_STOP_METHOD, { parse: parseAsyncTaskStopRequest }, (ctx) => agent.stopAsyncTask(ctx.params))
|
|
6925
7505
|
.onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
|
|
6926
7506
|
.connect(stream);
|
|
6927
7507
|
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|