@agentclientprotocol/claude-agent-acp 0.69.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -9
- package/dist/acp-agent.d.ts +118 -39
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +1199 -682
- 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/index.js +24 -1
- 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 +39 -18
- package/package.json +6 -6
package/dist/acp-agent.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
|
-
import { deleteSession,
|
|
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, {
|
|
@@ -570,14 +507,152 @@ class ClientConnection {
|
|
|
570
507
|
return this.ctx.notify(method, params);
|
|
571
508
|
}
|
|
572
509
|
}
|
|
510
|
+
function raceWithAbort(operation, signal) {
|
|
511
|
+
return new Promise((resolve, reject) => {
|
|
512
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
513
|
+
const onAbort = () => {
|
|
514
|
+
cleanup();
|
|
515
|
+
reject(new Error("Tool use aborted"));
|
|
516
|
+
};
|
|
517
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
518
|
+
if (signal.aborted) {
|
|
519
|
+
onAbort();
|
|
520
|
+
}
|
|
521
|
+
void operation.then((value) => {
|
|
522
|
+
cleanup();
|
|
523
|
+
resolve(value);
|
|
524
|
+
}, (error) => {
|
|
525
|
+
cleanup();
|
|
526
|
+
reject(error);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
/** Background OAuth work is implementation state, not part of the public
|
|
531
|
+
* `Session` shape exposed through `ClaudeAcpAgent.sessions`. */
|
|
532
|
+
const mcpAuthentications = new WeakMap();
|
|
533
|
+
/** Start OAuth for ACP-provided MCP servers that Claude reported as needing
|
|
534
|
+
* authentication. This runs after session creation has returned so an
|
|
535
|
+
* interactive browser flow never delays `session/new`. */
|
|
536
|
+
function startMcpAuthentication(host, sessionId, mcpServers) {
|
|
537
|
+
if (!host.clientCapabilities?.elicitation?.url || mcpServers.length === 0)
|
|
538
|
+
return;
|
|
539
|
+
const session = host.sessions[sessionId];
|
|
540
|
+
if (!session || mcpAuthentications.has(session))
|
|
541
|
+
return;
|
|
542
|
+
const requestedServers = new Set(mcpServers.map((server) => server.name));
|
|
543
|
+
const authentication = authenticateMcpServers(host, sessionId, session.query, requestedServers)
|
|
544
|
+
.catch((error) => {
|
|
545
|
+
if (!session.abortController.signal.aborted) {
|
|
546
|
+
host.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`);
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
.finally(() => {
|
|
550
|
+
if (mcpAuthentications.get(session) === authentication) {
|
|
551
|
+
mcpAuthentications.delete(session);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
mcpAuthentications.set(session, authentication);
|
|
555
|
+
}
|
|
556
|
+
async function authenticateMcpServers(host, sessionId, query, requestedServers) {
|
|
557
|
+
if (!supportsMcpOAuth(query)) {
|
|
558
|
+
host.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication.");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const statuses = await query.mcpServerStatus();
|
|
562
|
+
for (const status of statuses) {
|
|
563
|
+
if (status.status !== "needs-auth" || !requestedServers.has(status.name))
|
|
564
|
+
continue;
|
|
565
|
+
try {
|
|
566
|
+
await authenticateMcpServer(host, sessionId, query, status.name);
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
const session = host.sessions[sessionId];
|
|
570
|
+
if (session && !session.abortController.signal.aborted) {
|
|
571
|
+
host.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation.
|
|
577
|
+
* Claude opens and owns the localhost callback listener; the ACP client only
|
|
578
|
+
* needs to present the returned authorization URL. */
|
|
579
|
+
async function authenticateMcpServer(host, sessionId, query, serverName) {
|
|
580
|
+
const session = host.sessions[sessionId];
|
|
581
|
+
if (!session)
|
|
582
|
+
return;
|
|
583
|
+
const login = await query.mcpAuthenticate(serverName);
|
|
584
|
+
if (!login.requiresUserAction)
|
|
585
|
+
return;
|
|
586
|
+
if (!login.authUrl) {
|
|
587
|
+
throw new Error("Claude Code requested user action without returning an authorization URL");
|
|
588
|
+
}
|
|
589
|
+
const elicitationId = `mcp-oauth-${randomUUID()}`;
|
|
590
|
+
const flowAbort = new AbortController();
|
|
591
|
+
const abortFlow = () => flowAbort.abort(session.abortController.signal.reason);
|
|
592
|
+
session.abortController.signal.addEventListener("abort", abortFlow, { once: true });
|
|
593
|
+
if (session.abortController.signal.aborted)
|
|
594
|
+
abortFlow();
|
|
595
|
+
try {
|
|
596
|
+
const completed = waitForMcpAuthentication(host.sessions, sessionId, query, serverName, flowAbort.signal);
|
|
597
|
+
const elicitation = host.client.unstable_createElicitation({
|
|
598
|
+
mode: "url",
|
|
599
|
+
sessionId,
|
|
600
|
+
message: `Authenticate with MCP server ${serverName}`,
|
|
601
|
+
url: login.authUrl,
|
|
602
|
+
elicitationId,
|
|
603
|
+
}, flowAbort.signal);
|
|
604
|
+
const first = await Promise.race([
|
|
605
|
+
completed.then((authenticated) => ({ type: "completed", authenticated })),
|
|
606
|
+
elicitation.then((response) => ({ type: "elicitation", response })),
|
|
607
|
+
]);
|
|
608
|
+
if (first.type === "elicitation" && !CreateElicitationResponse.isAccept(first.response)) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (first.type === "elicitation") {
|
|
612
|
+
await completed;
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
await host.client.unstable_completeElicitation({ elicitationId });
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
if (!flowAbort.signal.aborted) {
|
|
619
|
+
host.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
finally {
|
|
624
|
+
flowAbort.abort();
|
|
625
|
+
session.abortController.signal.removeEventListener("abort", abortFlow);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async function waitForMcpAuthentication(sessions, sessionId, query, serverName, signal) {
|
|
629
|
+
const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS;
|
|
630
|
+
while (!signal.aborted && Date.now() < deadline) {
|
|
631
|
+
const session = sessions[sessionId];
|
|
632
|
+
if (!session || session.query !== query)
|
|
633
|
+
return false;
|
|
634
|
+
const status = (await query.mcpServerStatus()).find((server) => server.name === serverName);
|
|
635
|
+
if (!status || status.status === "failed" || status.status === "disabled")
|
|
636
|
+
return false;
|
|
637
|
+
if (status.status === "connected")
|
|
638
|
+
return true;
|
|
639
|
+
if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal)))
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
573
644
|
export class ClaudeAcpAgent {
|
|
574
645
|
sessions;
|
|
575
646
|
client;
|
|
576
647
|
clientCapabilities;
|
|
577
648
|
logger;
|
|
649
|
+
sessionModes;
|
|
578
650
|
gatewayAuthRequest;
|
|
579
651
|
/** Set while ACP overrides the agent's native provider configuration. */
|
|
580
652
|
providerConfig;
|
|
653
|
+
/** Serializes provider changes while every open query is recreated between turns. */
|
|
654
|
+
providerUpdate = null;
|
|
655
|
+
exitPlan;
|
|
581
656
|
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
582
657
|
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
583
658
|
* tests can shrink it. */
|
|
@@ -586,6 +661,52 @@ export class ClaudeAcpAgent {
|
|
|
586
661
|
this.sessions = {};
|
|
587
662
|
this.client = client;
|
|
588
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
|
+
});
|
|
589
710
|
}
|
|
590
711
|
async initialize(request) {
|
|
591
712
|
this.clientCapabilities = request.clientCapabilities;
|
|
@@ -684,6 +805,15 @@ export class ClaudeAcpAgent {
|
|
|
684
805
|
terminalAuthMethods.push(consoleLoginMethod);
|
|
685
806
|
}
|
|
686
807
|
}
|
|
808
|
+
const sessionCapabilities = {
|
|
809
|
+
additionalDirectories: {},
|
|
810
|
+
close: {},
|
|
811
|
+
delete: {},
|
|
812
|
+
fork: {},
|
|
813
|
+
list: {},
|
|
814
|
+
resume: {},
|
|
815
|
+
subagents: {},
|
|
816
|
+
};
|
|
687
817
|
return {
|
|
688
818
|
protocolVersion: 1,
|
|
689
819
|
agentCapabilities: {
|
|
@@ -708,14 +838,7 @@ export class ClaudeAcpAgent {
|
|
|
708
838
|
// capability prerequisite for the provider methods.
|
|
709
839
|
providers: {},
|
|
710
840
|
loadSession: true,
|
|
711
|
-
sessionCapabilities
|
|
712
|
-
additionalDirectories: {},
|
|
713
|
-
close: {},
|
|
714
|
-
delete: {},
|
|
715
|
-
fork: {},
|
|
716
|
-
list: {},
|
|
717
|
-
resume: {},
|
|
718
|
-
},
|
|
841
|
+
sessionCapabilities,
|
|
719
842
|
},
|
|
720
843
|
agentInfo: {
|
|
721
844
|
name: packageJson.name,
|
|
@@ -730,7 +853,7 @@ export class ClaudeAcpAgent {
|
|
|
730
853
|
// steering extension contract: advertises the `_session/steering` request
|
|
731
854
|
// so clients know they may inject a follow-up into a running turn.
|
|
732
855
|
_meta: {
|
|
733
|
-
...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
|
|
856
|
+
...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY),
|
|
734
857
|
steering: {
|
|
735
858
|
supported: true,
|
|
736
859
|
},
|
|
@@ -743,6 +866,8 @@ export class ClaudeAcpAgent {
|
|
|
743
866
|
};
|
|
744
867
|
}
|
|
745
868
|
async newSession(params) {
|
|
869
|
+
if (this.providerUpdate)
|
|
870
|
+
await this.providerUpdate;
|
|
746
871
|
const response = await this.createSession(params, {
|
|
747
872
|
// Revisit these meta values once we support resume
|
|
748
873
|
resume: params._meta?.claudeCode?.options?.resume,
|
|
@@ -750,39 +875,38 @@ export class ClaudeAcpAgent {
|
|
|
750
875
|
// Needs to happen after we return the session
|
|
751
876
|
setTimeout(() => {
|
|
752
877
|
this.sendAvailableCommandsUpdate(response.sessionId);
|
|
878
|
+
startMcpAuthentication(this, response.sessionId, params.mcpServers);
|
|
753
879
|
}, 0);
|
|
754
880
|
return response;
|
|
755
881
|
}
|
|
756
882
|
async unstable_forkSession(params) {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}, {
|
|
763
|
-
resume: params.sessionId,
|
|
764
|
-
forkSession: true,
|
|
883
|
+
if (this.providerUpdate)
|
|
884
|
+
await this.providerUpdate;
|
|
885
|
+
return forkSession(params, {
|
|
886
|
+
liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
|
|
887
|
+
messageIdForGrouping,
|
|
765
888
|
});
|
|
766
|
-
// Needs to happen after we return the session
|
|
767
|
-
setTimeout(() => {
|
|
768
|
-
this.sendAvailableCommandsUpdate(response.sessionId);
|
|
769
|
-
}, 0);
|
|
770
|
-
return response;
|
|
771
889
|
}
|
|
772
890
|
async resumeSession(params) {
|
|
891
|
+
if (this.providerUpdate)
|
|
892
|
+
await this.providerUpdate;
|
|
773
893
|
const result = await this.getOrCreateSession(params);
|
|
774
894
|
// Needs to happen after we return the session
|
|
775
895
|
setTimeout(() => {
|
|
776
896
|
this.sendAvailableCommandsUpdate(params.sessionId);
|
|
897
|
+
startMcpAuthentication(this, params.sessionId, params.mcpServers ?? []);
|
|
777
898
|
}, 0);
|
|
778
899
|
return result;
|
|
779
900
|
}
|
|
780
901
|
async loadSession(params) {
|
|
902
|
+
if (this.providerUpdate)
|
|
903
|
+
await this.providerUpdate;
|
|
781
904
|
const result = await this.getOrCreateSession(params);
|
|
782
905
|
await this.replaySessionHistory(params.sessionId);
|
|
783
906
|
// Send available commands after replay so it doesn't interleave with history
|
|
784
907
|
setTimeout(() => {
|
|
785
908
|
this.sendAvailableCommandsUpdate(params.sessionId);
|
|
909
|
+
startMcpAuthentication(this, params.sessionId, params.mcpServers ?? []);
|
|
786
910
|
}, 0);
|
|
787
911
|
return result;
|
|
788
912
|
}
|
|
@@ -803,40 +927,6 @@ export class ClaudeAcpAgent {
|
|
|
803
927
|
sessions,
|
|
804
928
|
};
|
|
805
929
|
}
|
|
806
|
-
/** Read the SDK-maintained title for a session and, if it changed since the
|
|
807
|
-
* last time we looked, notify the client with a `session_info_update`. The
|
|
808
|
-
* SDK has no push event for the title it auto-generates in the background, so
|
|
809
|
-
* we pull it at turn-end. A missing session file or read error is non-fatal:
|
|
810
|
-
* the title is best-effort and another turn will retry. */
|
|
811
|
-
async maybeUpdateSessionTitle(sessionId, session) {
|
|
812
|
-
let info;
|
|
813
|
-
try {
|
|
814
|
-
info = await getSessionInfo(sessionId, { dir: session.cwd });
|
|
815
|
-
}
|
|
816
|
-
catch (error) {
|
|
817
|
-
this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`);
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
// `customTitle` is a user-set `/rename`; `summary` is the auto-generated
|
|
821
|
-
// title (or first prompt). Prefer the explicit title when present.
|
|
822
|
-
const rawTitle = info?.customTitle ?? info?.summary;
|
|
823
|
-
if (!rawTitle) {
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
const title = sanitizeTitle(rawTitle);
|
|
827
|
-
if (title === session.lastTitle) {
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
session.lastTitle = title;
|
|
831
|
-
await this.client.sessionUpdate({
|
|
832
|
-
sessionId,
|
|
833
|
-
update: {
|
|
834
|
-
sessionUpdate: "session_info_update",
|
|
835
|
-
title,
|
|
836
|
-
updatedAt: new Date(info.lastModified).toISOString(),
|
|
837
|
-
},
|
|
838
|
-
});
|
|
839
|
-
}
|
|
840
930
|
async authenticate(_params) {
|
|
841
931
|
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
|
842
932
|
this.gatewayAuthRequest = _params;
|
|
@@ -846,6 +936,7 @@ export class ClaudeAcpAgent {
|
|
|
846
936
|
}
|
|
847
937
|
async unstable_listProviders(_params) {
|
|
848
938
|
const config = this.providerConfig ?? this.defaultProviderConfig();
|
|
939
|
+
this.logger.log(`[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`);
|
|
849
940
|
const provider = {
|
|
850
941
|
providerId: PROVIDER_ID,
|
|
851
942
|
supported: SUPPORTED_PROTOCOLS,
|
|
@@ -888,7 +979,8 @@ export class ClaudeAcpAgent {
|
|
|
888
979
|
}
|
|
889
980
|
config.vertex = { projectId: vertex.projectId, region: vertex.region };
|
|
890
981
|
}
|
|
891
|
-
this.
|
|
982
|
+
this.logger.log(`[providers/set] apiType=${config.apiType} baseUrl=${config.baseUrl} sessions=${Object.keys(this.sessions).length}`);
|
|
983
|
+
await this.enqueueProviderUpdate(config);
|
|
892
984
|
return {};
|
|
893
985
|
}
|
|
894
986
|
/**
|
|
@@ -897,7 +989,8 @@ export class ClaudeAcpAgent {
|
|
|
897
989
|
*/
|
|
898
990
|
async unstable_disableProvider(params) {
|
|
899
991
|
if (params.providerId === PROVIDER_ID) {
|
|
900
|
-
this.
|
|
992
|
+
this.logger.log(`[providers/disable] sessions=${Object.keys(this.sessions).length}`);
|
|
993
|
+
await this.enqueueProviderUpdate(undefined);
|
|
901
994
|
}
|
|
902
995
|
// Unknown provider: idempotent success.
|
|
903
996
|
return {};
|
|
@@ -958,6 +1051,8 @@ export class ClaudeAcpAgent {
|
|
|
958
1051
|
}
|
|
959
1052
|
}
|
|
960
1053
|
async prompt(params) {
|
|
1054
|
+
if (this.providerUpdate)
|
|
1055
|
+
await this.providerUpdate;
|
|
961
1056
|
const session = this.sessions[params.sessionId];
|
|
962
1057
|
if (!session) {
|
|
963
1058
|
throw new Error("Session not found");
|
|
@@ -968,6 +1063,9 @@ export class ClaudeAcpAgent {
|
|
|
968
1063
|
if (session.queryClosed) {
|
|
969
1064
|
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
970
1065
|
}
|
|
1066
|
+
if (session.autoModeFallbackWarningPending) {
|
|
1067
|
+
await this.sessionModes.publishFallbackWarning(params.sessionId, session);
|
|
1068
|
+
}
|
|
971
1069
|
if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
|
|
972
1070
|
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
973
1071
|
}
|
|
@@ -987,6 +1085,7 @@ export class ClaudeAcpAgent {
|
|
|
987
1085
|
session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
|
|
988
1086
|
fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
|
|
989
1087
|
}
|
|
1088
|
+
session.titles.onPrompt(params.prompt);
|
|
990
1089
|
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
|
991
1090
|
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
|
992
1091
|
// turn, pushes the user message onto the streaming input, makes sure the
|
|
@@ -999,9 +1098,19 @@ export class ClaudeAcpAgent {
|
|
|
999
1098
|
resolve: () => { },
|
|
1000
1099
|
reject: () => { },
|
|
1001
1100
|
};
|
|
1101
|
+
let completeTurn;
|
|
1102
|
+
turn.completion = new Promise((resolve) => {
|
|
1103
|
+
completeTurn = resolve;
|
|
1104
|
+
});
|
|
1002
1105
|
const response = new Promise((resolve, reject) => {
|
|
1003
|
-
turn.resolve =
|
|
1004
|
-
|
|
1106
|
+
turn.resolve = (result) => {
|
|
1107
|
+
resolve(result);
|
|
1108
|
+
completeTurn();
|
|
1109
|
+
};
|
|
1110
|
+
turn.reject = (error) => {
|
|
1111
|
+
reject(error);
|
|
1112
|
+
completeTurn();
|
|
1113
|
+
};
|
|
1005
1114
|
});
|
|
1006
1115
|
session.turnQueue ??= [];
|
|
1007
1116
|
session.turnQueue.push(turn);
|
|
@@ -1084,11 +1193,13 @@ export class ClaudeAcpAgent {
|
|
|
1084
1193
|
* an `SDKUserMessage` onto the same streaming input, which the SDK routes
|
|
1085
1194
|
* into the in-flight turn. The injected message's echo carries a uuid that
|
|
1086
1195
|
* matches no queued turn, so the consumer drops it as an unrelated replay
|
|
1087
|
-
* without promoting/settling anything. It is delivered at
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
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.
|
|
1092
1203
|
*
|
|
1093
1204
|
* Pre-empting means ABORTING: the interrupted cycle emits a `result` of its
|
|
1094
1205
|
* own and the steered message runs as a second one, so the turn is marked
|
|
@@ -1140,7 +1251,8 @@ export class ClaudeAcpAgent {
|
|
|
1140
1251
|
userMessage.uuid = steeredUuid;
|
|
1141
1252
|
// Deliver into the running turn rather than queuing behind it as a fresh
|
|
1142
1253
|
// prompt would.
|
|
1143
|
-
userMessage.priority =
|
|
1254
|
+
userMessage.priority =
|
|
1255
|
+
(session.pendingUserInputCount ?? 0) > 0 ? STEER_PRIORITY_LATER : STEER_PRIORITY_NOW;
|
|
1144
1256
|
// Mark before the push and in the same synchronous section as the in-flight
|
|
1145
1257
|
// check: the interrupt can have the CLI finalizing the aborted cycle by the
|
|
1146
1258
|
// time the consumer next runs, and an unmarked result would settle the turn
|
|
@@ -1158,6 +1270,21 @@ export class ClaudeAcpAgent {
|
|
|
1158
1270
|
await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
|
|
1159
1271
|
return { outcome: "injected" };
|
|
1160
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
|
+
}
|
|
1161
1288
|
/** Publish the audit terminal for every turn path that did not reach the
|
|
1162
1289
|
* report tool. The support flips the turn state synchronously before its
|
|
1163
1290
|
* transport await, so callers can stay fail-open and settle the ACP prompt
|
|
@@ -1246,8 +1373,27 @@ export class ClaudeAcpAgent {
|
|
|
1246
1373
|
* recognizable by the `parentToolUseId` meta that toAcpNotifications
|
|
1247
1374
|
* stamps from `parent_tool_use_id`, and never reach the top-level feed
|
|
1248
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))));
|
|
1249
1378
|
const sendUpdate = async (notification) => {
|
|
1250
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
|
+
}
|
|
1251
1397
|
if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
|
|
1252
1398
|
(update.sessionUpdate === "agent_message_chunk" ||
|
|
1253
1399
|
update.sessionUpdate === "agent_thought_chunk" ||
|
|
@@ -1257,12 +1403,32 @@ export class ClaudeAcpAgent {
|
|
|
1257
1403
|
return;
|
|
1258
1404
|
}
|
|
1259
1405
|
if (update.sessionUpdate === "agent_message_chunk") {
|
|
1260
|
-
const claudeMeta = update._meta?.claudeCode;
|
|
1261
1406
|
if (!claudeMeta?.parentToolUseId) {
|
|
1262
1407
|
session.emittedAssistantText = true;
|
|
1408
|
+
session.titles.onAssistantText(update.content);
|
|
1263
1409
|
}
|
|
1264
1410
|
}
|
|
1265
|
-
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
|
+
]);
|
|
1266
1432
|
};
|
|
1267
1433
|
let pendingWorkerShutdown = false;
|
|
1268
1434
|
const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
|
|
@@ -1319,12 +1485,17 @@ export class ClaudeAcpAgent {
|
|
|
1319
1485
|
// cleared when each consolidated message consumes it. #785 stopped
|
|
1320
1486
|
// resetting the streamed-content tracking here but left this line.
|
|
1321
1487
|
stopReason = "end_turn";
|
|
1322
|
-
session.accumulatedUsage = {
|
|
1488
|
+
session.accumulatedUsage = session.activeTurn?.carriedUsage ?? {
|
|
1323
1489
|
inputTokens: 0,
|
|
1324
1490
|
outputTokens: 0,
|
|
1325
1491
|
cachedReadTokens: 0,
|
|
1326
1492
|
cachedWriteTokens: 0,
|
|
1327
1493
|
};
|
|
1494
|
+
session.accumulatedModelUsage = session.activeTurn?.carriedModelUsage ?? {};
|
|
1495
|
+
if (session.activeTurn) {
|
|
1496
|
+
session.activeTurn.carriedUsage = undefined;
|
|
1497
|
+
session.activeTurn.carriedModelUsage = undefined;
|
|
1498
|
+
}
|
|
1328
1499
|
};
|
|
1329
1500
|
/** Promote a queued turn to active: it becomes the one output is attributed
|
|
1330
1501
|
* to, and its scratch starts fresh. Clears the cancelled flag so a turn
|
|
@@ -1623,11 +1794,7 @@ export class ClaudeAcpAgent {
|
|
|
1623
1794
|
return;
|
|
1624
1795
|
}
|
|
1625
1796
|
sessionFailures.recordActive(failure);
|
|
1626
|
-
settleActive(
|
|
1627
|
-
stopReason: "end_turn",
|
|
1628
|
-
usage: sessionUsage(session),
|
|
1629
|
-
_meta: sessionFailureMeta(failure),
|
|
1630
|
-
}, "providerError");
|
|
1797
|
+
settleActive(turnOutcome(session, "end_turn", sessionFailureMeta(failure)), "providerError");
|
|
1631
1798
|
};
|
|
1632
1799
|
/** Reject every in-flight turn — used when the stream dies. */
|
|
1633
1800
|
const failAllTurns = (error) => {
|
|
@@ -1699,7 +1866,9 @@ export class ClaudeAcpAgent {
|
|
|
1699
1866
|
// spent would never drain (it would swallow an unrelated later
|
|
1700
1867
|
// echo-less result instead).
|
|
1701
1868
|
const active = session.activeTurn;
|
|
1702
|
-
if (active.commandFinished === "completed" ||
|
|
1869
|
+
if (active.commandFinished === "completed" ||
|
|
1870
|
+
active.commandFinished === "discarded" ||
|
|
1871
|
+
active.commandFinished === "refused") {
|
|
1703
1872
|
// Finished SDK-side; any result already passed. Nothing to
|
|
1704
1873
|
// track.
|
|
1705
1874
|
}
|
|
@@ -1724,7 +1893,7 @@ export class ClaudeAcpAgent {
|
|
|
1724
1893
|
this.trackOrphanCommand(session, active.promptUuid, "started");
|
|
1725
1894
|
}
|
|
1726
1895
|
}
|
|
1727
|
-
settleActive(
|
|
1896
|
+
settleActive(turnOutcome(session, "cancelled"));
|
|
1728
1897
|
// The cancelled turn's result may never come (that's why the
|
|
1729
1898
|
// backstop fired) — close its delivery stretch here so partial
|
|
1730
1899
|
// streamed text can't suppress the next turn's issue-#453 fallback.
|
|
@@ -1778,9 +1947,10 @@ export class ClaudeAcpAgent {
|
|
|
1778
1947
|
// a deferred turn's stored outcome (followup results never mutate
|
|
1779
1948
|
// it), but the stored one is the authoritative source.
|
|
1780
1949
|
const inFlight = session.activeTurn;
|
|
1950
|
+
await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "at end of stream");
|
|
1781
1951
|
settleActive(session.cancelled
|
|
1782
|
-
?
|
|
1783
|
-
: (inFlight?.deferredSettle ??
|
|
1952
|
+
? turnOutcome(session, "cancelled")
|
|
1953
|
+
: (inFlight?.deferredSettle ?? turnOutcome(session, stopReason)));
|
|
1784
1954
|
// Queued turns the SDK never started never ran, so reject them rather
|
|
1785
1955
|
// than reporting a success (end_turn) — or a misleading "cancelled" —
|
|
1786
1956
|
// for a prompt that produced no output. (A cancel already settled the
|
|
@@ -1810,7 +1980,7 @@ export class ClaudeAcpAgent {
|
|
|
1810
1980
|
}
|
|
1811
1981
|
// CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
|
|
1812
1982
|
// uuid-stamped queued command (queued/started/completed/cancelled/
|
|
1813
|
-
// discarded) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
1983
|
+
// discarded/refused) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
1814
1984
|
// prompt() stamps a uuid on every message. The frame is @internal and
|
|
1815
1985
|
// absent from the SDKMessage union, so handle it BEFORE the exhaustive
|
|
1816
1986
|
// switch: it must not reach `unreachable`'s error log, and a `case`
|
|
@@ -1845,6 +2015,7 @@ export class ClaudeAcpAgent {
|
|
|
1845
2015
|
}
|
|
1846
2016
|
case "completed":
|
|
1847
2017
|
case "discarded":
|
|
2018
|
+
case "refused":
|
|
1848
2019
|
case "cancelled": {
|
|
1849
2020
|
// Terminal frames. Latch the fate on a still-queued turn so a
|
|
1850
2021
|
// later cancel() doesn't seed an orphan entry for a command
|
|
@@ -1867,6 +2038,7 @@ export class ClaudeAcpAgent {
|
|
|
1867
2038
|
const state = session.orphanCommands?.get(frame.command_uuid);
|
|
1868
2039
|
if (state === "pending") {
|
|
1869
2040
|
session.orphanCommands?.delete(frame.command_uuid);
|
|
2041
|
+
session.pendingEmptyInterruptionDiagnosticCommands?.delete(frame.command_uuid);
|
|
1870
2042
|
}
|
|
1871
2043
|
else if (state === "started") {
|
|
1872
2044
|
session.orphanCommands?.set(frame.command_uuid, "zombie");
|
|
@@ -1878,7 +2050,11 @@ export class ClaudeAcpAgent {
|
|
|
1878
2050
|
// command folded into another turn whose result is attributed
|
|
1879
2051
|
// elsewhere — either way no echo-less result remains to skip.
|
|
1880
2052
|
// "discarded" = session ended with it still queued; no result.
|
|
2053
|
+
// "refused" (2.1.238+) = a cross-session peer message declined
|
|
2054
|
+
// by receive-side policy before dispatch; never a prompt-lane
|
|
2055
|
+
// command of ours, and no result will ever come.
|
|
1881
2056
|
session.orphanCommands?.delete(frame.command_uuid);
|
|
2057
|
+
session.pendingEmptyInterruptionDiagnosticCommands?.delete(frame.command_uuid);
|
|
1882
2058
|
break;
|
|
1883
2059
|
}
|
|
1884
2060
|
default:
|
|
@@ -2001,6 +2177,7 @@ export class ClaudeAcpAgent {
|
|
|
2001
2177
|
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
|
2002
2178
|
lastAssistantUsage = null;
|
|
2003
2179
|
lastAssistantTotalUsage = usedTokens ?? 0;
|
|
2180
|
+
session.contextUsedTokens = usedTokens ?? 0;
|
|
2004
2181
|
await sendUpdate({
|
|
2005
2182
|
sessionId: message.session_id,
|
|
2006
2183
|
update: {
|
|
@@ -2054,7 +2231,7 @@ export class ClaudeAcpAgent {
|
|
|
2054
2231
|
// the interrupted turn's tokens entirely (issue #844). Zero
|
|
2055
2232
|
// when the cancel pre-empted the result (wedge/force-cancel).
|
|
2056
2233
|
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
|
|
2057
|
-
settleActive(
|
|
2234
|
+
settleActive(turnOutcome(session, "cancelled"));
|
|
2058
2235
|
// An interrupt can pre-empt the turn's result entirely
|
|
2059
2236
|
// (nothing ran the result-case `finally`), so close the
|
|
2060
2237
|
// delivery stretch here: idle is the SDK's authoritative
|
|
@@ -2128,11 +2305,9 @@ export class ClaudeAcpAgent {
|
|
|
2128
2305
|
`for the active turn; failing the in-flight prompt (issue #825)`);
|
|
2129
2306
|
await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
|
|
2130
2307
|
}
|
|
2131
|
-
//
|
|
2132
|
-
//
|
|
2133
|
-
|
|
2134
|
-
// landed. Push it to the client if it changed.
|
|
2135
|
-
await this.maybeUpdateSessionTitle(params.sessionId, session);
|
|
2308
|
+
// Turn-over is when a title may have landed or become
|
|
2309
|
+
// generatable; see SessionTitles.onTurnEnd.
|
|
2310
|
+
await session.titles.onTurnEnd(session);
|
|
2136
2311
|
}
|
|
2137
2312
|
break;
|
|
2138
2313
|
}
|
|
@@ -2198,6 +2373,7 @@ export class ClaudeAcpAgent {
|
|
|
2198
2373
|
break;
|
|
2199
2374
|
}
|
|
2200
2375
|
case "permission_denied": {
|
|
2376
|
+
unregisterHookCallback(message.tool_use_id);
|
|
2201
2377
|
// A tool call was auto-denied (by a rule, the classifier,
|
|
2202
2378
|
// dontAsk mode, etc.) before running. The tool_use block was
|
|
2203
2379
|
// already emitted as a `tool_call`, so mark it failed with the
|
|
@@ -2225,6 +2401,16 @@ export class ClaudeAcpAgent {
|
|
|
2225
2401
|
const parentToolUseId = message.agent_id
|
|
2226
2402
|
? session.liveBackgroundTasks.get(message.agent_id)?.parentToolUseId
|
|
2227
2403
|
: undefined;
|
|
2404
|
+
const eagerOwnerSessionId = session.eagerToolCallSessions?.get(message.tool_use_id);
|
|
2405
|
+
if (message.agent_id &&
|
|
2406
|
+
!parentToolUseId &&
|
|
2407
|
+
!eagerOwnerSessionId &&
|
|
2408
|
+
clientSupportsSubagents(this.clientCapabilities)) {
|
|
2409
|
+
// An agent-scoped denial with missing lineage cannot safely
|
|
2410
|
+
// be presented in the root transcript. The matching hidden
|
|
2411
|
+
// child tool call was not announced there.
|
|
2412
|
+
break;
|
|
2413
|
+
}
|
|
2228
2414
|
const reason = message.decision_reason ?? message.message;
|
|
2229
2415
|
await sendUpdate({
|
|
2230
2416
|
sessionId: message.session_id,
|
|
@@ -2279,7 +2465,15 @@ export class ClaudeAcpAgent {
|
|
|
2279
2465
|
case "hook_progress":
|
|
2280
2466
|
case "hook_response":
|
|
2281
2467
|
case "files_persisted":
|
|
2468
|
+
break;
|
|
2282
2469
|
case "task_progress":
|
|
2470
|
+
await asyncTasks.taskProgress({
|
|
2471
|
+
task_id: message.task_id,
|
|
2472
|
+
description: message.description,
|
|
2473
|
+
summary: message.summary,
|
|
2474
|
+
last_tool_name: message.last_tool_name,
|
|
2475
|
+
usage: message.usage,
|
|
2476
|
+
});
|
|
2283
2477
|
break;
|
|
2284
2478
|
case "task_started":
|
|
2285
2479
|
// For subagent tasks `task_id` is the subagent's agent id (the
|
|
@@ -2304,6 +2498,23 @@ export class ClaudeAcpAgent {
|
|
|
2304
2498
|
parentToolUseId: message.tool_use_id,
|
|
2305
2499
|
isSubagent: !!message.subagent_type,
|
|
2306
2500
|
});
|
|
2501
|
+
await subagents.taskStarted({
|
|
2502
|
+
taskId: message.task_id,
|
|
2503
|
+
toolUseId: message.tool_use_id,
|
|
2504
|
+
subagentType: message.subagent_type,
|
|
2505
|
+
description: message.description,
|
|
2506
|
+
prompt: message.prompt,
|
|
2507
|
+
}, sendUpdate);
|
|
2508
|
+
await asyncTasks.taskStarted({
|
|
2509
|
+
task_id: message.task_id,
|
|
2510
|
+
task_type: message.task_type,
|
|
2511
|
+
description: message.description,
|
|
2512
|
+
subagent_type: message.subagent_type,
|
|
2513
|
+
is_backgrounded: message.is_backgrounded,
|
|
2514
|
+
workflow_name: message.workflow_name,
|
|
2515
|
+
skip_transcript: message.skip_transcript,
|
|
2516
|
+
tool_use_id: message.tool_use_id,
|
|
2517
|
+
});
|
|
2307
2518
|
if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
|
|
2308
2519
|
(session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
|
|
2309
2520
|
}
|
|
@@ -2311,9 +2522,19 @@ export class ClaudeAcpAgent {
|
|
|
2311
2522
|
case "task_notification":
|
|
2312
2523
|
// The task settled — no further tool calls can originate
|
|
2313
2524
|
// from it, so its registry entry can be dropped.
|
|
2525
|
+
await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id);
|
|
2526
|
+
await asyncTasks.taskNotification({
|
|
2527
|
+
task_id: message.task_id,
|
|
2528
|
+
status: message.status,
|
|
2529
|
+
summary: message.summary,
|
|
2530
|
+
output_file: message.output_file,
|
|
2531
|
+
});
|
|
2532
|
+
if (message.tool_use_id)
|
|
2533
|
+
subagents.discardPending(message.tool_use_id);
|
|
2314
2534
|
session.liveBackgroundTasks.delete(message.task_id);
|
|
2315
2535
|
break;
|
|
2316
2536
|
case "task_updated":
|
|
2537
|
+
await asyncTasks.taskUpdated(message.task_id, message.patch);
|
|
2317
2538
|
// terminal-status task_updated patch and a (deduplicated)
|
|
2318
2539
|
// task_notification when a task settles, but only the patch is
|
|
2319
2540
|
// guaranteed per transition — prune on it too so the registry
|
|
@@ -2322,6 +2543,7 @@ export class ClaudeAcpAgent {
|
|
|
2322
2543
|
if (message.patch.status === "completed" ||
|
|
2323
2544
|
message.patch.status === "failed" ||
|
|
2324
2545
|
message.patch.status === "killed") {
|
|
2546
|
+
await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
|
|
2325
2547
|
session.liveBackgroundTasks.delete(message.task_id);
|
|
2326
2548
|
}
|
|
2327
2549
|
break;
|
|
@@ -2457,6 +2679,7 @@ export class ClaudeAcpAgent {
|
|
|
2457
2679
|
case "control_request_progress":
|
|
2458
2680
|
break;
|
|
2459
2681
|
case "background_tasks_changed":
|
|
2682
|
+
await asyncTasks.backgroundTasksChanged(message.tasks);
|
|
2460
2683
|
// A level signal: the full live background-task set on every
|
|
2461
2684
|
// membership change, with REPLACE semantics. Used only to
|
|
2462
2685
|
// reconcile `liveBackgroundTasks` — dropping (or, for
|
|
@@ -2512,6 +2735,8 @@ export class ClaudeAcpAgent {
|
|
|
2512
2735
|
// user-turn lifecycle (stop reason, settles, failActive,
|
|
2513
2736
|
// slash-command output forwarding), though their cost is real.
|
|
2514
2737
|
const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
|
|
2738
|
+
const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption;
|
|
2739
|
+
const pendingExitPlanContextReset = session.pendingExitPlanContextReset;
|
|
2515
2740
|
try {
|
|
2516
2741
|
// Reconcile the Fast mode toggle with the SDK's reported state.
|
|
2517
2742
|
// Gated to user-driven turns like every other side effect below;
|
|
@@ -2612,6 +2837,18 @@ export class ClaudeAcpAgent {
|
|
|
2612
2837
|
session.accumulatedUsage.cachedWriteTokens +=
|
|
2613
2838
|
message.usage.cache_creation_input_tokens;
|
|
2614
2839
|
}
|
|
2840
|
+
// The same tally split by model, for `_meta.quota.model_usage`.
|
|
2841
|
+
// `modelUsage` is a running total for the whole query() call, so
|
|
2842
|
+
// this result's own spend is what it added to the previous
|
|
2843
|
+
// reading. Advance the reading even for an autonomous result — it
|
|
2844
|
+
// is part of the running total the NEXT increment is measured
|
|
2845
|
+
// from — but leave the turn tally alone, exactly as above.
|
|
2846
|
+
const modelUsageReading = normalizeModelUsage(message.modelUsage);
|
|
2847
|
+
const resultModelUsage = modelUsageIncrement(modelUsageReading, session.lastModelUsageReading ?? {});
|
|
2848
|
+
session.lastModelUsageReading = modelUsageReading;
|
|
2849
|
+
if (!isAutonomousResult) {
|
|
2850
|
+
session.accumulatedModelUsage = addModelUsage(session.accumulatedModelUsage ?? {}, resultModelUsage);
|
|
2851
|
+
}
|
|
2615
2852
|
const matchingModelUsage = lastAssistantModel
|
|
2616
2853
|
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
2617
2854
|
: null;
|
|
@@ -2664,6 +2901,8 @@ export class ClaudeAcpAgent {
|
|
|
2664
2901
|
});
|
|
2665
2902
|
}
|
|
2666
2903
|
if (session.cancelled) {
|
|
2904
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2905
|
+
session.pendingExitPlanContextReset = undefined;
|
|
2667
2906
|
if (!isAutonomousResult) {
|
|
2668
2907
|
await clearFailuresFromEarlierTurns();
|
|
2669
2908
|
stopReason = "cancelled";
|
|
@@ -2701,7 +2940,61 @@ export class ClaudeAcpAgent {
|
|
|
2701
2940
|
}
|
|
2702
2941
|
break;
|
|
2703
2942
|
}
|
|
2943
|
+
// A cancel can race startup before the original prompt's echo.
|
|
2944
|
+
// The CLI then replays the cancelled prompt, its interruption
|
|
2945
|
+
// marker, and the replacement prompt before yielding this
|
|
2946
|
+
// error-shaped diagnostic for the OLD cycle. The replacement's
|
|
2947
|
+
// echo has already made it active, so treating the diagnostic as
|
|
2948
|
+
// its result rejects a healthy prompt and leaves its real output
|
|
2949
|
+
// arriving after session/prompt completed. Ignore only the
|
|
2950
|
+
// diagnostic's exact empty-user-interruption signature; ordinary
|
|
2951
|
+
// provider errors still follow the failure lanes below.
|
|
2952
|
+
if (message.is_error &&
|
|
2953
|
+
isEmptyUserInterruptionDiagnostic(message) &&
|
|
2954
|
+
(session.pendingEmptyInterruptionDiagnosticCommands?.size ?? 0) > 0) {
|
|
2955
|
+
const commandUuid = session
|
|
2956
|
+
.pendingEmptyInterruptionDiagnosticCommands.values()
|
|
2957
|
+
.next().value;
|
|
2958
|
+
if (commandUuid) {
|
|
2959
|
+
session.pendingEmptyInterruptionDiagnosticCommands.delete(commandUuid);
|
|
2960
|
+
}
|
|
2961
|
+
break;
|
|
2962
|
+
}
|
|
2963
|
+
// Some CLI versions omit the cancelled cycle's diagnostic. Do
|
|
2964
|
+
// not let an unused hand-off token swallow a later turn's real
|
|
2965
|
+
// interruption failure.
|
|
2966
|
+
session.pendingEmptyInterruptionDiagnosticCommands?.clear();
|
|
2704
2967
|
await clearFailuresFromEarlierTurns();
|
|
2968
|
+
// `interrupt: true` is required to make "No, keep planning"
|
|
2969
|
+
// terminate the ACP turn. Claude represents that intentional
|
|
2970
|
+
// interrupt as an error-shaped diagnostic, so translate only a
|
|
2971
|
+
// diagnostic causally paired with the recorded ExitPlanMode
|
|
2972
|
+
// permission response.
|
|
2973
|
+
const diagnostic = executionDiagnostic(message);
|
|
2974
|
+
if (pendingExitPlanModeInterruption &&
|
|
2975
|
+
pendingExitPlanModeInterruption.toolResultSeen &&
|
|
2976
|
+
message.is_error &&
|
|
2977
|
+
diagnostic &&
|
|
2978
|
+
/(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
|
|
2979
|
+
/(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic)) {
|
|
2980
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2981
|
+
if (pendingExitPlanContextReset &&
|
|
2982
|
+
pendingExitPlanContextReset.toolUseId ===
|
|
2983
|
+
pendingExitPlanModeInterruption.toolUseId) {
|
|
2984
|
+
await this.exitPlan.restart(params.sessionId, session, pendingExitPlanContextReset);
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2987
|
+
stopReason = "cancelled";
|
|
2988
|
+
settleOrDefer(turnOutcome(session, "cancelled"));
|
|
2989
|
+
break;
|
|
2990
|
+
}
|
|
2991
|
+
if (pendingExitPlanModeInterruption) {
|
|
2992
|
+
// This result ended the interrupted cycle without the exact
|
|
2993
|
+
// correlated cancellation shape. Never carry its marker into
|
|
2994
|
+
// a later turn, whether or not the tool result was observed.
|
|
2995
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
2996
|
+
session.pendingExitPlanContextReset = undefined;
|
|
2997
|
+
}
|
|
2705
2998
|
// A refusal can arrive on any result subtype (and may even set
|
|
2706
2999
|
// is_error), so handle it before the subtype switch — otherwise the
|
|
2707
3000
|
// is_error throw below would surface it as an internal error. The
|
|
@@ -2724,7 +3017,7 @@ export class ClaudeAcpAgent {
|
|
|
2724
3017
|
// settling it out from under them would strand their output
|
|
2725
3018
|
// and permission requests out-of-turn (issue #866's deadlock,
|
|
2726
3019
|
// through the refusal lane).
|
|
2727
|
-
settleOrDefer(
|
|
3020
|
+
settleOrDefer(turnOutcome(session, "refusal"));
|
|
2728
3021
|
break;
|
|
2729
3022
|
}
|
|
2730
3023
|
// A priority:'now' steer can make the SDK terminate the
|
|
@@ -2735,7 +3028,7 @@ export class ClaudeAcpAgent {
|
|
|
2735
3028
|
if (message.is_error &&
|
|
2736
3029
|
isSteering(session.activeTurn) &&
|
|
2737
3030
|
session.activeTurn.steeredEchoes.size > 0) {
|
|
2738
|
-
settleOrDefer(
|
|
3031
|
+
settleOrDefer(turnOutcome(session, "end_turn"));
|
|
2739
3032
|
break;
|
|
2740
3033
|
}
|
|
2741
3034
|
if (!message.is_error && lastAssistantModel !== null) {
|
|
@@ -2778,7 +3071,7 @@ export class ClaudeAcpAgent {
|
|
|
2778
3071
|
// prose can be injected into the feed.)
|
|
2779
3072
|
if (session.activeTurn?.isLocalOnlyCommand ||
|
|
2780
3073
|
(!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
|
|
2781
|
-
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache,
|
|
3074
|
+
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
|
|
2782
3075
|
await sendUpdate(notification);
|
|
2783
3076
|
}
|
|
2784
3077
|
}
|
|
@@ -2847,7 +3140,7 @@ export class ClaudeAcpAgent {
|
|
|
2847
3140
|
// idle/abort path. settleActive is idempotent, so a duplicate
|
|
2848
3141
|
// idle is a no-op.
|
|
2849
3142
|
if (!session.cancelled) {
|
|
2850
|
-
settleOrDefer(
|
|
3143
|
+
settleOrDefer(turnOutcome(session, stopReason));
|
|
2851
3144
|
}
|
|
2852
3145
|
}
|
|
2853
3146
|
finally {
|
|
@@ -2950,6 +3243,7 @@ export class ClaudeAcpAgent {
|
|
|
2950
3243
|
const nextUsage = totalTokens(lastAssistantUsage);
|
|
2951
3244
|
if (nextUsage !== lastAssistantTotalUsage) {
|
|
2952
3245
|
lastAssistantTotalUsage = nextUsage;
|
|
3246
|
+
session.contextUsedTokens = nextUsage;
|
|
2953
3247
|
await sendUpdate({
|
|
2954
3248
|
sessionId: params.sessionId,
|
|
2955
3249
|
update: {
|
|
@@ -3021,7 +3315,7 @@ export class ClaudeAcpAgent {
|
|
|
3021
3315
|
session.owedTrailingIdles++;
|
|
3022
3316
|
// Before activateTurn resets the accumulator, so the
|
|
3023
3317
|
// usage still belongs to the cancelled turn.
|
|
3024
|
-
settleActive(
|
|
3318
|
+
settleActive(turnOutcome(session, "cancelled"));
|
|
3025
3319
|
}
|
|
3026
3320
|
else if (isHeldOpen(session.activeTurn)) {
|
|
3027
3321
|
// A turn held open for its background subagents (see
|
|
@@ -3050,7 +3344,7 @@ export class ClaudeAcpAgent {
|
|
|
3050
3344
|
settleActive(session.activeTurn.steeredSettle);
|
|
3051
3345
|
}
|
|
3052
3346
|
else {
|
|
3053
|
-
settleActive(
|
|
3347
|
+
settleActive(turnOutcome(session, "end_turn"));
|
|
3054
3348
|
}
|
|
3055
3349
|
}
|
|
3056
3350
|
// Unlike the no-result teardown lanes, this hand-off must
|
|
@@ -3086,6 +3380,7 @@ export class ClaudeAcpAgent {
|
|
|
3086
3380
|
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
3087
3381
|
lastAssistantUsage = snapshotFromUsage(message.message.usage);
|
|
3088
3382
|
lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
|
|
3383
|
+
session.contextUsedTokens = lastAssistantTotalUsage;
|
|
3089
3384
|
lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
|
|
3090
3385
|
if (message.error || lastAssistantWasUsageLimit) {
|
|
3091
3386
|
lastAssistantFailureTitle = assistantMessageText(message.message);
|
|
@@ -3116,7 +3411,7 @@ export class ClaudeAcpAgent {
|
|
|
3116
3411
|
message.message.content.includes("<local-command-stdout>")) {
|
|
3117
3412
|
const stripped = stripLocalCommandMetadata(message.message.content);
|
|
3118
3413
|
if (typeof stripped === "string") {
|
|
3119
|
-
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache,
|
|
3414
|
+
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
|
|
3120
3415
|
clientCapabilities: this.clientCapabilities,
|
|
3121
3416
|
parentToolUseId: message.parent_tool_use_id,
|
|
3122
3417
|
cwd: session.cwd,
|
|
@@ -3226,17 +3521,22 @@ export class ClaudeAcpAgent {
|
|
|
3226
3521
|
}
|
|
3227
3522
|
else if (message.type === "assistant" &&
|
|
3228
3523
|
!(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
|
|
3229
|
-
// Legacy clients
|
|
3230
|
-
//
|
|
3231
|
-
//
|
|
3232
|
-
// feed. Capable clients opt into the branch above unchanged, with
|
|
3233
|
-
// `parentToolUseId` stamped by toAcpNotifications.
|
|
3524
|
+
// Legacy clients keep the flattened tool-call representation,
|
|
3525
|
+
// but nested text/thinking stays internal unless explicitly
|
|
3526
|
+
// requested through the historical transcript extension.
|
|
3234
3527
|
content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
|
|
3235
3528
|
}
|
|
3236
3529
|
else {
|
|
3237
3530
|
content = message.message.content;
|
|
3238
3531
|
}
|
|
3239
|
-
|
|
3532
|
+
const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
|
|
3533
|
+
let backgroundBashTask;
|
|
3534
|
+
if (message.type === "user") {
|
|
3535
|
+
backgroundBashTask = backgroundBashTaskFromToolResult(content, message.tool_use_result, session.toolUseCache);
|
|
3536
|
+
if (backgroundBashTask)
|
|
3537
|
+
await asyncTasks.taskBackgrounded(backgroundBashTask);
|
|
3538
|
+
}
|
|
3539
|
+
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
|
|
3240
3540
|
clientCapabilities: this.clientCapabilities,
|
|
3241
3541
|
parentToolUseId: message.parent_tool_use_id,
|
|
3242
3542
|
cwd: session.cwd,
|
|
@@ -3254,7 +3554,7 @@ export class ClaudeAcpAgent {
|
|
|
3254
3554
|
// filtered out of `content` above; blocks that do pass through
|
|
3255
3555
|
// (e.g. a subagent image) carry the stamped parentToolUseId
|
|
3256
3556
|
// meta and are excluded there.
|
|
3257
|
-
await sendUpdate(notification);
|
|
3557
|
+
await sendUpdate(backgroundedBashToolCall(acceptedPlanToolResult(notification, acceptedPlanToolUseId), backgroundBashTask, asyncTasks.enabled));
|
|
3258
3558
|
}
|
|
3259
3559
|
break;
|
|
3260
3560
|
}
|
|
@@ -3278,6 +3578,11 @@ export class ClaudeAcpAgent {
|
|
|
3278
3578
|
if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
|
|
3279
3579
|
break;
|
|
3280
3580
|
}
|
|
3581
|
+
const subagentParentToolUseId = message.parent_tool_use_id
|
|
3582
|
+
? [...session.liveBackgroundTasks.values()].some((task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id)
|
|
3583
|
+
? message.parent_tool_use_id
|
|
3584
|
+
: undefined
|
|
3585
|
+
: undefined;
|
|
3281
3586
|
await sendUpdate({
|
|
3282
3587
|
sessionId: message.session_id,
|
|
3283
3588
|
update: {
|
|
@@ -3287,6 +3592,9 @@ export class ClaudeAcpAgent {
|
|
|
3287
3592
|
_meta: {
|
|
3288
3593
|
claudeCode: {
|
|
3289
3594
|
toolName: message.tool_name,
|
|
3595
|
+
...(subagentParentToolUseId
|
|
3596
|
+
? { parentToolUseId: subagentParentToolUseId }
|
|
3597
|
+
: {}),
|
|
3290
3598
|
toolResponse: {
|
|
3291
3599
|
elapsedTimeSeconds: message.elapsed_time_seconds,
|
|
3292
3600
|
// For Agent/Task calls: the subagent's type, and — when
|
|
@@ -3326,8 +3634,17 @@ export class ClaudeAcpAgent {
|
|
|
3326
3634
|
// and task store are independent of the previous transcript.
|
|
3327
3635
|
// Clear both the in-memory snapshot and the client's visible plan
|
|
3328
3636
|
// before any follow-up prompt can republish stale tasks.
|
|
3637
|
+
await finishLifecycle("failed", "failed", "during conversation reset");
|
|
3638
|
+
subagents.clear();
|
|
3639
|
+
asyncTasks.clear();
|
|
3640
|
+
session.eagerToolCallSessions?.clear();
|
|
3641
|
+
clearHookCallbacks(params.sessionId);
|
|
3329
3642
|
session.taskState.clear();
|
|
3330
3643
|
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
3644
|
+
// A reset mounts a fresh transcript (`new_conversation_id`), so our
|
|
3645
|
+
// cached title no longer describes the session: drop it and
|
|
3646
|
+
// re-evaluate at the next turn-end.
|
|
3647
|
+
session.titles.reset();
|
|
3331
3648
|
break;
|
|
3332
3649
|
}
|
|
3333
3650
|
case "tool_use_summary":
|
|
@@ -3358,6 +3675,7 @@ export class ClaudeAcpAgent {
|
|
|
3358
3675
|
message.includes("process exited with") ||
|
|
3359
3676
|
message.includes("process terminated by signal") ||
|
|
3360
3677
|
message.includes("Failed to write to process stdin"));
|
|
3678
|
+
await finishLifecycle(session.cancelled ? "cancelled" : "failed", session.cancelled ? "stopped" : "failed", "after stream error");
|
|
3361
3679
|
if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
|
|
3362
3680
|
if (!isHeldOpen(session.activeTurn)) {
|
|
3363
3681
|
await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
|
|
@@ -3380,6 +3698,10 @@ export class ClaudeAcpAgent {
|
|
|
3380
3698
|
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
|
|
3381
3699
|
failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
|
|
3382
3700
|
this.closeQueryStream(session);
|
|
3701
|
+
session.eagerToolCallSessions?.clear();
|
|
3702
|
+
clearHookCallbacks(params.sessionId);
|
|
3703
|
+
session.nativeSubagentRuntime?.clear();
|
|
3704
|
+
session.asyncTaskRuntime?.clear();
|
|
3383
3705
|
delete this.sessions[params.sessionId];
|
|
3384
3706
|
}
|
|
3385
3707
|
else {
|
|
@@ -3416,18 +3738,34 @@ export class ClaudeAcpAgent {
|
|
|
3416
3738
|
}
|
|
3417
3739
|
}
|
|
3418
3740
|
async cancel(params) {
|
|
3741
|
+
this.exitPlan.cancel(params.sessionId);
|
|
3419
3742
|
const session = this.sessions[params.sessionId];
|
|
3420
3743
|
if (!session) {
|
|
3421
3744
|
return;
|
|
3422
3745
|
}
|
|
3746
|
+
session.cancelled = true;
|
|
3747
|
+
session.pendingExitPlanModeInterruption = undefined;
|
|
3748
|
+
session.pendingExitPlanContextReset = undefined;
|
|
3423
3749
|
// The stream already ended (see closeQueryStream): every in-flight turn was
|
|
3424
3750
|
// settled when it closed, and there is no live query to interrupt. Calling
|
|
3425
3751
|
// query.interrupt() on a finished iterator could reject and surface from
|
|
3426
3752
|
// this fire-and-forget notification, so there is nothing to do here.
|
|
3427
3753
|
if (session.queryClosed) {
|
|
3754
|
+
session.eagerToolCallSessions?.clear();
|
|
3755
|
+
clearHookCallbacks(params.sessionId);
|
|
3428
3756
|
return;
|
|
3429
3757
|
}
|
|
3430
|
-
|
|
3758
|
+
try {
|
|
3759
|
+
await session.nativeSubagentRuntime?.finishAll("cancelled", session.nativeSubagentDeliver ??
|
|
3760
|
+
(async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification))));
|
|
3761
|
+
}
|
|
3762
|
+
catch (error) {
|
|
3763
|
+
this.logger.error(`Session ${params.sessionId}: failed to publish cancelled subagent state`, error);
|
|
3764
|
+
}
|
|
3765
|
+
finally {
|
|
3766
|
+
session.eagerToolCallSessions?.clear();
|
|
3767
|
+
clearHookCallbacks(params.sessionId);
|
|
3768
|
+
}
|
|
3431
3769
|
// A priority steer may still be queued in the SDK when cancellation
|
|
3432
3770
|
// settles its owning turn. Its later echo matches no live turn, and its
|
|
3433
3771
|
// result must be skipped rather than promoted onto the next prompt.
|
|
@@ -3471,7 +3809,9 @@ export class ClaudeAcpAgent {
|
|
|
3471
3809
|
// never see lifecycle frames, so commandStarted/commandFinished stay
|
|
3472
3810
|
// unset and every turn takes the plain-seed path below).
|
|
3473
3811
|
for (const turn of orphanedTurns) {
|
|
3474
|
-
if (turn.commandFinished === "completed" ||
|
|
3812
|
+
if (turn.commandFinished === "completed" ||
|
|
3813
|
+
turn.commandFinished === "discarded" ||
|
|
3814
|
+
turn.commandFinished === "refused") {
|
|
3475
3815
|
// The command already finished SDK-side and its terminal frame was
|
|
3476
3816
|
// consumed while the turn sat queued — nothing is left to skip, and
|
|
3477
3817
|
// a seeded entry would never drain.
|
|
@@ -3500,6 +3840,24 @@ export class ClaudeAcpAgent {
|
|
|
3500
3840
|
}
|
|
3501
3841
|
session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
|
|
3502
3842
|
}
|
|
3843
|
+
const diagnosticTurns = orphanedTurns.filter((turn) => {
|
|
3844
|
+
if (turn.commandFinished === "completed" ||
|
|
3845
|
+
turn.commandFinished === "discarded" ||
|
|
3846
|
+
turn.commandFinished === "refused") {
|
|
3847
|
+
return false;
|
|
3848
|
+
}
|
|
3849
|
+
if (turn.commandStarted && turn.commandResultSeen)
|
|
3850
|
+
return false;
|
|
3851
|
+
if (turn.commandFinished === "cancelled" && !turn.commandStarted)
|
|
3852
|
+
return false;
|
|
3853
|
+
return true;
|
|
3854
|
+
});
|
|
3855
|
+
if (diagnosticTurns.length > 0) {
|
|
3856
|
+
session.pendingEmptyInterruptionDiagnosticCommands ??= new Set();
|
|
3857
|
+
for (const turn of diagnosticTurns) {
|
|
3858
|
+
session.pendingEmptyInterruptionDiagnosticCommands.add(turn.promptUuid);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3503
3861
|
// A deferred active turn (see Turn.deferredSettle) already has its
|
|
3504
3862
|
// result — it is only held open for its background subagents, which the
|
|
3505
3863
|
// interrupt below tears down. Settle it "cancelled" NOW: during the hold
|
|
@@ -3546,7 +3904,14 @@ export class ClaudeAcpAgent {
|
|
|
3546
3904
|
if (session.lastSessionState !== "idle") {
|
|
3547
3905
|
session.owedTrailingIdles++;
|
|
3548
3906
|
}
|
|
3549
|
-
|
|
3907
|
+
// Carries the held outcome's `_meta` too: a deferred outcome's only
|
|
3908
|
+
// metadata is its quota breakdown, the counterpart of the usage taken
|
|
3909
|
+
// from it here.
|
|
3910
|
+
active.resolve({
|
|
3911
|
+
stopReason: "cancelled",
|
|
3912
|
+
usage: active.deferredSettle.usage,
|
|
3913
|
+
...(active.deferredSettle._meta ? { _meta: active.deferredSettle._meta } : {}),
|
|
3914
|
+
});
|
|
3550
3915
|
}
|
|
3551
3916
|
}
|
|
3552
3917
|
// A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
|
|
@@ -3597,6 +3962,11 @@ export class ClaudeAcpAgent {
|
|
|
3597
3962
|
// activation-time self-heal.
|
|
3598
3963
|
if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
|
|
3599
3964
|
const stillQueued = new Set(receipt.still_queued);
|
|
3965
|
+
const droppedTurns = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid));
|
|
3966
|
+
const droppedCount = droppedTurns.length;
|
|
3967
|
+
for (const turn of droppedTurns) {
|
|
3968
|
+
session.pendingEmptyInterruptionDiagnosticCommands?.delete(turn.promptUuid);
|
|
3969
|
+
}
|
|
3600
3970
|
if (lifecycleLane) {
|
|
3601
3971
|
// Lifecycle lane: forget dropped orphans by uuid. Only entries still
|
|
3602
3972
|
// "pending" — an orphan absent from `still_queued` because it was
|
|
@@ -3615,9 +3985,8 @@ export class ClaudeAcpAgent {
|
|
|
3615
3985
|
}
|
|
3616
3986
|
}
|
|
3617
3987
|
else {
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
|
|
3988
|
+
if (droppedCount > 0) {
|
|
3989
|
+
session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - droppedCount);
|
|
3621
3990
|
}
|
|
3622
3991
|
}
|
|
3623
3992
|
}
|
|
@@ -3658,7 +4027,12 @@ export class ClaudeAcpAgent {
|
|
|
3658
4027
|
if (!session) {
|
|
3659
4028
|
return;
|
|
3660
4029
|
}
|
|
3661
|
-
|
|
4030
|
+
try {
|
|
4031
|
+
await this.cancel({ sessionId });
|
|
4032
|
+
}
|
|
4033
|
+
catch (error) {
|
|
4034
|
+
this.logger.error(`Session ${sessionId}: cancellation failed during teardown`, error);
|
|
4035
|
+
}
|
|
3662
4036
|
// cancel() arms the force-cancel floor and interrupts gracefully, but a
|
|
3663
4037
|
// wedged consumer only wakes when `cancelController` aborts — closeQueryStream
|
|
3664
4038
|
// below doesn't touch it. Since we're tearing the session down anyway, wake
|
|
@@ -3674,6 +4048,10 @@ export class ClaudeAcpAgent {
|
|
|
3674
4048
|
// here the client has asked us to close the session, so signalling abort is
|
|
3675
4049
|
// appropriate; query.close() above has already torn the subprocess down.
|
|
3676
4050
|
session.abortController.abort();
|
|
4051
|
+
session.eagerToolCallSessions?.clear();
|
|
4052
|
+
clearHookCallbacks(sessionId);
|
|
4053
|
+
session.nativeSubagentRuntime?.clear();
|
|
4054
|
+
session.asyncTaskRuntime?.clear();
|
|
3677
4055
|
delete this.sessions[sessionId];
|
|
3678
4056
|
}
|
|
3679
4057
|
/** Tear down all active sessions. Called when the ACP connection closes. */
|
|
@@ -3697,19 +4075,7 @@ export class ClaudeAcpAgent {
|
|
|
3697
4075
|
return {};
|
|
3698
4076
|
}
|
|
3699
4077
|
async setSessionMode(params) {
|
|
3700
|
-
|
|
3701
|
-
if (!session) {
|
|
3702
|
-
throw new Error("Session not found");
|
|
3703
|
-
}
|
|
3704
|
-
// The SDK query stream already ended (see closeQueryStream); the session is
|
|
3705
|
-
// a husk and `query.setPermissionMode` below would act on a closed query.
|
|
3706
|
-
// Fail with the same clear message prompt()/cancel() give for a dead stream.
|
|
3707
|
-
if (session.queryClosed) {
|
|
3708
|
-
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
3709
|
-
}
|
|
3710
|
-
await this.applySessionMode(params.sessionId, params.modeId);
|
|
3711
|
-
await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId);
|
|
3712
|
-
return {};
|
|
4078
|
+
return this.sessionModes.setSessionMode(params);
|
|
3713
4079
|
}
|
|
3714
4080
|
async setSessionConfigOption(params) {
|
|
3715
4081
|
const session = this.sessions[params.sessionId];
|
|
@@ -3779,15 +4145,10 @@ export class ClaudeAcpAgent {
|
|
|
3779
4145
|
// Use the canonical option value so downstream code always receives the
|
|
3780
4146
|
// model ID rather than the caller-supplied alias.
|
|
3781
4147
|
const resolvedValue = validValue.value;
|
|
4148
|
+
let effectiveValue = resolvedValue;
|
|
3782
4149
|
if (params.configId === MODE_CONFIG_ID) {
|
|
3783
|
-
await this.
|
|
3784
|
-
await this.
|
|
3785
|
-
sessionId: params.sessionId,
|
|
3786
|
-
update: {
|
|
3787
|
-
sessionUpdate: "current_mode_update",
|
|
3788
|
-
currentModeId: resolvedValue,
|
|
3789
|
-
},
|
|
3790
|
-
});
|
|
4150
|
+
effectiveValue = await this.sessionModes.selectMode(params.sessionId, resolvedValue);
|
|
4151
|
+
await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
|
|
3791
4152
|
}
|
|
3792
4153
|
else if (params.configId === MODEL_CONFIG_ID) {
|
|
3793
4154
|
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
|
@@ -3795,44 +4156,9 @@ export class ClaudeAcpAgent {
|
|
|
3795
4156
|
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
|
3796
4157
|
// effort changes and effort changes induced by a model switch go through
|
|
3797
4158
|
// the same path.
|
|
3798
|
-
await this.applyConfigOptionValue(params.sessionId, session, params.configId,
|
|
4159
|
+
await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
|
|
3799
4160
|
return { configOptions: session.configOptions };
|
|
3800
4161
|
}
|
|
3801
|
-
async applySessionMode(sessionId, modeId) {
|
|
3802
|
-
switch (modeId) {
|
|
3803
|
-
case "auto":
|
|
3804
|
-
case "default":
|
|
3805
|
-
case "acceptEdits":
|
|
3806
|
-
case "bypassPermissions":
|
|
3807
|
-
case "dontAsk":
|
|
3808
|
-
case "plan":
|
|
3809
|
-
break;
|
|
3810
|
-
default:
|
|
3811
|
-
throw new Error("Invalid Mode");
|
|
3812
|
-
}
|
|
3813
|
-
const session = this.sessions[sessionId];
|
|
3814
|
-
if (!session) {
|
|
3815
|
-
throw new Error("Session not found");
|
|
3816
|
-
}
|
|
3817
|
-
if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
|
|
3818
|
-
throw new Error(`Mode ${modeId} is not available in this session`);
|
|
3819
|
-
}
|
|
3820
|
-
try {
|
|
3821
|
-
await session.query.setPermissionMode(modeId);
|
|
3822
|
-
}
|
|
3823
|
-
catch (error) {
|
|
3824
|
-
if (error instanceof Error) {
|
|
3825
|
-
if (!error.message) {
|
|
3826
|
-
error.message = "Invalid Mode";
|
|
3827
|
-
}
|
|
3828
|
-
throw error;
|
|
3829
|
-
}
|
|
3830
|
-
else {
|
|
3831
|
-
// eslint-disable-next-line preserve-caught-error
|
|
3832
|
-
throw new Error("Invalid Mode");
|
|
3833
|
-
}
|
|
3834
|
-
}
|
|
3835
|
-
}
|
|
3836
4162
|
async replaySessionHistory(sessionId) {
|
|
3837
4163
|
const toolUseCache = {};
|
|
3838
4164
|
const messages = await getSessionMessages(sessionId);
|
|
@@ -3858,6 +4184,101 @@ export class ClaudeAcpAgent {
|
|
|
3858
4184
|
// ends an incomplete audit lane.
|
|
3859
4185
|
let replayingFileChangeAudit = false;
|
|
3860
4186
|
const replayFileChangeAuditToolUseIds = new Set();
|
|
4187
|
+
const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
|
|
4188
|
+
const replayTerminalStates = new Map();
|
|
4189
|
+
const replayChildren = new Map();
|
|
4190
|
+
if (nativeReplayEnabled) {
|
|
4191
|
+
for (const message of messages) {
|
|
4192
|
+
const content = message.message
|
|
4193
|
+
?.content;
|
|
4194
|
+
if (!Array.isArray(content))
|
|
4195
|
+
continue;
|
|
4196
|
+
const ownerToolUseId = parentToolUseIdOf(message);
|
|
4197
|
+
for (const block of content) {
|
|
4198
|
+
if (typeof block === "object" &&
|
|
4199
|
+
block !== null &&
|
|
4200
|
+
"type" in block &&
|
|
4201
|
+
(block.type === "tool_result" || block.type === "mcp_tool_result") &&
|
|
4202
|
+
"tool_use_id" in block &&
|
|
4203
|
+
typeof block.tool_use_id === "string") {
|
|
4204
|
+
replayTerminalStates.set(block.tool_use_id, replaySubagentTerminalState(block));
|
|
4205
|
+
}
|
|
4206
|
+
if (typeof block !== "object" ||
|
|
4207
|
+
block === null ||
|
|
4208
|
+
!("type" in block) ||
|
|
4209
|
+
!["tool_use", "server_tool_use", "mcp_tool_use"].includes(String(block.type)) ||
|
|
4210
|
+
!("id" in block) ||
|
|
4211
|
+
typeof block.id !== "string" ||
|
|
4212
|
+
!("name" in block) ||
|
|
4213
|
+
!isNativeSubagentControlTool(block.name)) {
|
|
4214
|
+
continue;
|
|
4215
|
+
}
|
|
4216
|
+
const input = "input" in block && typeof block.input === "object" && block.input !== null
|
|
4217
|
+
? block.input
|
|
4218
|
+
: {};
|
|
4219
|
+
const task = [input.prompt, input.description]
|
|
4220
|
+
.find((value) => typeof value === "string" && value.trim().length > 0)
|
|
4221
|
+
?.trim() ?? "Delegated task restored from session history";
|
|
4222
|
+
const name = [input.name, input.description, input.subagent_type]
|
|
4223
|
+
.find((value) => typeof value === "string" && value.trim().length > 0)
|
|
4224
|
+
?.trim() ?? "Restored agent";
|
|
4225
|
+
replayChildren.set(block.id, {
|
|
4226
|
+
sessionId: `${sessionId}:replay-subagent:${block.id}`,
|
|
4227
|
+
...(ownerToolUseId ? { parentToolUseId: ownerToolUseId } : {}),
|
|
4228
|
+
name,
|
|
4229
|
+
task,
|
|
4230
|
+
reconstructable: true,
|
|
4231
|
+
announced: false,
|
|
4232
|
+
terminalState: replayTerminalStates.get(block.id),
|
|
4233
|
+
});
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
for (const [toolUseId, terminalState] of replayTerminalStates) {
|
|
4237
|
+
const child = replayChildren.get(toolUseId);
|
|
4238
|
+
if (child)
|
|
4239
|
+
child.terminalState = terminalState;
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
const announceReplayChild = async (parentToolUseId, ancestors = new Set()) => {
|
|
4243
|
+
let child = replayChildren.get(parentToolUseId);
|
|
4244
|
+
if (!child) {
|
|
4245
|
+
child = {
|
|
4246
|
+
sessionId: `${sessionId}:replay-subagent:${parentToolUseId}`,
|
|
4247
|
+
name: "Disconnected agent",
|
|
4248
|
+
task: "Subagent restored without persisted launch metadata",
|
|
4249
|
+
reconstructable: false,
|
|
4250
|
+
announced: false,
|
|
4251
|
+
};
|
|
4252
|
+
replayChildren.set(parentToolUseId, child);
|
|
4253
|
+
}
|
|
4254
|
+
if (ancestors.has(parentToolUseId)) {
|
|
4255
|
+
child.reconstructable = false;
|
|
4256
|
+
child.terminalState = undefined;
|
|
4257
|
+
child.parentToolUseId = undefined;
|
|
4258
|
+
}
|
|
4259
|
+
if (child.parentToolUseId) {
|
|
4260
|
+
const nextAncestors = new Set(ancestors);
|
|
4261
|
+
nextAncestors.add(parentToolUseId);
|
|
4262
|
+
await announceReplayChild(child.parentToolUseId, nextAncestors);
|
|
4263
|
+
}
|
|
4264
|
+
if (!child.announced) {
|
|
4265
|
+
const parentSessionId = child.parentToolUseId
|
|
4266
|
+
? (replayChildren.get(child.parentToolUseId)?.sessionId ?? sessionId)
|
|
4267
|
+
: sessionId;
|
|
4268
|
+
await this.client.sessionUpdate(asSdkSessionNotification({
|
|
4269
|
+
sessionId: parentSessionId,
|
|
4270
|
+
update: {
|
|
4271
|
+
sessionUpdate: "subagent_spawned",
|
|
4272
|
+
subagentSessionId: child.sessionId,
|
|
4273
|
+
name: child.name,
|
|
4274
|
+
task: child.task,
|
|
4275
|
+
capabilities: {},
|
|
4276
|
+
},
|
|
4277
|
+
}));
|
|
4278
|
+
child.announced = true;
|
|
4279
|
+
}
|
|
4280
|
+
return child.sessionId;
|
|
4281
|
+
};
|
|
3861
4282
|
for (const message of messages) {
|
|
3862
4283
|
if (message.type === "user" &&
|
|
3863
4284
|
message.parent_tool_use_id === null &&
|
|
@@ -3898,7 +4319,13 @@ export class ClaudeAcpAgent {
|
|
|
3898
4319
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3899
4320
|
let content = message.message.content;
|
|
3900
4321
|
const parentToolUseId = parentToolUseIdOf(message);
|
|
3901
|
-
|
|
4322
|
+
const replayTargetSessionId = nativeReplayEnabled && parentToolUseId
|
|
4323
|
+
? await announceReplayChild(parentToolUseId)
|
|
4324
|
+
: sessionId;
|
|
4325
|
+
if (message.type === "assistant" &&
|
|
4326
|
+
parentToolUseId &&
|
|
4327
|
+
!nativeReplayEnabled &&
|
|
4328
|
+
!forwardSubagentText) {
|
|
3902
4329
|
content = stripSubagentTextAndThinking(content);
|
|
3903
4330
|
}
|
|
3904
4331
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
@@ -3970,7 +4397,37 @@ export class ClaudeAcpAgent {
|
|
|
3970
4397
|
messageId: replayMessageId,
|
|
3971
4398
|
parentToolUseId,
|
|
3972
4399
|
})) {
|
|
3973
|
-
|
|
4400
|
+
const toolName = notification.update._meta?.claudeCode?.toolName;
|
|
4401
|
+
if (nativeReplayEnabled &&
|
|
4402
|
+
(notification.update.sessionUpdate === "tool_call" ||
|
|
4403
|
+
notification.update.sessionUpdate === "tool_call_update") &&
|
|
4404
|
+
isNativeSubagentControlTool(toolName)) {
|
|
4405
|
+
continue;
|
|
4406
|
+
}
|
|
4407
|
+
await this.client.sessionUpdate({ ...notification, sessionId: replayTargetSessionId });
|
|
4408
|
+
}
|
|
4409
|
+
}
|
|
4410
|
+
if (nativeReplayEnabled) {
|
|
4411
|
+
// Claude history persists sidechain messages and Agent/Task tool uses,
|
|
4412
|
+
// but not task_started/task_updated lifecycle frames. Recover terminal
|
|
4413
|
+
// state from the launch tool_result. Missing results and malformed or
|
|
4414
|
+
// orphan lineage use the draft protocol's deterministic disconnected state.
|
|
4415
|
+
for (const child of [...replayChildren.values()].reverse()) {
|
|
4416
|
+
if (!child.announced)
|
|
4417
|
+
continue;
|
|
4418
|
+
const parentSessionId = child.parentToolUseId
|
|
4419
|
+
? (replayChildren.get(child.parentToolUseId)?.sessionId ?? sessionId)
|
|
4420
|
+
: sessionId;
|
|
4421
|
+
await this.client.sessionUpdate(asSdkSessionNotification({
|
|
4422
|
+
sessionId: parentSessionId,
|
|
4423
|
+
update: {
|
|
4424
|
+
sessionUpdate: "subagent_state_update",
|
|
4425
|
+
subagentSessionId: child.sessionId,
|
|
4426
|
+
state: child.reconstructable
|
|
4427
|
+
? (child.terminalState ?? "disconnected")
|
|
4428
|
+
: "disconnected",
|
|
4429
|
+
},
|
|
4430
|
+
}));
|
|
3974
4431
|
}
|
|
3975
4432
|
}
|
|
3976
4433
|
}
|
|
@@ -3982,23 +4439,45 @@ export class ClaudeAcpAgent {
|
|
|
3982
4439
|
const response = await this.client.writeTextFile(params);
|
|
3983
4440
|
return response;
|
|
3984
4441
|
}
|
|
4442
|
+
/** Mark a client request as blocking on user input for exactly the lifetime
|
|
4443
|
+
* of its promise. Steering consults this session-local count synchronously,
|
|
4444
|
+
* so a message arriving while any permission/elicitation card is open uses
|
|
4445
|
+
* non-interrupting SDK delivery. */
|
|
4446
|
+
async withPendingUserInput(sessionId, request) {
|
|
4447
|
+
const session = this.sessions[sessionId];
|
|
4448
|
+
if (!session)
|
|
4449
|
+
return request();
|
|
4450
|
+
session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
|
|
4451
|
+
try {
|
|
4452
|
+
return await request();
|
|
4453
|
+
}
|
|
4454
|
+
finally {
|
|
4455
|
+
session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
3985
4458
|
/** Forward a permission request to the client, wiring the tool call's
|
|
3986
4459
|
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
|
3987
4460
|
* while the client's prompt is still open the signal aborts, the SDK sends
|
|
3988
|
-
* `$/cancel_request`, and
|
|
3989
|
-
*
|
|
3990
|
-
* "Tool use aborted" the callers already expect
|
|
3991
|
-
|
|
3992
|
-
|
|
4461
|
+
* `$/cancel_request`, and our local abort race settles even if the client
|
|
4462
|
+
* ignores it. A `cancelled` outcome, request rejection, and local abort all
|
|
4463
|
+
* surface the same "Tool use aborted" the callers already expect. */
|
|
4464
|
+
async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId = params.sessionId) {
|
|
4465
|
+
if (signal.aborted)
|
|
4466
|
+
throw new Error("Tool use aborted");
|
|
3993
4467
|
// The SDK may invoke `canUseTool` (and therefore this permission request)
|
|
3994
4468
|
// before the assistant message's tool_use block streams to us. Some ACP clients
|
|
3995
4469
|
// expect the `tool_call` a permission request references to already exist,
|
|
3996
4470
|
// so emit it now if it hasn't been sent yet. The streamed tool_use chunk
|
|
3997
4471
|
// later refines it with a `tool_call_update` rather than emitting a
|
|
3998
4472
|
// duplicate (see `emittedToolCalls` in `toAcpNotifications`).
|
|
3999
|
-
await this.ensureToolCallEmitted(
|
|
4473
|
+
await this.ensureToolCallEmitted(ownerSessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, signal, params.sessionId);
|
|
4474
|
+
if (signal.aborted)
|
|
4475
|
+
throw new Error("Tool use aborted");
|
|
4476
|
+
// Do not rely on every ACP client settling requestPermission after the
|
|
4477
|
+
// cancellation signal. The local race guarantees that Claude's tool call
|
|
4478
|
+
// is released even when an older or broken client ignores $/cancel_request.
|
|
4000
4479
|
try {
|
|
4001
|
-
return await this.client.requestPermission(params, signal);
|
|
4480
|
+
return await this.withPendingUserInput(params.sessionId, () => raceWithAbort(this.client.requestPermission(params, signal), signal));
|
|
4002
4481
|
}
|
|
4003
4482
|
catch (error) {
|
|
4004
4483
|
if (signal.aborted) {
|
|
@@ -4019,7 +4498,7 @@ export class ClaudeAcpAgent {
|
|
|
4019
4498
|
* are resolved at tool_result time instead (see `toAcpNotifications`).
|
|
4020
4499
|
* `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
|
|
4021
4500
|
* that spawned it, matching the streamed path's `_meta`. */
|
|
4022
|
-
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
|
|
4501
|
+
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId, signal, notificationSessionId = sessionId) {
|
|
4023
4502
|
const session = this.sessions[sessionId];
|
|
4024
4503
|
if (!session) {
|
|
4025
4504
|
return;
|
|
@@ -4028,6 +4507,7 @@ export class ClaudeAcpAgent {
|
|
|
4028
4507
|
return;
|
|
4029
4508
|
}
|
|
4030
4509
|
session.emittedToolCalls.add(toolCallId);
|
|
4510
|
+
(session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
|
|
4031
4511
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
4032
4512
|
const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
|
|
4033
4513
|
if (parentToolUseId) {
|
|
@@ -4039,10 +4519,21 @@ export class ClaudeAcpAgent {
|
|
|
4039
4519
|
},
|
|
4040
4520
|
};
|
|
4041
4521
|
}
|
|
4042
|
-
|
|
4522
|
+
try {
|
|
4523
|
+
const emission = this.client.sessionUpdate({ sessionId: notificationSessionId, update });
|
|
4524
|
+
await (signal ? raceWithAbort(emission, signal) : emission);
|
|
4525
|
+
}
|
|
4526
|
+
catch (error) {
|
|
4527
|
+
// The set is also the de-duplication guard for the later streamed
|
|
4528
|
+
// tool_use. Keep it truthful: if emission failed, that path must still
|
|
4529
|
+
// be allowed to publish the tool call instead of refining a phantom one.
|
|
4530
|
+
session.emittedToolCalls.delete(toolCallId);
|
|
4531
|
+
session.eagerToolCallSessions?.delete(toolCallId);
|
|
4532
|
+
throw error;
|
|
4533
|
+
}
|
|
4043
4534
|
}
|
|
4044
4535
|
canUseTool(sessionId) {
|
|
4045
|
-
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
|
|
4536
|
+
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule, blockedPath, decisionReason, title, displayName, description, }) => {
|
|
4046
4537
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
4047
4538
|
const session = this.sessions[sessionId];
|
|
4048
4539
|
if (!session) {
|
|
@@ -4078,6 +4569,17 @@ export class ClaudeAcpAgent {
|
|
|
4078
4569
|
const parentToolUseId = agentID
|
|
4079
4570
|
? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
|
|
4080
4571
|
: undefined;
|
|
4572
|
+
const permissionSessionId = clientSupportsSubagents(this.clientCapabilities) && agentID
|
|
4573
|
+
? (() => {
|
|
4574
|
+
const child = session.nativeSubagentsByTaskId?.get(agentID);
|
|
4575
|
+
// A request must never target a child session before its
|
|
4576
|
+
// subagent_spawned notification. In the rare SDK ordering where
|
|
4577
|
+
// canUseTool beats the spawning Agent/Task frame, keep the
|
|
4578
|
+
// permission on the root session; the later frame will announce
|
|
4579
|
+
// the child with correct nested lineage.
|
|
4580
|
+
return child?.announced ? child.sessionId : sessionId;
|
|
4581
|
+
})()
|
|
4582
|
+
: sessionId;
|
|
4081
4583
|
if (agentID && !parentToolUseId) {
|
|
4082
4584
|
// The attribution rests on an undocumented SDK invariant
|
|
4083
4585
|
// (task_started.task_id === canUseTool's agentID for subagent tasks;
|
|
@@ -4095,152 +4597,81 @@ export class ClaudeAcpAgent {
|
|
|
4095
4597
|
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
|
4096
4598
|
// Like permission requests, the elicitation references this toolUseID, so
|
|
4097
4599
|
// make sure the tool_call has surfaced to the client before we send it.
|
|
4098
|
-
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
|
|
4099
|
-
return this.handleAskUserQuestion(
|
|
4600
|
+
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId, signal, permissionSessionId);
|
|
4601
|
+
return this.handleAskUserQuestion(permissionSessionId, toolInput, toolUseID, signal);
|
|
4100
4602
|
}
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
// "keep planning" reject path; it's always present in `availableModes`.
|
|
4124
|
-
const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
|
|
4125
|
-
const response = await this.requestPermissionFromClient({
|
|
4126
|
-
options,
|
|
4127
|
-
sessionId,
|
|
4128
|
-
toolCall: {
|
|
4129
|
-
toolCallId: toolUseID,
|
|
4130
|
-
rawInput: toolInput,
|
|
4131
|
-
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
4132
|
-
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
|
|
4133
|
-
// so clients can rely on one shape everywhere.
|
|
4134
|
-
...(parentToolUseId
|
|
4135
|
-
? { _meta: { claudeCode: { toolName, parentToolUseId } } }
|
|
4136
|
-
: {}),
|
|
4137
|
-
},
|
|
4138
|
-
}, toolName, signal, parentToolUseId);
|
|
4139
|
-
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
4140
|
-
throw new Error("Tool use aborted");
|
|
4141
|
-
}
|
|
4142
|
-
const selectedMode = response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
|
|
4143
|
-
const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
|
|
4144
|
-
if (selectedModeWasOffered &&
|
|
4145
|
-
(selectedMode === "default" ||
|
|
4146
|
-
selectedMode === "acceptEdits" ||
|
|
4147
|
-
selectedMode === "auto" ||
|
|
4148
|
-
selectedMode === "bypassPermissions")) {
|
|
4149
|
-
await this.client.sessionUpdate({
|
|
4150
|
-
sessionId,
|
|
4151
|
-
update: {
|
|
4152
|
-
sessionUpdate: "current_mode_update",
|
|
4153
|
-
currentModeId: selectedMode,
|
|
4154
|
-
},
|
|
4155
|
-
});
|
|
4156
|
-
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode);
|
|
4157
|
-
return {
|
|
4158
|
-
behavior: "allow",
|
|
4159
|
-
updatedInput: toolInput,
|
|
4160
|
-
updatedPermissions: suggestions ?? [
|
|
4161
|
-
{ type: "setMode", mode: selectedMode, destination: "session" },
|
|
4162
|
-
],
|
|
4163
|
-
};
|
|
4164
|
-
}
|
|
4165
|
-
else {
|
|
4166
|
-
return {
|
|
4167
|
-
behavior: "deny",
|
|
4168
|
-
message: "User rejected request to exit plan mode.",
|
|
4169
|
-
};
|
|
4170
|
-
}
|
|
4171
|
-
}
|
|
4172
|
-
// In bypass mode the CLI skips permission checks itself; the asks that
|
|
4173
|
-
// still reach canUseTool are the ones it insists on prompting for even
|
|
4174
|
-
// under --dangerously-skip-permissions. Keep auto-allowing those —
|
|
4175
|
-
// bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
|
|
4176
|
-
// user explicitly configured a permissions.ask rule for this tool, and
|
|
4177
|
-
// the SDK's guidance is that hosts running auto-approval must treat such
|
|
4178
|
-
// asks as a human prompt. Fall through to the normal request below.
|
|
4179
|
-
if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
|
|
4180
|
-
return {
|
|
4181
|
-
behavior: "allow",
|
|
4182
|
-
updatedInput: toolInput,
|
|
4183
|
-
updatedPermissions: suggestions ?? [
|
|
4184
|
-
{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
|
|
4185
|
-
],
|
|
4603
|
+
// Do not auto-allow here based on the session's advertised mode. Claude
|
|
4604
|
+
// Code applies bypassPermissions before invoking canUseTool; a request
|
|
4605
|
+
// that still reaches this callback is deliberately bypass-immune (for
|
|
4606
|
+
// example a safety check, a tool requiring user interaction, or an
|
|
4607
|
+
// explicit ask rule). Re-applying bypass in the host would erase that
|
|
4608
|
+
// provider safety decision.
|
|
4609
|
+
const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, matchedAskRule !== undefined);
|
|
4610
|
+
const presentation = buildClaudePermissionPresentation({
|
|
4611
|
+
toolName,
|
|
4612
|
+
input: toolInput,
|
|
4613
|
+
toolUseID,
|
|
4614
|
+
cwd: session.cwd,
|
|
4615
|
+
supportsTerminalOutput,
|
|
4616
|
+
blockedPath,
|
|
4617
|
+
title,
|
|
4618
|
+
displayName,
|
|
4619
|
+
description,
|
|
4620
|
+
decisionReason,
|
|
4621
|
+
});
|
|
4622
|
+
if (parentToolUseId) {
|
|
4623
|
+
presentation.toolCall._meta = {
|
|
4624
|
+
claudeCode: { toolName, parentToolUseId },
|
|
4186
4625
|
};
|
|
4187
4626
|
}
|
|
4627
|
+
const permissionOptions = buildClaudePermissionOptions({
|
|
4628
|
+
toolName,
|
|
4629
|
+
displayName,
|
|
4630
|
+
input: toolInput,
|
|
4631
|
+
cwd: session.cwd,
|
|
4632
|
+
durableChangeSet,
|
|
4633
|
+
allowPersistentOptions: matchedAskRule === undefined,
|
|
4634
|
+
availableModes: this.sessionModes.availableModeIds(session.modes),
|
|
4635
|
+
contextUsedPercent: session.contextUsedTokens === undefined || session.contextWindowSize <= 0
|
|
4636
|
+
? undefined
|
|
4637
|
+
: Math.max(0, Math.min(100, Math.round((session.contextUsedTokens / session.contextWindowSize) * 100))),
|
|
4638
|
+
});
|
|
4188
4639
|
const response = await this.requestPermissionFromClient({
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
name: "Always Allow",
|
|
4195
|
-
optionId: "allow_always",
|
|
4196
|
-
_meta: {
|
|
4197
|
-
permission: permissionMetadataForAlwaysAllow(suggestions, toolName),
|
|
4198
|
-
},
|
|
4199
|
-
},
|
|
4200
|
-
],
|
|
4201
|
-
sessionId,
|
|
4202
|
-
toolCall: {
|
|
4203
|
-
toolCallId: toolUseID,
|
|
4204
|
-
rawInput: toolInput,
|
|
4205
|
-
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
4206
|
-
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
|
|
4207
|
-
// so clients can rely on one shape everywhere.
|
|
4208
|
-
...(parentToolUseId
|
|
4209
|
-
? { _meta: { claudeCode: { toolName, parentToolUseId } } }
|
|
4210
|
-
: {}),
|
|
4211
|
-
},
|
|
4212
|
-
}, toolName, signal, parentToolUseId);
|
|
4213
|
-
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
4640
|
+
...presentation,
|
|
4641
|
+
options: permissionOptions,
|
|
4642
|
+
sessionId: permissionSessionId,
|
|
4643
|
+
}, toolName, signal, parentToolUseId, sessionId);
|
|
4644
|
+
if (signal.aborted)
|
|
4214
4645
|
throw new Error("Tool use aborted");
|
|
4646
|
+
const decodedPermission = decodeClaudePermissionResponse(response, toolName, toolInput, toolUseID, permissionOptions, durableChangeSet);
|
|
4647
|
+
let permissionResult = decodedPermission.permissionResult;
|
|
4648
|
+
const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
|
|
4649
|
+
permissionResult = autoFallback.permissionResult;
|
|
4650
|
+
if (autoFallback.fallbackApplied) {
|
|
4651
|
+
await this.sessionModes.publishFallbackWarning(sessionId, session);
|
|
4215
4652
|
}
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
behavior: "allow",
|
|
4228
|
-
destination: "session",
|
|
4229
|
-
},
|
|
4230
|
-
],
|
|
4231
|
-
};
|
|
4232
|
-
}
|
|
4233
|
-
return {
|
|
4234
|
-
behavior: "allow",
|
|
4235
|
-
updatedInput: toolInput,
|
|
4653
|
+
const clearContextMode = decodedPermission.contextResetMode
|
|
4654
|
+
? this.sessionModes.effectiveMode(session, decodedPermission.contextResetMode)
|
|
4655
|
+
: undefined;
|
|
4656
|
+
if (toolName === "ExitPlanMode" && clearContextMode) {
|
|
4657
|
+
const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : "";
|
|
4658
|
+
if (!plan)
|
|
4659
|
+
throw new Error("ExitPlanMode clear-context selection requires a plan");
|
|
4660
|
+
session.pendingExitPlanContextReset = {
|
|
4661
|
+
toolUseId: toolUseID,
|
|
4662
|
+
plan,
|
|
4663
|
+
mode: clearContextMode,
|
|
4236
4664
|
};
|
|
4237
4665
|
}
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4666
|
+
if (toolName === "ExitPlanMode" &&
|
|
4667
|
+
permissionResult.behavior === "deny" &&
|
|
4668
|
+
permissionResult.interrupt === true) {
|
|
4669
|
+
session.pendingExitPlanModeInterruption = {
|
|
4670
|
+
toolUseId: toolUseID,
|
|
4671
|
+
toolResultSeen: false,
|
|
4242
4672
|
};
|
|
4243
4673
|
}
|
|
4674
|
+
return permissionResult;
|
|
4244
4675
|
};
|
|
4245
4676
|
}
|
|
4246
4677
|
/**
|
|
@@ -4259,7 +4690,7 @@ export class ClaudeAcpAgent {
|
|
|
4259
4690
|
return { action: "decline" };
|
|
4260
4691
|
}
|
|
4261
4692
|
try {
|
|
4262
|
-
const response = await this.client.unstable_createElicitation(createRequest, signal);
|
|
4693
|
+
const response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
|
|
4263
4694
|
if (signal.aborted) {
|
|
4264
4695
|
return { action: "cancel" };
|
|
4265
4696
|
}
|
|
@@ -4289,7 +4720,7 @@ export class ClaudeAcpAgent {
|
|
|
4289
4720
|
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
|
4290
4721
|
let response;
|
|
4291
4722
|
try {
|
|
4292
|
-
response = await this.client.unstable_createElicitation(createRequest, signal);
|
|
4723
|
+
response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(createRequest, signal));
|
|
4293
4724
|
}
|
|
4294
4725
|
catch (error) {
|
|
4295
4726
|
// A cancellation we requested (signal aborted) settles as an aborted tool
|
|
@@ -4329,7 +4760,7 @@ export class ClaudeAcpAgent {
|
|
|
4329
4760
|
}
|
|
4330
4761
|
let response;
|
|
4331
4762
|
try {
|
|
4332
|
-
response = await this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal);
|
|
4763
|
+
response = await this.withPendingUserInput(sessionId, () => this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal));
|
|
4333
4764
|
}
|
|
4334
4765
|
catch (error) {
|
|
4335
4766
|
// A cancellation we requested (signal aborted) is expected teardown;
|
|
@@ -4375,12 +4806,11 @@ export class ClaudeAcpAgent {
|
|
|
4375
4806
|
}
|
|
4376
4807
|
async applyConfigOptionValue(sessionId, session, configId, value) {
|
|
4377
4808
|
if (configId === MODE_CONFIG_ID) {
|
|
4378
|
-
|
|
4379
|
-
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
4809
|
+
this.sessionModes.syncConfig(session, value);
|
|
4380
4810
|
}
|
|
4381
4811
|
else if (configId === MODEL_CONFIG_ID) {
|
|
4382
|
-
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for
|
|
4383
|
-
//
|
|
4812
|
+
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for applying
|
|
4813
|
+
// the Auto fallback below; its `displayName`/`description` also let us infer the
|
|
4384
4814
|
// context window for semantic aliases (e.g. `default`) whose ID alone
|
|
4385
4815
|
// carries no "1m" token.
|
|
4386
4816
|
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
@@ -4402,41 +4832,7 @@ export class ClaudeAcpAgent {
|
|
|
4402
4832
|
session.contextWindowAuthoritative = seeded.authoritative;
|
|
4403
4833
|
}
|
|
4404
4834
|
session.models = { ...session.models, currentModelId: value };
|
|
4405
|
-
|
|
4406
|
-
// mode if the SDK no longer offers it (today: "auto" on Haiku). An
|
|
4407
|
-
// unknown model (an SDK-initiated refusal fallback to a model outside
|
|
4408
|
-
// the user's `availableModels` allowlist — user-driven switches are
|
|
4409
|
-
// validated against the options first) tells us nothing about its
|
|
4410
|
-
// capabilities, so keep the current modes rather than spuriously
|
|
4411
|
-
// downgrading (e.g. kicking the user out of "auto" for a model that
|
|
4412
|
-
// does support it).
|
|
4413
|
-
const newAvailableModes = newModelInfo
|
|
4414
|
-
? buildAvailableModes(newModelInfo)
|
|
4415
|
-
: session.modes.availableModes;
|
|
4416
|
-
// Capture BEFORE mutating session.modes so the log message reflects
|
|
4417
|
-
// the invalidated mode rather than "default".
|
|
4418
|
-
const previousModeId = session.modes.currentModeId;
|
|
4419
|
-
let modeDowngraded = false;
|
|
4420
|
-
if (!newAvailableModes.some((m) => m.id === previousModeId)) {
|
|
4421
|
-
session.modes = {
|
|
4422
|
-
availableModes: newAvailableModes,
|
|
4423
|
-
currentModeId: "default",
|
|
4424
|
-
};
|
|
4425
|
-
try {
|
|
4426
|
-
await session.query.setPermissionMode("default");
|
|
4427
|
-
}
|
|
4428
|
-
catch (err) {
|
|
4429
|
-
// Failing the entire model switch over a bookkeeping sync error is
|
|
4430
|
-
// worse UX than logging and continuing; the user explicitly asked
|
|
4431
|
-
// to change models. The next setPermissionMode from the user will
|
|
4432
|
-
// either succeed or surface a fresh error.
|
|
4433
|
-
this.logger.error(`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, err);
|
|
4434
|
-
}
|
|
4435
|
-
modeDowngraded = true;
|
|
4436
|
-
}
|
|
4437
|
-
else {
|
|
4438
|
-
session.modes = { ...session.modes, availableModes: newAvailableModes };
|
|
4439
|
-
}
|
|
4835
|
+
const modeDowngraded = await this.sessionModes.reconcileForModel(session, newModelInfo);
|
|
4440
4836
|
// `model_not_allowed` described the model we just left, so it must not
|
|
4441
4837
|
// follow us onto the new one; the remaining reasons are account- or
|
|
4442
4838
|
// environment-scoped and stay true across a switch. Either way the next
|
|
@@ -4471,13 +4867,7 @@ export class ClaudeAcpAgent {
|
|
|
4471
4867
|
// still precedes the caller's config_option_update so order-sensitive
|
|
4472
4868
|
// clients update currentModeId before re-rendering the option list.
|
|
4473
4869
|
if (modeDowngraded) {
|
|
4474
|
-
await this.
|
|
4475
|
-
sessionId,
|
|
4476
|
-
update: {
|
|
4477
|
-
sessionUpdate: "current_mode_update",
|
|
4478
|
-
currentModeId: "default",
|
|
4479
|
-
},
|
|
4480
|
-
});
|
|
4870
|
+
await this.sessionModes.publishFallbackState(sessionId, session);
|
|
4481
4871
|
}
|
|
4482
4872
|
}
|
|
4483
4873
|
else if (configId === AGENT_CONFIG_ID) {
|
|
@@ -4678,7 +5068,10 @@ export class ClaudeAcpAgent {
|
|
|
4678
5068
|
// We want to create a new session id unless it is resume,
|
|
4679
5069
|
// but not resume + forkSession.
|
|
4680
5070
|
let sessionId;
|
|
4681
|
-
if (creationOpts.
|
|
5071
|
+
if (creationOpts.publicSessionId) {
|
|
5072
|
+
sessionId = creationOpts.publicSessionId;
|
|
5073
|
+
}
|
|
5074
|
+
else if (creationOpts.forkSession) {
|
|
4682
5075
|
sessionId = randomUUID();
|
|
4683
5076
|
}
|
|
4684
5077
|
else if (creationOpts.resume) {
|
|
@@ -4737,10 +5130,12 @@ export class ClaudeAcpAgent {
|
|
|
4737
5130
|
}
|
|
4738
5131
|
}
|
|
4739
5132
|
const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
|
|
5133
|
+
const initialPermissionMode = creationOpts.permissionMode ?? permissionMode;
|
|
4740
5134
|
// Extract options from _meta if provided
|
|
4741
5135
|
const sessionMeta = params._meta;
|
|
4742
5136
|
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
4743
|
-
const forwardSubagentText =
|
|
5137
|
+
const forwardSubagentText = clientSupportsSubagents(this.clientCapabilities) ||
|
|
5138
|
+
supportsSubagentTranscript(this.clientCapabilities) ||
|
|
4744
5139
|
userProvidedOptions?.forwardSubagentText === true;
|
|
4745
5140
|
// Configure thinking behavior from environment variable
|
|
4746
5141
|
const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
|
|
@@ -4799,13 +5194,36 @@ export class ClaudeAcpAgent {
|
|
|
4799
5194
|
// config concurrently, so re-resolving it after any of the awaits between
|
|
4800
5195
|
// here and the session registration could disagree with the env baked
|
|
4801
5196
|
// into the query.
|
|
5197
|
+
const resolvedProvider = this.resolveProviderConfig();
|
|
5198
|
+
const providerEnv = createEnvForProvider(resolvedProvider);
|
|
5199
|
+
const configuredSettings = userProvidedOptions?.settings ??
|
|
5200
|
+
(modelConfig
|
|
5201
|
+
? {
|
|
5202
|
+
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
|
5203
|
+
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
|
5204
|
+
}
|
|
5205
|
+
: undefined);
|
|
5206
|
+
// Claude Code applies env from settings.json after the subprocess env. Put
|
|
5207
|
+
// an active ACP route in the programmatic settings tier too so user/project
|
|
5208
|
+
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
|
|
5209
|
+
let settings = configuredSettings;
|
|
5210
|
+
if (resolvedProvider) {
|
|
5211
|
+
const baseSettings = typeof configuredSettings === "string"
|
|
5212
|
+
? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
|
|
5213
|
+
: configuredSettings;
|
|
5214
|
+
settings = {
|
|
5215
|
+
...baseSettings,
|
|
5216
|
+
apiKeyHelper: "",
|
|
5217
|
+
env: { ...baseSettings?.env, ...providerEnv },
|
|
5218
|
+
};
|
|
5219
|
+
}
|
|
4802
5220
|
const env = {
|
|
4803
5221
|
...process.env,
|
|
4804
5222
|
...userProvidedOptions?.env,
|
|
4805
5223
|
// Client-managed LLM routing: `providers/set` config wins, else the
|
|
4806
|
-
// legacy gateway auth request.
|
|
4807
|
-
//
|
|
4808
|
-
...
|
|
5224
|
+
// legacy gateway auth request. Routing is baked into the query at
|
|
5225
|
+
// creation; provider updates recreate loaded queries between turns.
|
|
5226
|
+
...providerEnv,
|
|
4809
5227
|
// Opt-in to session state events like when the agent is idle
|
|
4810
5228
|
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
4811
5229
|
};
|
|
@@ -4814,22 +5232,13 @@ export class ClaudeAcpAgent {
|
|
|
4814
5232
|
// SDK, so per-session `_meta` env routing and ambient process-env routing
|
|
4815
5233
|
// are distinguished exactly as the CLI will see them.
|
|
4816
5234
|
const providerCacheKey = providerCacheKeyFor(env);
|
|
5235
|
+
this.logger.log(`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`);
|
|
4817
5236
|
const options = {
|
|
4818
5237
|
systemPrompt,
|
|
4819
5238
|
settingSources: ["user", "project", "local"],
|
|
4820
5239
|
...(thinking !== undefined && { thinking }),
|
|
4821
5240
|
...userProvidedOptions,
|
|
4822
|
-
|
|
4823
|
-
// configuration (e.g. Bedrock model ID overrides). When the caller
|
|
4824
|
-
// provides settings via _meta, we intentionally ignore the env var —
|
|
4825
|
-
// the caller is assumed to have full control over model configuration.
|
|
4826
|
-
...(!userProvidedOptions?.settings &&
|
|
4827
|
-
modelConfig && {
|
|
4828
|
-
settings: {
|
|
4829
|
-
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
|
4830
|
-
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
|
4831
|
-
},
|
|
4832
|
-
}),
|
|
5241
|
+
...(settings && { settings }),
|
|
4833
5242
|
env,
|
|
4834
5243
|
// Override certain fields that must be controlled by ACP
|
|
4835
5244
|
cwd: params.cwd,
|
|
@@ -4845,7 +5254,7 @@ export class ClaudeAcpAgent {
|
|
|
4845
5254
|
// If we want bypassPermissions to be an option, we have to allow it here.
|
|
4846
5255
|
// But it doesn't work in root mode, so we only activate it if it will work.
|
|
4847
5256
|
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
4848
|
-
permissionMode,
|
|
5257
|
+
permissionMode: initialPermissionMode,
|
|
4849
5258
|
canUseTool: this.canUseTool(sessionId),
|
|
4850
5259
|
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
|
4851
5260
|
// when the client advertised support, so non-supporting clients keep the
|
|
@@ -4889,13 +5298,7 @@ export class ClaudeAcpAgent {
|
|
|
4889
5298
|
hooks: [
|
|
4890
5299
|
createPostToolUseHook({
|
|
4891
5300
|
onEnterPlanMode: async () => {
|
|
4892
|
-
await this.
|
|
4893
|
-
sessionId,
|
|
4894
|
-
update: {
|
|
4895
|
-
sessionUpdate: "current_mode_update",
|
|
4896
|
-
currentModeId: "plan",
|
|
4897
|
-
},
|
|
4898
|
-
});
|
|
5301
|
+
await this.sessionModes.publishCurrent(sessionId, "plan");
|
|
4899
5302
|
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
|
|
4900
5303
|
},
|
|
4901
5304
|
}),
|
|
@@ -4933,7 +5336,8 @@ export class ClaudeAcpAgent {
|
|
|
4933
5336
|
},
|
|
4934
5337
|
],
|
|
4935
5338
|
},
|
|
4936
|
-
...creationOpts,
|
|
5339
|
+
...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
|
|
5340
|
+
...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
|
|
4937
5341
|
abortController,
|
|
4938
5342
|
};
|
|
4939
5343
|
// Prefer the official ACP `additionalDirectories` field. Fall back to the
|
|
@@ -4943,7 +5347,7 @@ export class ClaudeAcpAgent {
|
|
|
4943
5347
|
options.additionalDirectories = additionalDirectories;
|
|
4944
5348
|
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
|
4945
5349
|
// Set our own session id if not resuming an existing session.
|
|
4946
|
-
options.sessionId = sessionId;
|
|
5350
|
+
options.sessionId = creationOpts.publicSessionId ? randomUUID() : sessionId;
|
|
4947
5351
|
}
|
|
4948
5352
|
// Handle abort controller from meta options
|
|
4949
5353
|
if (abortController?.signal.aborted) {
|
|
@@ -4982,8 +5386,8 @@ export class ClaudeAcpAgent {
|
|
|
4982
5386
|
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
|
|
4983
5387
|
: initializationResult.models;
|
|
4984
5388
|
const { modelState: models, resumedContextWindow } = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
4985
|
-
//
|
|
4986
|
-
//
|
|
5389
|
+
// Resolve the current model's capabilities separately from the stable
|
|
5390
|
+
// permission-mode catalog advertised to ACP clients.
|
|
4987
5391
|
// A resumed session can be running a model outside the `availableModels`
|
|
4988
5392
|
// allowlist (currentModelId is then the verbatim live id, see
|
|
4989
5393
|
// `matchResumedModel`); its capabilities are still known to the SDK's
|
|
@@ -5017,38 +5421,12 @@ export class ClaudeAcpAgent {
|
|
|
5017
5421
|
},
|
|
5018
5422
|
]
|
|
5019
5423
|
: allowedModels;
|
|
5020
|
-
const
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
// (matches what was passed into `options.permissionMode` above) and use
|
|
5027
|
-
// `effectiveMode` for the post-clamp value the session actually runs in.
|
|
5028
|
-
let effectiveMode = permissionMode;
|
|
5029
|
-
if (!availableModes.some((m) => m.id === effectiveMode)) {
|
|
5030
|
-
if (effectiveMode === "auto") {
|
|
5031
|
-
this.logger.error(`permissions.defaultMode "auto" is not available for model ` +
|
|
5032
|
-
`"${models.currentModelId}"; falling back to "default".`);
|
|
5033
|
-
}
|
|
5034
|
-
else {
|
|
5035
|
-
this.logger.error(`permissions.defaultMode "${effectiveMode}" is not available in ` +
|
|
5036
|
-
`this session; falling back to "default".`);
|
|
5037
|
-
}
|
|
5038
|
-
effectiveMode = "default";
|
|
5039
|
-
// Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
|
|
5040
|
-
// try/catch since failing here would abort session creation entirely.
|
|
5041
|
-
try {
|
|
5042
|
-
await q.setPermissionMode("default");
|
|
5043
|
-
}
|
|
5044
|
-
catch (err) {
|
|
5045
|
-
this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
|
|
5046
|
-
}
|
|
5047
|
-
}
|
|
5048
|
-
const modes = {
|
|
5049
|
-
currentModeId: effectiveMode,
|
|
5050
|
-
availableModes,
|
|
5051
|
-
};
|
|
5424
|
+
const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
|
|
5425
|
+
query: q,
|
|
5426
|
+
requestedMode: initialPermissionMode,
|
|
5427
|
+
currentModelInfo,
|
|
5428
|
+
currentModelId: models.currentModelId,
|
|
5429
|
+
});
|
|
5052
5430
|
const agents = await discoverCustomAgents(q);
|
|
5053
5431
|
// Only adopt the requested agent as the selected value if it's one we
|
|
5054
5432
|
// actually surface in the picker. A built-in (filtered out above) or
|
|
@@ -5124,16 +5502,22 @@ export class ClaudeAcpAgent {
|
|
|
5124
5502
|
cancelled: false,
|
|
5125
5503
|
cwd: params.cwd,
|
|
5126
5504
|
sessionFingerprint: computeSessionFingerprint(params),
|
|
5505
|
+
creationParams: params,
|
|
5127
5506
|
settingsManager,
|
|
5507
|
+
titles: new SessionTitles(this, sessionId),
|
|
5128
5508
|
accumulatedUsage: {
|
|
5129
5509
|
inputTokens: 0,
|
|
5130
5510
|
outputTokens: 0,
|
|
5131
5511
|
cachedReadTokens: 0,
|
|
5132
5512
|
cachedWriteTokens: 0,
|
|
5133
5513
|
},
|
|
5514
|
+
accumulatedModelUsage: {},
|
|
5515
|
+
lastModelUsageReading: {},
|
|
5134
5516
|
modes,
|
|
5135
5517
|
models,
|
|
5136
5518
|
modelInfos,
|
|
5519
|
+
autoModeFallbackWarningShown: false,
|
|
5520
|
+
autoModeFallbackWarningPending,
|
|
5137
5521
|
configOptions,
|
|
5138
5522
|
agents,
|
|
5139
5523
|
currentAgent,
|
|
@@ -5148,7 +5532,11 @@ export class ClaudeAcpAgent {
|
|
|
5148
5532
|
taskState,
|
|
5149
5533
|
toolUseCache: {},
|
|
5150
5534
|
emittedToolCalls: new Set(),
|
|
5535
|
+
eagerToolCallSessions: new Map(),
|
|
5151
5536
|
liveBackgroundTasks: new Map(),
|
|
5537
|
+
nativeSubagentsByTaskId: new Map(),
|
|
5538
|
+
nativeSubagentTaskIdByToolUseId: new Map(),
|
|
5539
|
+
nativeSubagentParentByToolUseId: new Map(),
|
|
5152
5540
|
emittedAssistantText: false,
|
|
5153
5541
|
owedTrailingIdles: 0,
|
|
5154
5542
|
messageIdToUuid: new Map(),
|
|
@@ -5162,6 +5550,41 @@ export class ClaudeAcpAgent {
|
|
|
5162
5550
|
configOptions,
|
|
5163
5551
|
};
|
|
5164
5552
|
}
|
|
5553
|
+
/**
|
|
5554
|
+
* Provider routing is baked into the environment of each SDK Query. Wait for
|
|
5555
|
+
* all submitted turns to settle, close every query, then resume each Claude
|
|
5556
|
+
* session with the same ID so subsequent turns inherit the new environment.
|
|
5557
|
+
*/
|
|
5558
|
+
async enqueueProviderUpdate(config) {
|
|
5559
|
+
const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve();
|
|
5560
|
+
const update = previous.then(async () => {
|
|
5561
|
+
const sessions = Object.entries(this.sessions);
|
|
5562
|
+
const activeTurns = sessions.flatMap(([, session]) => (session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])));
|
|
5563
|
+
if (activeTurns.length > 0) {
|
|
5564
|
+
this.logger.log(`Waiting for ${activeTurns.length} active Claude turn(s) before provider update`);
|
|
5565
|
+
await Promise.all(activeTurns);
|
|
5566
|
+
}
|
|
5567
|
+
this.providerConfig = config;
|
|
5568
|
+
for (const [sessionId, session] of sessions) {
|
|
5569
|
+
if (this.sessions[sessionId] !== session || !session.creationParams) {
|
|
5570
|
+
continue;
|
|
5571
|
+
}
|
|
5572
|
+
this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
|
|
5573
|
+
this.closeQueryStream(session);
|
|
5574
|
+
delete this.sessions[sessionId];
|
|
5575
|
+
await this.createSession(session.creationParams, { resume: sessionId });
|
|
5576
|
+
}
|
|
5577
|
+
});
|
|
5578
|
+
this.providerUpdate = update;
|
|
5579
|
+
try {
|
|
5580
|
+
await update;
|
|
5581
|
+
}
|
|
5582
|
+
finally {
|
|
5583
|
+
if (this.providerUpdate === update) {
|
|
5584
|
+
this.providerUpdate = null;
|
|
5585
|
+
}
|
|
5586
|
+
}
|
|
5587
|
+
}
|
|
5165
5588
|
}
|
|
5166
5589
|
function shouldEmitRawMessage(config, message) {
|
|
5167
5590
|
if (config === true)
|
|
@@ -5178,12 +5601,125 @@ function sessionUsage(session) {
|
|
|
5178
5601
|
outputTokens: session.accumulatedUsage.outputTokens,
|
|
5179
5602
|
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
|
|
5180
5603
|
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
|
|
5181
|
-
totalTokens: session.accumulatedUsage
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5604
|
+
totalTokens: tallyTotal(session.accumulatedUsage),
|
|
5605
|
+
};
|
|
5606
|
+
}
|
|
5607
|
+
/** The prompt response for a turn ending in `stopReason`: its usage and the
|
|
5608
|
+
* `_meta.quota` breakdown, both read off the session accumulators in the same
|
|
5609
|
+
* breath so a stored outcome (see Turn.deferredSettle / Turn.steeredSettle)
|
|
5610
|
+
* can't carry a usage and a quota from different moments. `extraMeta` merges
|
|
5611
|
+
* in alongside quota — a terminal session failure, today. */
|
|
5612
|
+
function turnOutcome(session, stopReason, extraMeta) {
|
|
5613
|
+
return {
|
|
5614
|
+
stopReason,
|
|
5615
|
+
usage: sessionUsage(session),
|
|
5616
|
+
_meta: { ...turnQuotaMeta(session), ...extraMeta },
|
|
5617
|
+
};
|
|
5618
|
+
}
|
|
5619
|
+
/** `_meta.quota` for a prompt response: what the turn spent, shaped like
|
|
5620
|
+
* codex-acp's (snake_case container keys, camelCase counters) so a client
|
|
5621
|
+
* reads one shape from either agent.
|
|
5622
|
+
*
|
|
5623
|
+
* The two halves have different scopes, and by design don't have to add up.
|
|
5624
|
+
* `token_count` mirrors the response's own `usage`, which the SDK reports for
|
|
5625
|
+
* the MAIN AGENT LOOP only. The `model_usage` rows come from
|
|
5626
|
+
* `result.modelUsage`, which also counts Task subagents, sidechains and
|
|
5627
|
+
* internal calls such as compaction — the accounting-grade figure per the SDK,
|
|
5628
|
+
* so the rows can total more than `token_count`. They are the fuller picture,
|
|
5629
|
+
* not a decomposition of it. */
|
|
5630
|
+
function turnQuotaMeta(session) {
|
|
5631
|
+
return {
|
|
5632
|
+
quota: {
|
|
5633
|
+
token_count: quotaTokenCount(session.accumulatedUsage),
|
|
5634
|
+
model_usage: Object.entries(session.accumulatedModelUsage ?? {}).map(([model, usage]) => ({
|
|
5635
|
+
model,
|
|
5636
|
+
token_count: quotaTokenCount(usage),
|
|
5637
|
+
})),
|
|
5638
|
+
},
|
|
5639
|
+
};
|
|
5640
|
+
}
|
|
5641
|
+
/** One `token_count` object. `cachedInputTokens` is cache reads, matching
|
|
5642
|
+
* codex's field; Claude also reports cache writes, which codex has no slot
|
|
5643
|
+
* for, so those ride along in an extra sibling (the same name the ACP `usage`
|
|
5644
|
+
* field already uses) and are counted in `totalTokens`. `reasoningOutputTokens`
|
|
5645
|
+
* is always 0: Claude bills thinking inside its output tokens and never breaks
|
|
5646
|
+
* it out — the key is kept so the shape stays uniform across agents. */
|
|
5647
|
+
function quotaTokenCount(usage) {
|
|
5648
|
+
return {
|
|
5649
|
+
totalTokens: tallyTotal(usage),
|
|
5650
|
+
inputTokens: usage.inputTokens,
|
|
5651
|
+
cachedInputTokens: usage.cachedReadTokens,
|
|
5652
|
+
cachedWriteTokens: usage.cachedWriteTokens,
|
|
5653
|
+
outputTokens: usage.outputTokens,
|
|
5654
|
+
reasoningOutputTokens: 0,
|
|
5185
5655
|
};
|
|
5186
5656
|
}
|
|
5657
|
+
/** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes
|
|
5658
|
+
* the cache counters, so this is not double-counting (see `totalTokens`). */
|
|
5659
|
+
function tallyTotal(usage) {
|
|
5660
|
+
return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens;
|
|
5661
|
+
}
|
|
5662
|
+
/** Project `result.modelUsage` into our tally shape. Counters are coerced the
|
|
5663
|
+
* way `snapshotFromUsage` coerces the stream's: third-party backends have been
|
|
5664
|
+
* observed omitting fields, and a missing or NaN counter must not reach the
|
|
5665
|
+
* wire as NaN (which `JSON.stringify` writes as `null`). */
|
|
5666
|
+
function normalizeModelUsage(modelUsage) {
|
|
5667
|
+
const tally = {};
|
|
5668
|
+
for (const [model, usage] of Object.entries(modelUsage ?? {})) {
|
|
5669
|
+
tally[model] = {
|
|
5670
|
+
inputTokens: finiteCount(usage?.inputTokens),
|
|
5671
|
+
outputTokens: finiteCount(usage?.outputTokens),
|
|
5672
|
+
cachedReadTokens: finiteCount(usage?.cacheReadInputTokens),
|
|
5673
|
+
cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens),
|
|
5674
|
+
};
|
|
5675
|
+
}
|
|
5676
|
+
return tally;
|
|
5677
|
+
}
|
|
5678
|
+
function finiteCount(value) {
|
|
5679
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
5680
|
+
}
|
|
5681
|
+
/** `current - previous` per model, dropping models with nothing to report so a
|
|
5682
|
+
* turn only lists the models it actually ran on. A reading that fell BELOW the
|
|
5683
|
+
* previous one means the running total restarted under us (a mid-session
|
|
5684
|
+
* /clear, a resumed session starting fresh, a zeroed crash result): there is no
|
|
5685
|
+
* usable reference left to subtract, so the reading itself is the increment. */
|
|
5686
|
+
function modelUsageIncrement(current, previous) {
|
|
5687
|
+
const increment = {};
|
|
5688
|
+
for (const [model, usage] of Object.entries(current)) {
|
|
5689
|
+
const base = previous[model];
|
|
5690
|
+
const subtracted = base
|
|
5691
|
+
? {
|
|
5692
|
+
inputTokens: usage.inputTokens - base.inputTokens,
|
|
5693
|
+
outputTokens: usage.outputTokens - base.outputTokens,
|
|
5694
|
+
cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens,
|
|
5695
|
+
cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens,
|
|
5696
|
+
}
|
|
5697
|
+
: usage;
|
|
5698
|
+
const rewound = Object.values(subtracted).some((count) => count < 0);
|
|
5699
|
+
const resolved = rewound ? usage : subtracted;
|
|
5700
|
+
if (tallyTotal(resolved) > 0) {
|
|
5701
|
+
increment[model] = resolved;
|
|
5702
|
+
}
|
|
5703
|
+
}
|
|
5704
|
+
return increment;
|
|
5705
|
+
}
|
|
5706
|
+
/** Fold `increment` into `base` per model — the per-model counterpart of the
|
|
5707
|
+
* `+=` the turn's flat accumulator uses. */
|
|
5708
|
+
function addModelUsage(base, increment) {
|
|
5709
|
+
const merged = { ...base };
|
|
5710
|
+
for (const [model, usage] of Object.entries(increment)) {
|
|
5711
|
+
const existing = merged[model];
|
|
5712
|
+
merged[model] = existing
|
|
5713
|
+
? {
|
|
5714
|
+
inputTokens: existing.inputTokens + usage.inputTokens,
|
|
5715
|
+
outputTokens: existing.outputTokens + usage.outputTokens,
|
|
5716
|
+
cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens,
|
|
5717
|
+
cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens,
|
|
5718
|
+
}
|
|
5719
|
+
: usage;
|
|
5720
|
+
}
|
|
5721
|
+
return merged;
|
|
5722
|
+
}
|
|
5187
5723
|
/** Sum all four fields as a proxy for post-turn context occupancy: the current
|
|
5188
5724
|
* turn's output becomes next turn's input. Per the Anthropic API, input_tokens
|
|
5189
5725
|
* excludes cache tokens — cache_read and cache_creation are reported
|
|
@@ -5249,13 +5785,27 @@ function createEnvForProvider(config) {
|
|
|
5249
5785
|
if (!config) {
|
|
5250
5786
|
return {};
|
|
5251
5787
|
}
|
|
5788
|
+
const resetRouting = {
|
|
5789
|
+
ANTHROPIC_BASE_URL: "",
|
|
5790
|
+
ANTHROPIC_BEDROCK_BASE_URL: "",
|
|
5791
|
+
ANTHROPIC_VERTEX_BASE_URL: "",
|
|
5792
|
+
CLAUDE_CODE_USE_BEDROCK: "0",
|
|
5793
|
+
CLAUDE_CODE_USE_VERTEX: "0",
|
|
5794
|
+
ANTHROPIC_VERTEX_PROJECT_ID: "",
|
|
5795
|
+
CLOUD_ML_REGION: "",
|
|
5796
|
+
AWS_REGION: "",
|
|
5797
|
+
ANTHROPIC_API_KEY: "",
|
|
5798
|
+
ANTHROPIC_AUTH_TOKEN: "",
|
|
5799
|
+
CLAUDE_CODE_OAUTH_TOKEN: "",
|
|
5800
|
+
};
|
|
5252
5801
|
const customHeaders = Object.entries(config.headers)
|
|
5253
5802
|
.map(([key, value]) => `${key}: ${value}`)
|
|
5254
5803
|
.join("\n");
|
|
5255
5804
|
if (config.apiType === "bedrock") {
|
|
5256
5805
|
return {
|
|
5806
|
+
...resetRouting,
|
|
5257
5807
|
CLAUDE_CODE_USE_BEDROCK: "1",
|
|
5258
|
-
AWS_BEARER_TOKEN_BEDROCK: "
|
|
5808
|
+
AWS_BEARER_TOKEN_BEDROCK: "acp-proxy", // Bypass local AWS credential checks
|
|
5259
5809
|
ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
|
|
5260
5810
|
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
5261
5811
|
};
|
|
@@ -5264,6 +5814,7 @@ function createEnvForProvider(config) {
|
|
|
5264
5814
|
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
|
|
5265
5815
|
// validation; fall back to empty strings defensively.
|
|
5266
5816
|
return {
|
|
5817
|
+
...resetRouting,
|
|
5267
5818
|
CLAUDE_CODE_USE_VERTEX: "1",
|
|
5268
5819
|
ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
|
|
5269
5820
|
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
|
|
@@ -5272,9 +5823,10 @@ function createEnvForProvider(config) {
|
|
|
5272
5823
|
};
|
|
5273
5824
|
}
|
|
5274
5825
|
return {
|
|
5826
|
+
...resetRouting,
|
|
5275
5827
|
ANTHROPIC_BASE_URL: config.baseUrl,
|
|
5276
5828
|
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
5277
|
-
ANTHROPIC_AUTH_TOKEN: "
|
|
5829
|
+
ANTHROPIC_AUTH_TOKEN: "acp-proxy", // Bypass local Claude login checks
|
|
5278
5830
|
};
|
|
5279
5831
|
}
|
|
5280
5832
|
/**
|
|
@@ -5293,50 +5845,6 @@ function isValidBaseUrl(baseUrl) {
|
|
|
5293
5845
|
}
|
|
5294
5846
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
5295
5847
|
}
|
|
5296
|
-
/**
|
|
5297
|
-
* Build the list of permission modes the agent will advertise for the given
|
|
5298
|
-
* model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
|
|
5299
|
-
* the SDK's model-level availability signal. `undefined`/`false` both exclude
|
|
5300
|
-
* `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
|
|
5301
|
-
*/
|
|
5302
|
-
function buildAvailableModes(modelInfo) {
|
|
5303
|
-
const modes = [];
|
|
5304
|
-
// Only advertise "auto" when the SDK reports the model supports it.
|
|
5305
|
-
if (modelInfo?.supportsAutoMode === true) {
|
|
5306
|
-
modes.push({
|
|
5307
|
-
id: "auto",
|
|
5308
|
-
name: "Auto",
|
|
5309
|
-
description: "Use a model classifier to approve/deny permission prompts",
|
|
5310
|
-
});
|
|
5311
|
-
}
|
|
5312
|
-
modes.push({
|
|
5313
|
-
// Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces;
|
|
5314
|
-
// the wire id stays "default" ("manual" is only an accepted input alias).
|
|
5315
|
-
id: "default",
|
|
5316
|
-
name: "Manual",
|
|
5317
|
-
description: "Standard behavior, prompts for dangerous operations",
|
|
5318
|
-
}, {
|
|
5319
|
-
id: "acceptEdits",
|
|
5320
|
-
name: "Accept Edits",
|
|
5321
|
-
description: "Auto-accept file edit operations",
|
|
5322
|
-
}, {
|
|
5323
|
-
id: "plan",
|
|
5324
|
-
name: "Plan Mode",
|
|
5325
|
-
description: "Planning mode, no actual tool execution",
|
|
5326
|
-
}, {
|
|
5327
|
-
id: "dontAsk",
|
|
5328
|
-
name: "Don't Ask",
|
|
5329
|
-
description: "Don't prompt for permissions, deny if not pre-approved",
|
|
5330
|
-
});
|
|
5331
|
-
if (ALLOW_BYPASS) {
|
|
5332
|
-
modes.push({
|
|
5333
|
-
id: "bypassPermissions",
|
|
5334
|
-
name: "Bypass Permissions",
|
|
5335
|
-
description: "Bypass all permission checks",
|
|
5336
|
-
});
|
|
5337
|
-
}
|
|
5338
|
-
return modes;
|
|
5339
|
-
}
|
|
5340
5848
|
// Translate a UI effort value into the flag-layer payload. The SDK
|
|
5341
5849
|
// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
|
|
5342
5850
|
// and only clears a key when an explicit `null` is sent — see
|
|
@@ -5367,7 +5875,6 @@ export const BUILTIN_AGENT_NAMES = new Set([
|
|
|
5367
5875
|
// reserved sentinel: a custom agent named exactly this would collide with it
|
|
5368
5876
|
// (two options sharing the value, selection silently routing to `null`), so we
|
|
5369
5877
|
// exclude that name from discovery.
|
|
5370
|
-
export const DEFAULT_AGENT_ID = "default";
|
|
5371
5878
|
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
|
5372
5879
|
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
|
5373
5880
|
* list if discovery fails so a flaky control request never blocks session
|
|
@@ -5385,9 +5892,8 @@ export async function discoverCustomAgents(q) {
|
|
|
5385
5892
|
* Centralized so the option declarations in `buildConfigOptions` and the
|
|
5386
5893
|
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
|
|
5387
5894
|
* same identifiers and can't drift apart. */
|
|
5388
|
-
export
|
|
5895
|
+
export { MODE_CONFIG_ID };
|
|
5389
5896
|
export const MODEL_CONFIG_ID = "model";
|
|
5390
|
-
export const EFFORT_CONFIG_ID = "effort";
|
|
5391
5897
|
export const AGENT_CONFIG_ID = "agent";
|
|
5392
5898
|
export const FAST_MODE_CONFIG_ID = "fast";
|
|
5393
5899
|
/** Select-fallback values used when the client has not opted into boolean
|
|
@@ -5486,19 +5992,7 @@ export function resolveFastModeEnabled(params) {
|
|
|
5486
5992
|
}
|
|
5487
5993
|
export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
|
|
5488
5994
|
const options = [
|
|
5489
|
-
|
|
5490
|
-
id: MODE_CONFIG_ID,
|
|
5491
|
-
name: "Mode",
|
|
5492
|
-
description: "Session permission mode",
|
|
5493
|
-
category: "mode",
|
|
5494
|
-
type: "select",
|
|
5495
|
-
currentValue: modes.currentModeId,
|
|
5496
|
-
options: modes.availableModes.map((m) => ({
|
|
5497
|
-
value: m.id,
|
|
5498
|
-
name: m.name,
|
|
5499
|
-
description: m.description,
|
|
5500
|
-
})),
|
|
5501
|
-
},
|
|
5995
|
+
SessionModeManager.configOption(modes),
|
|
5502
5996
|
{
|
|
5503
5997
|
id: MODEL_CONFIG_ID,
|
|
5504
5998
|
name: "Model",
|
|
@@ -6167,6 +6661,46 @@ function claudeCodeMetaFromToolUse(toolUse, cwd) {
|
|
|
6167
6661
|
...(skillPath ? { skillPath } : {}),
|
|
6168
6662
|
};
|
|
6169
6663
|
}
|
|
6664
|
+
/**
|
|
6665
|
+
* Marks the Bash `tool_call_update` whose command detached into the background.
|
|
6666
|
+
*
|
|
6667
|
+
* A backgrounded Bash call returns as soon as the command is handed off, so the
|
|
6668
|
+
* card reaches `completed` while the command itself runs on for minutes. ACP has
|
|
6669
|
+
* no tool-call status for "still running elsewhere", so this marker is what lets
|
|
6670
|
+
* a client render the card as backgrounded work instead of finished work. It
|
|
6671
|
+
* rides the update the tool result already emits, so it costs no extra
|
|
6672
|
+
* notification and cannot arrive out of order.
|
|
6673
|
+
*
|
|
6674
|
+
* The command's own lifecycle -- progress, completion, the stop control -- is
|
|
6675
|
+
* published separately as an async task; this says only that the card has one.
|
|
6676
|
+
* Hence the AIR namespace rather than `claudeCode`: to a client without the
|
|
6677
|
+
* `asyncTasks` capability, which is never sent that lifecycle, the marker would
|
|
6678
|
+
* promise a card state it has no way to ever resolve.
|
|
6679
|
+
*/
|
|
6680
|
+
function backgroundedBashToolCall(notification, task, asyncTasksSupported) {
|
|
6681
|
+
const update = notification.update;
|
|
6682
|
+
const toolCallId = task ? nonBlankTaskField(task.toolCallId ?? task.tool_use_id) : undefined;
|
|
6683
|
+
if (!asyncTasksSupported ||
|
|
6684
|
+
!toolCallId ||
|
|
6685
|
+
update.sessionUpdate !== "tool_call_update" ||
|
|
6686
|
+
update.toolCallId !== toolCallId) {
|
|
6687
|
+
return notification;
|
|
6688
|
+
}
|
|
6689
|
+
return {
|
|
6690
|
+
...notification,
|
|
6691
|
+
update: {
|
|
6692
|
+
...update,
|
|
6693
|
+
_meta: withAirMeta(update._meta, AIR_ASYNC_TASKS_CAPABILITY, { backgrounded: true }),
|
|
6694
|
+
},
|
|
6695
|
+
};
|
|
6696
|
+
}
|
|
6697
|
+
/** The task fields arrive as `unknown` off the wire; only non-blank strings carry a link. */
|
|
6698
|
+
function nonBlankTaskField(value) {
|
|
6699
|
+
if (typeof value !== "string")
|
|
6700
|
+
return undefined;
|
|
6701
|
+
const trimmed = value.trim();
|
|
6702
|
+
return trimmed ? trimmed : undefined;
|
|
6703
|
+
}
|
|
6170
6704
|
/** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
|
|
6171
6705
|
const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
|
|
6172
6706
|
/**
|
|
@@ -6258,33 +6792,6 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
6258
6792
|
...(locations ? { locations } : {}),
|
|
6259
6793
|
};
|
|
6260
6794
|
}
|
|
6261
|
-
/** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
|
|
6262
|
-
* wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
|
|
6263
|
-
* by-tool_use_id lookup. Each entry explains why an is_error tool_result
|
|
6264
|
-
* carries harness prose instead of the tool's own output — "user-rejected",
|
|
6265
|
-
* "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
|
|
6266
|
-
* on the wire ahead of schema updates, so no enum check). Malformed entries
|
|
6267
|
-
* are skipped rather than failing the message. */
|
|
6268
|
-
function parseToolResultMeta(raw) {
|
|
6269
|
-
if (!Array.isArray(raw)) {
|
|
6270
|
-
return undefined;
|
|
6271
|
-
}
|
|
6272
|
-
let byToolUseId;
|
|
6273
|
-
for (const entry of raw) {
|
|
6274
|
-
if (typeof entry !== "object" || entry === null) {
|
|
6275
|
-
continue;
|
|
6276
|
-
}
|
|
6277
|
-
const { id, non_execution_kind, user_feedback } = entry;
|
|
6278
|
-
if (typeof id !== "string" || typeof non_execution_kind !== "string") {
|
|
6279
|
-
continue;
|
|
6280
|
-
}
|
|
6281
|
-
(byToolUseId ??= new Map()).set(id, {
|
|
6282
|
-
nonExecutionKind: non_execution_kind,
|
|
6283
|
-
...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
|
|
6284
|
-
});
|
|
6285
|
-
}
|
|
6286
|
-
return byToolUseId;
|
|
6287
|
-
}
|
|
6288
6795
|
/**
|
|
6289
6796
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
6290
6797
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -6432,6 +6939,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6432
6939
|
claudeCode: {
|
|
6433
6940
|
toolResponse,
|
|
6434
6941
|
toolName,
|
|
6942
|
+
...(options?.parentToolUseId
|
|
6943
|
+
? { parentToolUseId: options.parentToolUseId }
|
|
6944
|
+
: {}),
|
|
6435
6945
|
},
|
|
6436
6946
|
},
|
|
6437
6947
|
toolCallId: toolUseId,
|
|
@@ -6443,7 +6953,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6443
6953
|
update,
|
|
6444
6954
|
});
|
|
6445
6955
|
},
|
|
6446
|
-
});
|
|
6956
|
+
}, sessionId);
|
|
6447
6957
|
}
|
|
6448
6958
|
let rawInput;
|
|
6449
6959
|
try {
|
|
@@ -6485,6 +6995,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6485
6995
|
case "mcp_tool_result": {
|
|
6486
6996
|
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
|
|
6487
6997
|
options?.emittedToolCalls?.delete(chunk.tool_use_id);
|
|
6998
|
+
completeHookCallback(chunk.tool_use_id);
|
|
6488
6999
|
// Why this is_error result carries harness prose instead of tool
|
|
6489
7000
|
// output (user-rejected / interrupted / …), when the SDK said so.
|
|
6490
7001
|
// Spread into the claudeCode meta of every update emitted below; the
|
|
@@ -6622,7 +7133,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6622
7133
|
toolCallId: chunk.tool_use_id,
|
|
6623
7134
|
sessionUpdate: "tool_call_update",
|
|
6624
7135
|
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
|
6625
|
-
|
|
7136
|
+
// terminal_output already carried the exact bytes in the preceding
|
|
7137
|
+
// update. Repeating them as rawOutput wastes bandwidth and lets a
|
|
7138
|
+
// client accidentally render the same output twice.
|
|
7139
|
+
...(toolMeta?.terminal_output
|
|
7140
|
+
? {}
|
|
7141
|
+
: { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }),
|
|
6626
7142
|
...toolUpdate,
|
|
6627
7143
|
};
|
|
6628
7144
|
}
|
|
@@ -6643,7 +7159,6 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6643
7159
|
case "compaction":
|
|
6644
7160
|
case "compaction_delta":
|
|
6645
7161
|
case "advisor_tool_result":
|
|
6646
|
-
case "mid_conv_system":
|
|
6647
7162
|
case "fallback":
|
|
6648
7163
|
break;
|
|
6649
7164
|
default:
|
|
@@ -6800,7 +7315,7 @@ export async function runPromptWithCancellation(agent, params, signal) {
|
|
|
6800
7315
|
signal.removeEventListener("abort", onAbort);
|
|
6801
7316
|
}
|
|
6802
7317
|
}
|
|
6803
|
-
export function runAcp() {
|
|
7318
|
+
export function runAcp(logger) {
|
|
6804
7319
|
const input = nodeToWebWritable(process.stdout);
|
|
6805
7320
|
const output = nodeToWebReadable(process.stdin);
|
|
6806
7321
|
const stream = ndJsonStream(input, output);
|
|
@@ -6831,9 +7346,10 @@ export function runAcp() {
|
|
|
6831
7346
|
.onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
|
|
6832
7347
|
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
|
6833
7348
|
.onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
|
|
7349
|
+
.onRequest(ASYNC_TASK_STOP_METHOD, { parse: parseAsyncTaskStopRequest }, (ctx) => agent.stopAsyncTask(ctx.params))
|
|
6834
7350
|
.onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
|
|
6835
7351
|
.connect(stream);
|
|
6836
|
-
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
|
7352
|
+
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
|
6837
7353
|
return { connection, agent };
|
|
6838
7354
|
}
|
|
6839
7355
|
function commonPrefixLength(a, b) {
|
|
@@ -6928,6 +7444,7 @@ const PROVIDER_ROUTING_ENV_VARS = [
|
|
|
6928
7444
|
"ANTHROPIC_CUSTOM_HEADERS",
|
|
6929
7445
|
"ANTHROPIC_API_KEY",
|
|
6930
7446
|
"ANTHROPIC_AUTH_TOKEN",
|
|
7447
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
6931
7448
|
];
|
|
6932
7449
|
/** Stable identifier for the LLM backend a session's query is created against,
|
|
6933
7450
|
* used to scope {@link contextWindowCache} per backend. Positional `\0`-join
|