claude-code-acp-ts 0.24.3 → 0.37.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/dist/acp-agent.d.ts +65 -8
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +1091 -200
- package/dist/index.js +50 -9
- package/dist/lib.d.ts +3 -3
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +3 -3
- package/dist/settings.d.ts +14 -54
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +40 -103
- package/dist/tools.d.ts +45 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +174 -30
- package/dist/utils.d.ts +0 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -17
- package/package.json +14 -14
- package/dist/mcp-server.d.ts +0 -21
- package/dist/mcp-server.d.ts.map +0 -1
- package/dist/mcp-server.js +0 -826
package/dist/acp-agent.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
|
-
import { getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import * as fs from "node:fs";
|
|
5
4
|
import * as os from "node:os";
|
|
6
5
|
import * as path from "node:path";
|
|
7
6
|
import packageJson from "../package.json" with { type: "json" };
|
|
8
7
|
import { SettingsManager } from "./settings.js";
|
|
9
|
-
import { createPostToolUseHook, planEntries, registerHookCallback, toolInfoFromToolUse,
|
|
8
|
+
import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
10
9
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
11
10
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
12
11
|
const MAX_TITLE_LENGTH = 256;
|
|
@@ -21,13 +20,63 @@ function sanitizeTitle(text) {
|
|
|
21
20
|
}
|
|
22
21
|
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
|
23
22
|
}
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const ZERO_USAGE = Object.freeze({
|
|
24
|
+
input_tokens: 0,
|
|
25
|
+
output_tokens: 0,
|
|
26
|
+
cache_read_input_tokens: 0,
|
|
27
|
+
cache_creation_input_tokens: 0,
|
|
28
|
+
});
|
|
29
|
+
const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
30
|
+
/** Compute a stable fingerprint of the session-defining params so we can
|
|
31
|
+
* detect when a loadSession/resumeSession call requires tearing down and
|
|
32
|
+
* recreating the underlying Query process. MCP servers are sorted by name
|
|
33
|
+
* so that ordering differences don't trigger unnecessary recreations. */
|
|
34
|
+
function computeSessionFingerprint(params) {
|
|
35
|
+
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
36
|
+
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
|
26
37
|
}
|
|
27
38
|
export async function claudeCliPath() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
if (process.env.CLAUDE_CODE_EXECUTABLE) {
|
|
40
|
+
return process.env.CLAUDE_CODE_EXECUTABLE;
|
|
41
|
+
}
|
|
42
|
+
// The SDK's CLI is a native binary shipped as a platform-specific optional
|
|
43
|
+
// dependency of @anthropic-ai/claude-agent-sdk. Resolve via a require bound
|
|
44
|
+
// to the SDK so nested installs are found even when npm doesn't hoist.
|
|
45
|
+
const { createRequire } = await import("node:module");
|
|
46
|
+
const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk"));
|
|
47
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
48
|
+
// On linux, both glibc and musl variants may be installed side-by-side
|
|
49
|
+
// (e.g. bunx hydrates every optional dep), so picking one by trial is
|
|
50
|
+
// unreliable: the wrong binary segfaults at runtime instead of failing to
|
|
51
|
+
// spawn. Detect the runtime libc and prefer the matching variant, falling
|
|
52
|
+
// back to the other only if the preferred one isn't installed.
|
|
53
|
+
const candidates = process.platform === "linux"
|
|
54
|
+
? isMuslLibc()
|
|
55
|
+
? [
|
|
56
|
+
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
|
57
|
+
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
|
58
|
+
]
|
|
59
|
+
: [
|
|
60
|
+
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
|
61
|
+
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
|
62
|
+
]
|
|
63
|
+
: [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`];
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
try {
|
|
66
|
+
return req.resolve(candidate);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// try next candidate
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Claude native binary not found for ${process.platform}-${process.arch}. ` +
|
|
73
|
+
`Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE.`);
|
|
74
|
+
}
|
|
75
|
+
function isMuslLibc() {
|
|
76
|
+
// process.report.getReport().header.glibcVersionRuntime is populated when
|
|
77
|
+
// Node is dynamically linked against glibc, and absent on musl.
|
|
78
|
+
const report = process.report?.getReport();
|
|
79
|
+
return !report?.header?.glibcVersionRuntime;
|
|
31
80
|
}
|
|
32
81
|
function shouldHideClaudeAuth() {
|
|
33
82
|
return process.argv.includes("--hide-claude-auth");
|
|
@@ -38,7 +87,54 @@ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
|
|
38
87
|
// Slash commands that the SDK handles locally without replaying the user
|
|
39
88
|
// message and without invoking the model.
|
|
40
89
|
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
|
90
|
+
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
|
|
91
|
+
// their output as user messages in the session transcript, wrapping the
|
|
92
|
+
// payload in these XML-like markers that the CLI uses for its own display.
|
|
93
|
+
// The live prompt loop drops them; replay must strip them too or they leak
|
|
94
|
+
// into the UI on session/load.
|
|
95
|
+
const LOCAL_COMMAND_TAG_PATTERN = /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
|
|
96
|
+
function stripMarkerTags(text) {
|
|
97
|
+
return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Return user-message content with local-command marker tags removed, or
|
|
101
|
+
* `null` if nothing meaningful remains (caller should skip the message).
|
|
102
|
+
* Preserves real prose that's mixed in alongside the markers — e.g. a
|
|
103
|
+
* message like `<command-name>…</command-name>hi` becomes `hi`.
|
|
104
|
+
*/
|
|
105
|
+
export function stripLocalCommandMetadata(content) {
|
|
106
|
+
if (typeof content === "string") {
|
|
107
|
+
const stripped = stripMarkerTags(content);
|
|
108
|
+
return stripped.trim() === "" ? null : stripped;
|
|
109
|
+
}
|
|
110
|
+
if (!Array.isArray(content))
|
|
111
|
+
return content;
|
|
112
|
+
const kept = [];
|
|
113
|
+
for (const block of content) {
|
|
114
|
+
if (block &&
|
|
115
|
+
typeof block === "object" &&
|
|
116
|
+
"type" in block &&
|
|
117
|
+
block.type === "text" &&
|
|
118
|
+
"text" in block &&
|
|
119
|
+
typeof block.text === "string") {
|
|
120
|
+
const stripped = stripMarkerTags(block.text);
|
|
121
|
+
if (stripped.trim() === "")
|
|
122
|
+
continue;
|
|
123
|
+
kept.push({ ...block, text: stripped });
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
kept.push(block);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (kept.length === 0)
|
|
130
|
+
return null;
|
|
131
|
+
return kept;
|
|
132
|
+
}
|
|
133
|
+
export function isLocalCommandMetadata(content) {
|
|
134
|
+
return stripLocalCommandMetadata(content) === null;
|
|
135
|
+
}
|
|
41
136
|
const PERMISSION_MODE_ALIASES = {
|
|
137
|
+
auto: "auto",
|
|
42
138
|
default: "default",
|
|
43
139
|
acceptedits: "acceptEdits",
|
|
44
140
|
dontask: "dontAsk",
|
|
@@ -46,26 +142,64 @@ const PERMISSION_MODE_ALIASES = {
|
|
|
46
142
|
bypasspermissions: "bypassPermissions",
|
|
47
143
|
bypass: "bypassPermissions",
|
|
48
144
|
};
|
|
49
|
-
export function resolvePermissionMode(defaultMode) {
|
|
145
|
+
export function resolvePermissionMode(defaultMode, logger = console) {
|
|
50
146
|
if (defaultMode === undefined) {
|
|
51
147
|
return "default";
|
|
52
148
|
}
|
|
53
149
|
if (typeof defaultMode !== "string") {
|
|
54
|
-
|
|
150
|
+
logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
|
|
151
|
+
return "default";
|
|
55
152
|
}
|
|
56
153
|
const normalized = defaultMode.trim().toLowerCase();
|
|
57
154
|
if (normalized === "") {
|
|
58
|
-
|
|
155
|
+
logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
|
|
156
|
+
return "default";
|
|
59
157
|
}
|
|
60
158
|
const mapped = PERMISSION_MODE_ALIASES[normalized];
|
|
61
159
|
if (!mapped) {
|
|
62
|
-
|
|
160
|
+
logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
|
|
161
|
+
return "default";
|
|
63
162
|
}
|
|
64
163
|
if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
|
|
65
|
-
|
|
164
|
+
logger.error("Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.");
|
|
165
|
+
return "default";
|
|
66
166
|
}
|
|
67
167
|
return mapped;
|
|
68
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Builds the label for the "Always Allow" permission option so the user can see
|
|
171
|
+
* the exact scope they are committing to. Uses the SDK-provided suggestions
|
|
172
|
+
* when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
|
|
173
|
+
* tool so "Always Allow" is never a blank check without disclosure.
|
|
174
|
+
*/
|
|
175
|
+
export function describeAlwaysAllow(suggestions, toolName) {
|
|
176
|
+
if (!suggestions || suggestions.length === 0) {
|
|
177
|
+
return `Always Allow all ${toolName}`;
|
|
178
|
+
}
|
|
179
|
+
const ruleLabels = [];
|
|
180
|
+
const directories = [];
|
|
181
|
+
for (const update of suggestions) {
|
|
182
|
+
if (update.type === "addRules" && update.behavior === "allow") {
|
|
183
|
+
for (const rule of update.rules) {
|
|
184
|
+
ruleLabels.push(rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else if (update.type === "addDirectories") {
|
|
188
|
+
directories.push(...update.directories);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const parts = [];
|
|
192
|
+
if (ruleLabels.length > 0) {
|
|
193
|
+
parts.push(ruleLabels.join(", "));
|
|
194
|
+
}
|
|
195
|
+
if (directories.length > 0) {
|
|
196
|
+
parts.push(`access to ${directories.join(", ")}`);
|
|
197
|
+
}
|
|
198
|
+
if (parts.length === 0) {
|
|
199
|
+
return `Always Allow all ${toolName}`;
|
|
200
|
+
}
|
|
201
|
+
return `Always Allow ${parts.join(" and ")}`;
|
|
202
|
+
}
|
|
69
203
|
// Implement the ACP Agent interface
|
|
70
204
|
export class ClaudeAcpAgent {
|
|
71
205
|
constructor(client, logger) {
|
|
@@ -90,24 +224,87 @@ export class ClaudeAcpAgent {
|
|
|
90
224
|
},
|
|
91
225
|
},
|
|
92
226
|
};
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
name: "
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
227
|
+
const gatewayBedrockAuthMethod = {
|
|
228
|
+
id: "gateway-bedrock",
|
|
229
|
+
name: "Custom model gateway",
|
|
230
|
+
description: "Use a custom gateway to authenticate and access models",
|
|
231
|
+
_meta: {
|
|
232
|
+
gateway: {
|
|
233
|
+
protocol: "bedrock",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
99
236
|
};
|
|
100
237
|
const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
|
|
101
|
-
// If client supports terminal-auth capability, use that instead.
|
|
102
238
|
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
239
|
+
// Detect remote environments where the OAuth browser redirect to localhost
|
|
240
|
+
// won't work. This matches the SDK's internal isRemote check. In these cases,
|
|
241
|
+
// the `auth login` subcommand would fall back to a device-code-like manual
|
|
242
|
+
// flow, which doesn't work well over ACP, so we offer the TUI login instead.
|
|
243
|
+
const isRemote = !!(process.env.NO_BROWSER ||
|
|
244
|
+
process.env.SSH_CONNECTION ||
|
|
245
|
+
process.env.SSH_CLIENT ||
|
|
246
|
+
process.env.SSH_TTY ||
|
|
247
|
+
process.env.CLAUDE_CODE_REMOTE);
|
|
248
|
+
const terminalAuthMethods = [];
|
|
249
|
+
if (isRemote) {
|
|
250
|
+
const remoteLoginMethod = {
|
|
251
|
+
description: "Run `claude /login` in the terminal",
|
|
252
|
+
name: "Log in with Claude",
|
|
253
|
+
id: "claude-login",
|
|
254
|
+
type: "terminal",
|
|
255
|
+
args: ["--cli"],
|
|
110
256
|
};
|
|
257
|
+
if (supportsMetaTerminalAuth) {
|
|
258
|
+
remoteLoginMethod._meta = {
|
|
259
|
+
"terminal-auth": {
|
|
260
|
+
command: process.execPath,
|
|
261
|
+
args: [...process.argv.slice(1), "--cli"],
|
|
262
|
+
label: "Claude Login",
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
|
|
267
|
+
terminalAuthMethods.push(remoteLoginMethod);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const claudeLoginMethod = {
|
|
272
|
+
description: "Use Claude subscription ",
|
|
273
|
+
name: "Claude Subscription",
|
|
274
|
+
id: "claude-ai-login",
|
|
275
|
+
type: "terminal",
|
|
276
|
+
args: ["--cli", "auth", "login", "--claudeai"],
|
|
277
|
+
};
|
|
278
|
+
const consoleLoginMethod = {
|
|
279
|
+
description: "Use Anthropic Console (API usage billing)",
|
|
280
|
+
name: "Anthropic Console",
|
|
281
|
+
id: "console-login",
|
|
282
|
+
type: "terminal",
|
|
283
|
+
args: ["--cli", "auth", "login", "--console"],
|
|
284
|
+
};
|
|
285
|
+
if (supportsMetaTerminalAuth) {
|
|
286
|
+
const baseArgs = process.argv.slice(1);
|
|
287
|
+
claudeLoginMethod._meta = {
|
|
288
|
+
"terminal-auth": {
|
|
289
|
+
command: process.execPath,
|
|
290
|
+
args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
|
|
291
|
+
label: "Claude Login",
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
consoleLoginMethod._meta = {
|
|
295
|
+
"terminal-auth": {
|
|
296
|
+
command: process.execPath,
|
|
297
|
+
args: [...baseArgs, "--cli", "auth", "login", "--console"],
|
|
298
|
+
label: "Anthropic Console Login",
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
|
|
303
|
+
terminalAuthMethods.push(claudeLoginMethod);
|
|
304
|
+
}
|
|
305
|
+
if (supportsTerminalAuth || supportsMetaTerminalAuth) {
|
|
306
|
+
terminalAuthMethods.push(consoleLoginMethod);
|
|
307
|
+
}
|
|
111
308
|
}
|
|
112
309
|
return {
|
|
113
310
|
protocolVersion: 1,
|
|
@@ -127,10 +324,12 @@ export class ClaudeAcpAgent {
|
|
|
127
324
|
},
|
|
128
325
|
loadSession: true,
|
|
129
326
|
sessionCapabilities: {
|
|
327
|
+
additionalDirectories: {},
|
|
328
|
+
close: {},
|
|
329
|
+
delete: {},
|
|
130
330
|
fork: {},
|
|
131
331
|
list: {},
|
|
132
332
|
resume: {},
|
|
133
|
-
close: {},
|
|
134
333
|
},
|
|
135
334
|
},
|
|
136
335
|
agentInfo: {
|
|
@@ -139,19 +338,12 @@ export class ClaudeAcpAgent {
|
|
|
139
338
|
version: packageJson.version,
|
|
140
339
|
},
|
|
141
340
|
authMethods: [
|
|
142
|
-
...
|
|
143
|
-
|
|
144
|
-
: []),
|
|
145
|
-
...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
|
|
341
|
+
...terminalAuthMethods,
|
|
342
|
+
...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
|
|
146
343
|
],
|
|
147
344
|
};
|
|
148
345
|
}
|
|
149
346
|
async newSession(params) {
|
|
150
|
-
if (!this.gatewayAuthMeta &&
|
|
151
|
-
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
|
|
152
|
-
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))) {
|
|
153
|
-
throw RequestError.authRequired();
|
|
154
|
-
}
|
|
155
347
|
const response = await this.createSession(params, {
|
|
156
348
|
// Revisit these meta values once we support resume
|
|
157
349
|
resume: params._meta?.claudeCode?.options?.resume,
|
|
@@ -166,6 +358,7 @@ export class ClaudeAcpAgent {
|
|
|
166
358
|
const response = await this.createSession({
|
|
167
359
|
cwd: params.cwd,
|
|
168
360
|
mcpServers: params.mcpServers ?? [],
|
|
361
|
+
additionalDirectories: params.additionalDirectories,
|
|
169
362
|
_meta: params._meta,
|
|
170
363
|
}, {
|
|
171
364
|
resume: params.sessionId,
|
|
@@ -177,7 +370,7 @@ export class ClaudeAcpAgent {
|
|
|
177
370
|
}, 0);
|
|
178
371
|
return response;
|
|
179
372
|
}
|
|
180
|
-
async
|
|
373
|
+
async resumeSession(params) {
|
|
181
374
|
const result = await this.getOrCreateSession(params);
|
|
182
375
|
// Needs to happen after we return the session
|
|
183
376
|
setTimeout(() => {
|
|
@@ -212,8 +405,8 @@ export class ClaudeAcpAgent {
|
|
|
212
405
|
};
|
|
213
406
|
}
|
|
214
407
|
async authenticate(_params) {
|
|
215
|
-
if (_params.methodId === "gateway") {
|
|
216
|
-
this.
|
|
408
|
+
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
|
409
|
+
this.gatewayAuthRequest = _params;
|
|
217
410
|
return;
|
|
218
411
|
}
|
|
219
412
|
throw new Error("Method not implemented.");
|
|
@@ -231,8 +424,15 @@ export class ClaudeAcpAgent {
|
|
|
231
424
|
cachedWriteTokens: 0,
|
|
232
425
|
};
|
|
233
426
|
let lastAssistantTotalUsage = null;
|
|
427
|
+
let lastAssistantUsage = null;
|
|
234
428
|
let lastAssistantModel = null;
|
|
235
|
-
|
|
429
|
+
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
|
|
430
|
+
// problem, billing), it sets a categorical `error` field on the
|
|
431
|
+
// `SDKAssistantMessage` that precedes the final `result` message. We
|
|
432
|
+
// capture it here so the subsequent `RequestError.internalError` can
|
|
433
|
+
// forward it to clients as structured `data`, sparing them from
|
|
434
|
+
// pattern-matching on the human-readable message text.
|
|
435
|
+
let lastAssistantError;
|
|
236
436
|
const userMessage = promptToClaude(params);
|
|
237
437
|
const promptUuid = randomUUID();
|
|
238
438
|
userMessage.uuid = promptUuid;
|
|
@@ -256,6 +456,7 @@ export class ClaudeAcpAgent {
|
|
|
256
456
|
}
|
|
257
457
|
session.promptRunning = true;
|
|
258
458
|
let handedOff = false;
|
|
459
|
+
let errored = false;
|
|
259
460
|
let stopReason = "end_turn";
|
|
260
461
|
try {
|
|
261
462
|
while (true) {
|
|
@@ -266,6 +467,13 @@ export class ClaudeAcpAgent {
|
|
|
266
467
|
}
|
|
267
468
|
break;
|
|
268
469
|
}
|
|
470
|
+
if (session.emitRawSDKMessages &&
|
|
471
|
+
shouldEmitRawMessage(session.emitRawSDKMessages, message)) {
|
|
472
|
+
await this.client.extNotification("_claude/sdkMessage", {
|
|
473
|
+
sessionId: params.sessionId,
|
|
474
|
+
message: message,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
269
477
|
switch (message.type) {
|
|
270
478
|
case "system":
|
|
271
479
|
switch (message.subtype) {
|
|
@@ -296,12 +504,13 @@ export class ClaudeAcpAgent {
|
|
|
296
504
|
// "944k/1m" right after the user sees "Compacting completed",
|
|
297
505
|
// which is confusing and wrong.
|
|
298
506
|
lastAssistantTotalUsage = 0;
|
|
507
|
+
lastAssistantUsage = null;
|
|
299
508
|
await this.client.sessionUpdate({
|
|
300
509
|
sessionId: message.session_id,
|
|
301
510
|
update: {
|
|
302
511
|
sessionUpdate: "usage_update",
|
|
303
512
|
used: 0,
|
|
304
|
-
size:
|
|
513
|
+
size: session.contextWindowSize,
|
|
305
514
|
},
|
|
306
515
|
});
|
|
307
516
|
await this.client.sessionUpdate({
|
|
@@ -325,10 +534,50 @@ export class ClaudeAcpAgent {
|
|
|
325
534
|
}
|
|
326
535
|
case "session_state_changed": {
|
|
327
536
|
if (message.state === "idle") {
|
|
537
|
+
if (session.cancelled) {
|
|
538
|
+
stopReason = "cancelled";
|
|
539
|
+
}
|
|
328
540
|
return { stopReason, usage: sessionUsage(session) };
|
|
329
541
|
}
|
|
330
542
|
break;
|
|
331
543
|
}
|
|
544
|
+
case "memory_recall": {
|
|
545
|
+
const isSynthesis = message.mode === "synthesize";
|
|
546
|
+
const locations = isSynthesis
|
|
547
|
+
? []
|
|
548
|
+
: message.memories.map((m) => ({ path: m.path }));
|
|
549
|
+
const content = isSynthesis
|
|
550
|
+
? message.memories
|
|
551
|
+
.filter((m) => typeof m.content === "string")
|
|
552
|
+
.map((m) => ({
|
|
553
|
+
type: "content",
|
|
554
|
+
content: { type: "text", text: m.content },
|
|
555
|
+
}))
|
|
556
|
+
: [];
|
|
557
|
+
const count = message.memories.length;
|
|
558
|
+
const title = isSynthesis
|
|
559
|
+
? "Recalled synthesized memory"
|
|
560
|
+
: `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
|
|
561
|
+
await this.client.sessionUpdate({
|
|
562
|
+
sessionId: message.session_id,
|
|
563
|
+
update: {
|
|
564
|
+
sessionUpdate: "tool_call",
|
|
565
|
+
toolCallId: message.uuid,
|
|
566
|
+
title,
|
|
567
|
+
kind: "read",
|
|
568
|
+
status: "completed",
|
|
569
|
+
...(locations.length > 0 && { locations }),
|
|
570
|
+
...(content.length > 0 && { content }),
|
|
571
|
+
_meta: {
|
|
572
|
+
claudeCode: {
|
|
573
|
+
toolName: "memory_recall",
|
|
574
|
+
toolResponse: { mode: message.mode },
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
332
581
|
case "hook_started":
|
|
333
582
|
case "hook_progress":
|
|
334
583
|
case "hook_response":
|
|
@@ -336,8 +585,13 @@ export class ClaudeAcpAgent {
|
|
|
336
585
|
case "task_started":
|
|
337
586
|
case "task_notification":
|
|
338
587
|
case "task_progress":
|
|
588
|
+
case "task_updated":
|
|
339
589
|
case "elicitation_complete":
|
|
590
|
+
case "plugin_install":
|
|
591
|
+
case "notification":
|
|
340
592
|
case "api_retry":
|
|
593
|
+
case "mirror_error":
|
|
594
|
+
case "permission_denied":
|
|
341
595
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
342
596
|
break;
|
|
343
597
|
default:
|
|
@@ -354,8 +608,18 @@ export class ClaudeAcpAgent {
|
|
|
354
608
|
const matchingModelUsage = lastAssistantModel
|
|
355
609
|
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
356
610
|
: null;
|
|
357
|
-
|
|
358
|
-
|
|
611
|
+
// Only overwrite when we have an authoritative value — a miss
|
|
612
|
+
// (e.g. a turn with no top-level assistant message) would
|
|
613
|
+
// otherwise discard the window learned on a prior turn and
|
|
614
|
+
// leave the next prompt's mid-stream updates reporting 200k.
|
|
615
|
+
if (matchingModelUsage) {
|
|
616
|
+
session.contextWindowSize = matchingModelUsage.contextWindow;
|
|
617
|
+
}
|
|
618
|
+
// Task-notification followups are autonomous work triggered by a
|
|
619
|
+
// task-notification system message, not by the user's prompt.
|
|
620
|
+
// They should not influence the user-turn lifecycle (stop reason,
|
|
621
|
+
// slash-command output forwarding) but their cost is real.
|
|
622
|
+
const isTaskNotification = message.origin?.kind === "task-notification";
|
|
359
623
|
// Send usage_update notification
|
|
360
624
|
if (lastAssistantTotalUsage !== null) {
|
|
361
625
|
await this.client.sessionUpdate({
|
|
@@ -363,16 +627,21 @@ export class ClaudeAcpAgent {
|
|
|
363
627
|
update: {
|
|
364
628
|
sessionUpdate: "usage_update",
|
|
365
629
|
used: lastAssistantTotalUsage,
|
|
366
|
-
size: contextWindowSize,
|
|
630
|
+
size: session.contextWindowSize,
|
|
367
631
|
cost: {
|
|
368
632
|
amount: message.total_cost_usd,
|
|
369
633
|
currency: "USD",
|
|
370
634
|
},
|
|
635
|
+
...(message.origin && {
|
|
636
|
+
_meta: { "_claude/origin": message.origin },
|
|
637
|
+
}),
|
|
371
638
|
},
|
|
372
639
|
});
|
|
373
640
|
}
|
|
374
641
|
if (session.cancelled) {
|
|
375
|
-
|
|
642
|
+
if (!isTaskNotification) {
|
|
643
|
+
stopReason = "cancelled";
|
|
644
|
+
}
|
|
376
645
|
break;
|
|
377
646
|
}
|
|
378
647
|
switch (message.subtype) {
|
|
@@ -381,15 +650,19 @@ export class ClaudeAcpAgent {
|
|
|
381
650
|
throw RequestError.authRequired();
|
|
382
651
|
}
|
|
383
652
|
if (message.stop_reason === "max_tokens") {
|
|
384
|
-
|
|
653
|
+
if (!isTaskNotification) {
|
|
654
|
+
stopReason = "max_tokens";
|
|
655
|
+
}
|
|
385
656
|
break;
|
|
386
657
|
}
|
|
387
658
|
if (message.is_error) {
|
|
388
|
-
throw RequestError.internalError(
|
|
659
|
+
throw RequestError.internalError(errorKindData(lastAssistantError), message.result);
|
|
389
660
|
}
|
|
390
661
|
// For local-only commands (no model invocation), the result
|
|
391
662
|
// text is the command output — forward it to the client.
|
|
392
|
-
|
|
663
|
+
// Task-notification followups never originate from a user
|
|
664
|
+
// slash command, so skip the forwarding for them.
|
|
665
|
+
if (isLocalOnlyCommand && !isTaskNotification) {
|
|
393
666
|
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, this.toolUseCache, this.client, this.logger)) {
|
|
394
667
|
await this.client.sessionUpdate(notification);
|
|
395
668
|
}
|
|
@@ -398,22 +671,28 @@ export class ClaudeAcpAgent {
|
|
|
398
671
|
}
|
|
399
672
|
case "error_during_execution": {
|
|
400
673
|
if (message.stop_reason === "max_tokens") {
|
|
401
|
-
|
|
674
|
+
if (!isTaskNotification) {
|
|
675
|
+
stopReason = "max_tokens";
|
|
676
|
+
}
|
|
402
677
|
break;
|
|
403
678
|
}
|
|
404
679
|
if (message.is_error) {
|
|
405
|
-
throw RequestError.internalError(
|
|
680
|
+
throw RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype);
|
|
681
|
+
}
|
|
682
|
+
if (!isTaskNotification) {
|
|
683
|
+
stopReason = "end_turn";
|
|
406
684
|
}
|
|
407
|
-
stopReason = "end_turn";
|
|
408
685
|
break;
|
|
409
686
|
}
|
|
410
687
|
case "error_max_budget_usd":
|
|
411
688
|
case "error_max_turns":
|
|
412
689
|
case "error_max_structured_output_retries":
|
|
413
690
|
if (message.is_error) {
|
|
414
|
-
throw RequestError.internalError(
|
|
691
|
+
throw RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype);
|
|
692
|
+
}
|
|
693
|
+
if (!isTaskNotification) {
|
|
694
|
+
stopReason = "max_turn_requests";
|
|
415
695
|
}
|
|
416
|
-
stopReason = "max_turn_requests";
|
|
417
696
|
break;
|
|
418
697
|
default:
|
|
419
698
|
unreachable(message, this.logger);
|
|
@@ -422,9 +701,57 @@ export class ClaudeAcpAgent {
|
|
|
422
701
|
break;
|
|
423
702
|
}
|
|
424
703
|
case "stream_event": {
|
|
704
|
+
if (message.parent_tool_use_id === null &&
|
|
705
|
+
(message.event.type === "message_start" || message.event.type === "message_delta")) {
|
|
706
|
+
if (message.event.type === "message_start") {
|
|
707
|
+
lastAssistantUsage = snapshotFromUsage(message.event.message.usage);
|
|
708
|
+
const model = message.event.message.model;
|
|
709
|
+
if (model && model !== "<synthetic>") {
|
|
710
|
+
lastAssistantModel = model;
|
|
711
|
+
// Only upgrade from the default — once a `result` has given
|
|
712
|
+
// us an authoritative window, trust it over the heuristic.
|
|
713
|
+
// Model switches invalidate the cached window via
|
|
714
|
+
// `syncSessionConfigState`, which resets us back to the
|
|
715
|
+
// default so this branch runs again for the new model.
|
|
716
|
+
if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
|
|
717
|
+
const inferred = inferContextWindowFromModel(model);
|
|
718
|
+
if (inferred !== null) {
|
|
719
|
+
session.contextWindowSize = inferred;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
const usage = message.event.usage;
|
|
726
|
+
const prev = lastAssistantUsage ?? ZERO_USAGE;
|
|
727
|
+
// Per Anthropic API, message_delta usage fields are *cumulative*;
|
|
728
|
+
// nullable fields (input_tokens and the cache fields) fall back
|
|
729
|
+
// to the prior snapshot when the server omits them from this
|
|
730
|
+
// delta. Only output_tokens is guaranteed non-null.
|
|
731
|
+
lastAssistantUsage = {
|
|
732
|
+
input_tokens: usage.input_tokens ?? prev.input_tokens,
|
|
733
|
+
output_tokens: usage.output_tokens,
|
|
734
|
+
cache_read_input_tokens: usage.cache_read_input_tokens ?? prev.cache_read_input_tokens,
|
|
735
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? prev.cache_creation_input_tokens,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
const nextUsage = totalTokens(lastAssistantUsage);
|
|
739
|
+
if (nextUsage !== lastAssistantTotalUsage) {
|
|
740
|
+
lastAssistantTotalUsage = nextUsage;
|
|
741
|
+
await this.client.sessionUpdate({
|
|
742
|
+
sessionId: params.sessionId,
|
|
743
|
+
update: {
|
|
744
|
+
sessionUpdate: "usage_update",
|
|
745
|
+
used: nextUsage,
|
|
746
|
+
size: session.contextWindowSize,
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
425
751
|
for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.client, this.logger, {
|
|
426
752
|
clientCapabilities: this.clientCapabilities,
|
|
427
753
|
cwd: session.cwd,
|
|
754
|
+
taskState: session.taskState,
|
|
428
755
|
})) {
|
|
429
756
|
await this.client.sessionUpdate(notification);
|
|
430
757
|
}
|
|
@@ -454,33 +781,41 @@ export class ClaudeAcpAgent {
|
|
|
454
781
|
break;
|
|
455
782
|
}
|
|
456
783
|
}
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
// Track the current top-level model for context window size lookup
|
|
472
|
-
// (exclude subagent messages to stay in sync with lastAssistantTotalUsage)
|
|
473
|
-
if (message.type === "assistant" &&
|
|
474
|
-
message.parent_tool_use_id === null &&
|
|
475
|
-
message.message.model &&
|
|
476
|
-
message.message.model !== "<synthetic>") {
|
|
477
|
-
lastAssistantModel = message.message.model;
|
|
784
|
+
// Snapshot the latest top-level assistant usage and model so the
|
|
785
|
+
// next `result` can emit a usage_update tied to the right context
|
|
786
|
+
// window. Subagent messages are excluded to keep the snapshot
|
|
787
|
+
// aligned with what the user's current selection is producing.
|
|
788
|
+
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
789
|
+
lastAssistantUsage = snapshotFromUsage(message.message.usage);
|
|
790
|
+
lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
|
|
791
|
+
if (message.message.model && message.message.model !== "<synthetic>") {
|
|
792
|
+
lastAssistantModel = message.message.model;
|
|
793
|
+
}
|
|
794
|
+
if (message.error) {
|
|
795
|
+
lastAssistantError = message.error;
|
|
796
|
+
}
|
|
478
797
|
}
|
|
479
|
-
//
|
|
480
|
-
//
|
|
798
|
+
// Strip <command-*>/<local-command-stdout> markers and render any
|
|
799
|
+
// remaining prose. Skill bodies and built-in slash commands (e.g.
|
|
800
|
+
// /usage, /status, /model) arrive wrapped in these tags; pure-marker
|
|
801
|
+
// payloads (e.g. /compact's malformed output) strip to null and are
|
|
802
|
+
// skipped. Mirrors the replay path at replaySessionHistory.
|
|
481
803
|
if (typeof message.message.content === "string" &&
|
|
482
804
|
message.message.content.includes("<local-command-stdout>")) {
|
|
483
|
-
|
|
805
|
+
const stripped = stripLocalCommandMetadata(message.message.content);
|
|
806
|
+
if (typeof stripped === "string") {
|
|
807
|
+
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger, {
|
|
808
|
+
clientCapabilities: this.clientCapabilities,
|
|
809
|
+
parentToolUseId: message.parent_tool_use_id,
|
|
810
|
+
cwd: session.cwd,
|
|
811
|
+
taskState: session.taskState,
|
|
812
|
+
})) {
|
|
813
|
+
await this.client.sessionUpdate(notification);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
this.logger.log(message.message.content);
|
|
818
|
+
}
|
|
484
819
|
break;
|
|
485
820
|
}
|
|
486
821
|
if (typeof message.message.content === "string" &&
|
|
@@ -512,6 +847,7 @@ export class ClaudeAcpAgent {
|
|
|
512
847
|
clientCapabilities: this.clientCapabilities,
|
|
513
848
|
parentToolUseId: message.parent_tool_use_id,
|
|
514
849
|
cwd: session.cwd,
|
|
850
|
+
taskState: session.taskState,
|
|
515
851
|
})) {
|
|
516
852
|
await this.client.sessionUpdate(notification);
|
|
517
853
|
}
|
|
@@ -531,6 +867,30 @@ export class ClaudeAcpAgent {
|
|
|
531
867
|
throw new Error("Session did not end in result");
|
|
532
868
|
}
|
|
533
869
|
catch (error) {
|
|
870
|
+
errored = true;
|
|
871
|
+
// A failed turn typically leaves a trailing `session_state_changed: idle`
|
|
872
|
+
// (and possibly more) in the query iterator. If we don't drain it here,
|
|
873
|
+
// the next prompt's first `query.next()` consumes that stale idle and
|
|
874
|
+
// short-circuits to end_turn with zero usage
|
|
875
|
+
// Bounded so a misbehaving SDK can't hang the next prompt indefinitely.
|
|
876
|
+
try {
|
|
877
|
+
await session.query.interrupt();
|
|
878
|
+
const MAX_DRAIN = 100;
|
|
879
|
+
for (let i = 0; i < MAX_DRAIN; i++) {
|
|
880
|
+
const { value: m, done } = await session.query.next();
|
|
881
|
+
if (done || !m)
|
|
882
|
+
break;
|
|
883
|
+
if (m.type === "system" && m.subtype === "session_state_changed" && m.state === "idle") {
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
if (i === MAX_DRAIN - 1) {
|
|
887
|
+
this.logger.error(`Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
catch (drainErr) {
|
|
892
|
+
this.logger.error(`Session ${params.sessionId}: failed to drain query after prompt error:`, drainErr);
|
|
893
|
+
}
|
|
534
894
|
if (error instanceof RequestError || !(error instanceof Error)) {
|
|
535
895
|
throw error;
|
|
536
896
|
}
|
|
@@ -551,10 +911,20 @@ export class ClaudeAcpAgent {
|
|
|
551
911
|
finally {
|
|
552
912
|
if (!handedOff) {
|
|
553
913
|
session.promptRunning = false;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
914
|
+
if (errored) {
|
|
915
|
+
// The query stream was just drained — handing pending prompts off
|
|
916
|
+
// onto it would let them race with the recovery. Cancel them so
|
|
917
|
+
// each waiting prompt() returns stopReason: "cancelled" and the
|
|
918
|
+
// client can decide whether to retry.
|
|
919
|
+
for (const pending of session.pendingMessages.values()) {
|
|
920
|
+
pending.resolve(true);
|
|
921
|
+
}
|
|
922
|
+
session.pendingMessages.clear();
|
|
923
|
+
}
|
|
924
|
+
else if (session.pendingMessages.size > 0) {
|
|
925
|
+
// This usually should not happen, but in case the loop finishes
|
|
926
|
+
// without claude sending all message replays, we resolve the
|
|
927
|
+
// next pending prompt call to ensure no prompts get stuck.
|
|
558
928
|
const next = [...session.pendingMessages.entries()].sort((a, b) => a[1].order - b[1].order)[0];
|
|
559
929
|
if (next) {
|
|
560
930
|
next[1].resolve(false);
|
|
@@ -567,7 +937,7 @@ export class ClaudeAcpAgent {
|
|
|
567
937
|
async cancel(params) {
|
|
568
938
|
const session = this.sessions[params.sessionId];
|
|
569
939
|
if (!session) {
|
|
570
|
-
|
|
940
|
+
return;
|
|
571
941
|
}
|
|
572
942
|
session.cancelled = true;
|
|
573
943
|
for (const [, pending] of session.pendingMessages) {
|
|
@@ -576,23 +946,51 @@ export class ClaudeAcpAgent {
|
|
|
576
946
|
session.pendingMessages.clear();
|
|
577
947
|
await session.query.interrupt();
|
|
578
948
|
}
|
|
579
|
-
|
|
580
|
-
|
|
949
|
+
/** Cleanly tear down a session: cancel in-flight work, dispose resources,
|
|
950
|
+
* and remove it from the session map. */
|
|
951
|
+
async teardownSession(sessionId) {
|
|
952
|
+
const session = this.sessions[sessionId];
|
|
581
953
|
if (!session) {
|
|
582
|
-
|
|
954
|
+
return;
|
|
583
955
|
}
|
|
584
|
-
await this.cancel({ sessionId
|
|
956
|
+
await this.cancel({ sessionId });
|
|
585
957
|
session.settingsManager.dispose();
|
|
586
958
|
session.abortController.abort();
|
|
587
|
-
|
|
959
|
+
session.query.close();
|
|
960
|
+
delete this.sessions[sessionId];
|
|
961
|
+
}
|
|
962
|
+
/** Tear down all active sessions. Called when the ACP connection closes. */
|
|
963
|
+
async dispose() {
|
|
964
|
+
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
|
965
|
+
}
|
|
966
|
+
async closeSession(params) {
|
|
967
|
+
if (!this.sessions[params.sessionId]) {
|
|
968
|
+
throw new Error("Session not found");
|
|
969
|
+
}
|
|
970
|
+
await this.teardownSession(params.sessionId);
|
|
971
|
+
return {};
|
|
972
|
+
}
|
|
973
|
+
async unstable_deleteSession(params) {
|
|
974
|
+
// Tear down any active in-memory state first so the on-disk file isn't
|
|
975
|
+
// recreated by an outstanding query writing to it.
|
|
976
|
+
if (this.sessions[params.sessionId]) {
|
|
977
|
+
await this.teardownSession(params.sessionId);
|
|
978
|
+
}
|
|
979
|
+
await deleteSession(params.sessionId);
|
|
588
980
|
return {};
|
|
589
981
|
}
|
|
590
982
|
async unstable_setSessionModel(params) {
|
|
591
|
-
|
|
983
|
+
const session = this.sessions[params.sessionId];
|
|
984
|
+
if (!session) {
|
|
592
985
|
throw new Error("Session not found");
|
|
593
986
|
}
|
|
594
|
-
|
|
595
|
-
|
|
987
|
+
// Resolve aliases (e.g. "opus", "opus[1m]") to canonical model IDs so
|
|
988
|
+
// downstream lookups in modelInfos succeed and the effort option isn't
|
|
989
|
+
// silently dropped.
|
|
990
|
+
const resolved = resolveModelPreference(session.modelInfos, params.modelId);
|
|
991
|
+
const modelId = resolved?.value ?? params.modelId;
|
|
992
|
+
await session.query.setModel(modelId);
|
|
993
|
+
await this.updateConfigOption(params.sessionId, "model", modelId);
|
|
596
994
|
}
|
|
597
995
|
async setSessionMode(params) {
|
|
598
996
|
if (!this.sessions[params.sessionId]) {
|
|
@@ -651,14 +1049,15 @@ export class ClaudeAcpAgent {
|
|
|
651
1049
|
else if (params.configId === "model") {
|
|
652
1050
|
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
|
653
1051
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1052
|
+
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
|
1053
|
+
// effort changes and effort changes induced by a model switch go through
|
|
1054
|
+
// the same path.
|
|
1055
|
+
await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
|
|
658
1056
|
return { configOptions: session.configOptions };
|
|
659
1057
|
}
|
|
660
1058
|
async applySessionMode(sessionId, modeId) {
|
|
661
1059
|
switch (modeId) {
|
|
1060
|
+
case "auto":
|
|
662
1061
|
case "default":
|
|
663
1062
|
case "acceptEdits":
|
|
664
1063
|
case "bypassPermissions":
|
|
@@ -668,8 +1067,15 @@ export class ClaudeAcpAgent {
|
|
|
668
1067
|
default:
|
|
669
1068
|
throw new Error("Invalid Mode");
|
|
670
1069
|
}
|
|
1070
|
+
const session = this.sessions[sessionId];
|
|
1071
|
+
if (!session) {
|
|
1072
|
+
throw new Error("Session not found");
|
|
1073
|
+
}
|
|
1074
|
+
if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
|
|
1075
|
+
throw new Error(`Mode ${modeId} is not available in this session`);
|
|
1076
|
+
}
|
|
671
1077
|
try {
|
|
672
|
-
await
|
|
1078
|
+
await session.query.setPermissionMode(modeId);
|
|
673
1079
|
}
|
|
674
1080
|
catch (error) {
|
|
675
1081
|
if (error instanceof Error) {
|
|
@@ -688,14 +1094,23 @@ export class ClaudeAcpAgent {
|
|
|
688
1094
|
const toolUseCache = {};
|
|
689
1095
|
const messages = await getSessionMessages(sessionId);
|
|
690
1096
|
for (const message of messages) {
|
|
1097
|
+
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
1098
|
+
let content = message.message.content;
|
|
1099
|
+
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
1100
|
+
if (message.message.role === "user") {
|
|
1101
|
+
content = stripLocalCommandMetadata(content);
|
|
1102
|
+
if (content === null)
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
691
1105
|
for (const notification of toAcpNotifications(
|
|
692
1106
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
693
|
-
|
|
1107
|
+
content,
|
|
694
1108
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
695
1109
|
message.message.role, sessionId, toolUseCache, this.client, this.logger, {
|
|
696
1110
|
registerHooks: false,
|
|
697
1111
|
clientCapabilities: this.clientCapabilities,
|
|
698
1112
|
cwd: this.sessions[sessionId]?.cwd,
|
|
1113
|
+
taskState: this.sessions[sessionId]?.taskState,
|
|
699
1114
|
})) {
|
|
700
1115
|
await this.client.sessionUpdate(notification);
|
|
701
1116
|
}
|
|
@@ -711,6 +1126,7 @@ export class ClaudeAcpAgent {
|
|
|
711
1126
|
}
|
|
712
1127
|
canUseTool(sessionId) {
|
|
713
1128
|
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
|
1129
|
+
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
|
714
1130
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
715
1131
|
const session = this.sessions[sessionId];
|
|
716
1132
|
if (!session) {
|
|
@@ -720,7 +1136,8 @@ export class ClaudeAcpAgent {
|
|
|
720
1136
|
};
|
|
721
1137
|
}
|
|
722
1138
|
if (toolName === "ExitPlanMode") {
|
|
723
|
-
const
|
|
1139
|
+
const optionsAll = [
|
|
1140
|
+
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
|
724
1141
|
{
|
|
725
1142
|
kind: "allow_always",
|
|
726
1143
|
name: "Yes, and auto-accept edits",
|
|
@@ -730,12 +1147,18 @@ export class ClaudeAcpAgent {
|
|
|
730
1147
|
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
|
731
1148
|
];
|
|
732
1149
|
if (ALLOW_BYPASS) {
|
|
733
|
-
|
|
1150
|
+
optionsAll.unshift({
|
|
734
1151
|
kind: "allow_always",
|
|
735
1152
|
name: "Yes, and bypass permissions",
|
|
736
1153
|
optionId: "bypassPermissions",
|
|
737
1154
|
});
|
|
738
1155
|
}
|
|
1156
|
+
// Filter against the session's currently-advertised modes so we never
|
|
1157
|
+
// present options the active model can't honor (e.g. `auto` on Haiku).
|
|
1158
|
+
// `bypassPermissions` is already covered by `availableModes` via
|
|
1159
|
+
// `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
|
|
1160
|
+
// "keep planning" reject path; it's always present in `availableModes`.
|
|
1161
|
+
const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
|
|
739
1162
|
const response = await this.client.requestPermission({
|
|
740
1163
|
options,
|
|
741
1164
|
sessionId,
|
|
@@ -748,23 +1171,26 @@ export class ClaudeAcpAgent {
|
|
|
748
1171
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
749
1172
|
throw new Error("Tool use aborted");
|
|
750
1173
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1174
|
+
const selectedMode = response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
|
|
1175
|
+
const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
|
|
1176
|
+
if (selectedModeWasOffered &&
|
|
1177
|
+
(selectedMode === "default" ||
|
|
1178
|
+
selectedMode === "acceptEdits" ||
|
|
1179
|
+
selectedMode === "auto" ||
|
|
1180
|
+
selectedMode === "bypassPermissions")) {
|
|
755
1181
|
await this.client.sessionUpdate({
|
|
756
1182
|
sessionId,
|
|
757
1183
|
update: {
|
|
758
1184
|
sessionUpdate: "current_mode_update",
|
|
759
|
-
currentModeId:
|
|
1185
|
+
currentModeId: selectedMode,
|
|
760
1186
|
},
|
|
761
1187
|
});
|
|
762
|
-
await this.updateConfigOption(sessionId, "mode",
|
|
1188
|
+
await this.updateConfigOption(sessionId, "mode", selectedMode);
|
|
763
1189
|
return {
|
|
764
1190
|
behavior: "allow",
|
|
765
1191
|
updatedInput: toolInput,
|
|
766
1192
|
updatedPermissions: suggestions ?? [
|
|
767
|
-
{ type: "setMode", mode:
|
|
1193
|
+
{ type: "setMode", mode: selectedMode, destination: "session" },
|
|
768
1194
|
],
|
|
769
1195
|
};
|
|
770
1196
|
}
|
|
@@ -788,7 +1214,7 @@ export class ClaudeAcpAgent {
|
|
|
788
1214
|
options: [
|
|
789
1215
|
{
|
|
790
1216
|
kind: "allow_always",
|
|
791
|
-
name:
|
|
1217
|
+
name: alwaysAllowLabel,
|
|
792
1218
|
optionId: "allow_always",
|
|
793
1219
|
},
|
|
794
1220
|
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
|
@@ -851,8 +1277,7 @@ export class ClaudeAcpAgent {
|
|
|
851
1277
|
const session = this.sessions[sessionId];
|
|
852
1278
|
if (!session)
|
|
853
1279
|
return;
|
|
854
|
-
this.
|
|
855
|
-
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
1280
|
+
await this.applyConfigOptionValue(sessionId, session, configId, value);
|
|
856
1281
|
await this.client.sessionUpdate({
|
|
857
1282
|
sessionId,
|
|
858
1283
|
update: {
|
|
@@ -861,27 +1286,107 @@ export class ClaudeAcpAgent {
|
|
|
861
1286
|
},
|
|
862
1287
|
});
|
|
863
1288
|
}
|
|
864
|
-
|
|
1289
|
+
async applyConfigOptionValue(sessionId, session, configId, value) {
|
|
865
1290
|
if (configId === "mode") {
|
|
866
1291
|
session.modes = { ...session.modes, currentModeId: value };
|
|
1292
|
+
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
867
1293
|
}
|
|
868
1294
|
else if (configId === "model") {
|
|
1295
|
+
if (session.models.currentModelId !== value) {
|
|
1296
|
+
// The cached context window was learned for the previous model; reset
|
|
1297
|
+
// to the new model's heuristic so mid-stream updates between now and
|
|
1298
|
+
// the next `result` reflect the user's selection instead of the old
|
|
1299
|
+
// model's window.
|
|
1300
|
+
session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
|
|
1301
|
+
}
|
|
869
1302
|
session.models = { ...session.models, currentModelId: value };
|
|
1303
|
+
// Recompute availableModes for the new model and clamp the current
|
|
1304
|
+
// mode if the SDK no longer offers it (today: "auto" on Haiku).
|
|
1305
|
+
// `ModelInfo.supportsAutoMode` is the canonical SDK signal.
|
|
1306
|
+
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
1307
|
+
const newAvailableModes = buildAvailableModes(newModelInfo);
|
|
1308
|
+
// Capture BEFORE mutating session.modes so the log message reflects
|
|
1309
|
+
// the invalidated mode rather than "default".
|
|
1310
|
+
const previousModeId = session.modes.currentModeId;
|
|
1311
|
+
let modeDowngraded = false;
|
|
1312
|
+
if (!newAvailableModes.some((m) => m.id === previousModeId)) {
|
|
1313
|
+
session.modes = {
|
|
1314
|
+
availableModes: newAvailableModes,
|
|
1315
|
+
currentModeId: "default",
|
|
1316
|
+
};
|
|
1317
|
+
try {
|
|
1318
|
+
await session.query.setPermissionMode("default");
|
|
1319
|
+
}
|
|
1320
|
+
catch (err) {
|
|
1321
|
+
// Failing the entire model switch over a bookkeeping sync error is
|
|
1322
|
+
// worse UX than logging and continuing; the user explicitly asked
|
|
1323
|
+
// to change models. The next setPermissionMode from the user will
|
|
1324
|
+
// either succeed or surface a fresh error.
|
|
1325
|
+
this.logger.error(`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, err);
|
|
1326
|
+
}
|
|
1327
|
+
modeDowngraded = true;
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
session.modes = { ...session.modes, availableModes: newAvailableModes };
|
|
1331
|
+
}
|
|
1332
|
+
// Rebuild config options since effort levels depend on the selected model
|
|
1333
|
+
const effortOpt = session.configOptions.find((o) => o.id === "effort");
|
|
1334
|
+
const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
|
1335
|
+
session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort);
|
|
1336
|
+
// Sync effort with the SDK if it changed after the model switch
|
|
1337
|
+
const newEffortOpt = session.configOptions.find((o) => o.id === "effort");
|
|
1338
|
+
const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
|
1339
|
+
if (newEffort !== currentEffort) {
|
|
1340
|
+
await session.query.applyFlagSettings({
|
|
1341
|
+
effortLevel: toSdkEffortLevel(newEffort),
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
// Emit current_mode_update only after session.modes AND
|
|
1345
|
+
// session.configOptions have been fully reconciled. This way, a failure
|
|
1346
|
+
// in the configOptions/effort rebuild above can't leave the client with
|
|
1347
|
+
// a clamped currentModeId but stale configOptions, and the notification
|
|
1348
|
+
// still precedes the caller's config_option_update so order-sensitive
|
|
1349
|
+
// clients update currentModeId before re-rendering the option list.
|
|
1350
|
+
if (modeDowngraded) {
|
|
1351
|
+
await this.client.sessionUpdate({
|
|
1352
|
+
sessionId,
|
|
1353
|
+
update: {
|
|
1354
|
+
sessionUpdate: "current_mode_update",
|
|
1355
|
+
currentModeId: "default",
|
|
1356
|
+
},
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
1362
|
+
if (configId === "effort") {
|
|
1363
|
+
await session.query.applyFlagSettings({
|
|
1364
|
+
effortLevel: toSdkEffortLevel(value),
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
870
1367
|
}
|
|
871
1368
|
}
|
|
872
1369
|
async getOrCreateSession(params) {
|
|
873
1370
|
const existingSession = this.sessions[params.sessionId];
|
|
874
1371
|
if (existingSession) {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1372
|
+
const fingerprint = computeSessionFingerprint(params);
|
|
1373
|
+
if (fingerprint === existingSession.sessionFingerprint) {
|
|
1374
|
+
return {
|
|
1375
|
+
sessionId: params.sessionId,
|
|
1376
|
+
modes: existingSession.modes,
|
|
1377
|
+
models: existingSession.models,
|
|
1378
|
+
configOptions: existingSession.configOptions,
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
// Session-defining params changed (e.g. cwd pointed at a git worktree,
|
|
1382
|
+
// or MCP servers reconfigured). Tear down the existing session and
|
|
1383
|
+
// recreate it so the underlying Query process picks up the new values.
|
|
1384
|
+
await this.teardownSession(params.sessionId);
|
|
881
1385
|
}
|
|
882
1386
|
const response = await this.createSession({
|
|
883
1387
|
cwd: params.cwd,
|
|
884
1388
|
mcpServers: params.mcpServers ?? [],
|
|
1389
|
+
additionalDirectories: params.additionalDirectories,
|
|
885
1390
|
_meta: params._meta,
|
|
886
1391
|
}, {
|
|
887
1392
|
resume: params.sessionId,
|
|
@@ -924,7 +1429,7 @@ export class ClaudeAcpAgent {
|
|
|
924
1429
|
: undefined,
|
|
925
1430
|
};
|
|
926
1431
|
}
|
|
927
|
-
else {
|
|
1432
|
+
else if (!("type" in server)) {
|
|
928
1433
|
// Stdio type MCP server (with or without explicit type field)
|
|
929
1434
|
mcpServers[server.name] = {
|
|
930
1435
|
type: "stdio",
|
|
@@ -944,12 +1449,18 @@ export class ClaudeAcpAgent {
|
|
|
944
1449
|
systemPrompt = customPrompt;
|
|
945
1450
|
}
|
|
946
1451
|
else if (typeof customPrompt === "object" &&
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1452
|
+
customPrompt !== null &&
|
|
1453
|
+
!Array.isArray(customPrompt)) {
|
|
1454
|
+
// Forward all preset options (append, excludeDynamicSections, and
|
|
1455
|
+
// anything the SDK adds later) while locking type/preset.
|
|
1456
|
+
systemPrompt = {
|
|
1457
|
+
...customPrompt,
|
|
1458
|
+
type: "preset",
|
|
1459
|
+
preset: "claude_code",
|
|
1460
|
+
};
|
|
950
1461
|
}
|
|
951
1462
|
}
|
|
952
|
-
const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode);
|
|
1463
|
+
const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
|
|
953
1464
|
// Extract options from _meta if provided
|
|
954
1465
|
const sessionMeta = params._meta;
|
|
955
1466
|
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
@@ -957,8 +1468,10 @@ export class ClaudeAcpAgent {
|
|
|
957
1468
|
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
|
|
958
1469
|
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
|
|
959
1470
|
: undefined;
|
|
1471
|
+
// Parse model configuration from environment (e.g. Bedrock model overrides)
|
|
1472
|
+
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
|
|
960
1473
|
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
|
|
961
|
-
const disallowedTools = ["AskUserQuestion"];
|
|
1474
|
+
const disallowedTools = ["AskUserQuestion", "WebFetch", "WebSearch"];
|
|
962
1475
|
// Resolve which built-in tools to expose.
|
|
963
1476
|
// Explicit tools array from _meta.claudeCode.options takes precedence.
|
|
964
1477
|
// disableBuiltInTools is a legacy shorthand for tools: [] — kept for
|
|
@@ -966,15 +1479,30 @@ export class ClaudeAcpAgent {
|
|
|
966
1479
|
const tools = userProvidedOptions?.tools ??
|
|
967
1480
|
(params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" });
|
|
968
1481
|
const abortController = userProvidedOptions?.abortController || new AbortController();
|
|
1482
|
+
// Per-session task state. Created here (rather than in the session record
|
|
1483
|
+
// below) so the TaskCreated/TaskCompleted hook callbacks can close over
|
|
1484
|
+
// the same Map that the streaming message handler will read from.
|
|
1485
|
+
const taskState = new Map();
|
|
969
1486
|
const options = {
|
|
970
1487
|
systemPrompt,
|
|
971
1488
|
settingSources: ["user", "project", "local"],
|
|
972
1489
|
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
|
|
973
1490
|
...userProvidedOptions,
|
|
1491
|
+
// CLAUDE_MODEL_CONFIG env var is a fallback for model
|
|
1492
|
+
// configuration (e.g. Bedrock model ID overrides). When the caller
|
|
1493
|
+
// provides settings via _meta, we intentionally ignore the env var —
|
|
1494
|
+
// the caller is assumed to have full control over model configuration.
|
|
1495
|
+
...(!userProvidedOptions?.settings &&
|
|
1496
|
+
modelConfig && {
|
|
1497
|
+
settings: {
|
|
1498
|
+
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
|
1499
|
+
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
|
1500
|
+
},
|
|
1501
|
+
}),
|
|
974
1502
|
env: {
|
|
975
1503
|
...process.env,
|
|
976
1504
|
...userProvidedOptions?.env,
|
|
977
|
-
...createEnvForGateway(this.
|
|
1505
|
+
...createEnvForGateway(this.gatewayAuthRequest),
|
|
978
1506
|
// Opt-in to session state events like when the agent is idle
|
|
979
1507
|
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
980
1508
|
},
|
|
@@ -987,14 +1515,7 @@ export class ClaudeAcpAgent {
|
|
|
987
1515
|
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
988
1516
|
permissionMode,
|
|
989
1517
|
canUseTool: this.canUseTool(sessionId),
|
|
990
|
-
|
|
991
|
-
// here works to find zed's managed node version.
|
|
992
|
-
executable: isStaticBinary() ? undefined : process.execPath,
|
|
993
|
-
...(process.env.CLAUDE_CODE_EXECUTABLE
|
|
994
|
-
? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE }
|
|
995
|
-
: isStaticBinary()
|
|
996
|
-
? { pathToClaudeCodeExecutable: await claudeCliPath() }
|
|
997
|
-
: {}),
|
|
1518
|
+
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
|
|
998
1519
|
extraArgs: {
|
|
999
1520
|
...userProvidedOptions?.extraArgs,
|
|
1000
1521
|
"replay-user-messages": "",
|
|
@@ -1022,13 +1543,56 @@ export class ClaudeAcpAgent {
|
|
|
1022
1543
|
],
|
|
1023
1544
|
},
|
|
1024
1545
|
],
|
|
1546
|
+
TaskCreated: [
|
|
1547
|
+
...(userProvidedOptions?.hooks?.TaskCreated || []),
|
|
1548
|
+
{
|
|
1549
|
+
hooks: [
|
|
1550
|
+
createTaskHook({
|
|
1551
|
+
taskState,
|
|
1552
|
+
onChange: async () => {
|
|
1553
|
+
await this.client.sessionUpdate({
|
|
1554
|
+
sessionId,
|
|
1555
|
+
update: {
|
|
1556
|
+
sessionUpdate: "plan",
|
|
1557
|
+
entries: taskStateToPlanEntries(taskState),
|
|
1558
|
+
},
|
|
1559
|
+
});
|
|
1560
|
+
},
|
|
1561
|
+
}),
|
|
1562
|
+
],
|
|
1563
|
+
},
|
|
1564
|
+
],
|
|
1565
|
+
TaskCompleted: [
|
|
1566
|
+
...(userProvidedOptions?.hooks?.TaskCompleted || []),
|
|
1567
|
+
{
|
|
1568
|
+
hooks: [
|
|
1569
|
+
createTaskHook({
|
|
1570
|
+
taskState,
|
|
1571
|
+
onChange: async () => {
|
|
1572
|
+
await this.client.sessionUpdate({
|
|
1573
|
+
sessionId,
|
|
1574
|
+
update: {
|
|
1575
|
+
sessionUpdate: "plan",
|
|
1576
|
+
entries: taskStateToPlanEntries(taskState),
|
|
1577
|
+
},
|
|
1578
|
+
});
|
|
1579
|
+
},
|
|
1580
|
+
}),
|
|
1581
|
+
],
|
|
1582
|
+
},
|
|
1583
|
+
],
|
|
1025
1584
|
},
|
|
1026
1585
|
...creationOpts,
|
|
1027
1586
|
abortController,
|
|
1028
1587
|
};
|
|
1588
|
+
// Prefer the official ACP `additionalDirectories` field. Fall back to the
|
|
1589
|
+
// legacy `_meta.additionalRoots` extension for clients that haven't been
|
|
1590
|
+
// updated yet. Either source is merged with directories supplied via
|
|
1591
|
+
// `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
|
|
1592
|
+
const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
|
|
1029
1593
|
options.additionalDirectories = [
|
|
1030
1594
|
...(userProvidedOptions?.additionalDirectories ?? []),
|
|
1031
|
-
...
|
|
1595
|
+
...acpAdditionalDirectories,
|
|
1032
1596
|
];
|
|
1033
1597
|
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
|
1034
1598
|
// Set our own session id if not resuming an existing session.
|
|
@@ -1038,15 +1602,6 @@ export class ClaudeAcpAgent {
|
|
|
1038
1602
|
if (abortController?.signal.aborted) {
|
|
1039
1603
|
throw new Error("Cancelled");
|
|
1040
1604
|
}
|
|
1041
|
-
// ==================== 会话创建开始 ====================
|
|
1042
|
-
const sessionCreateStartTime = Date.now();
|
|
1043
|
-
this.logger.log(`🔵 [ACP] 开始创建会话`);
|
|
1044
|
-
this.logger.log(` ├─ sessionId: ${sessionId}`);
|
|
1045
|
-
this.logger.log(` ├─ cwd: ${params.cwd}`);
|
|
1046
|
-
this.logger.log(` └─ MCP 服务器数量: ${Object.keys(mcpServers).length}`);
|
|
1047
|
-
if (Object.keys(mcpServers).length > 0) {
|
|
1048
|
-
this.logger.log(` └─ MCP 服务器列表: ${Object.keys(mcpServers).join(", ")}`);
|
|
1049
|
-
}
|
|
1050
1605
|
const q = query({
|
|
1051
1606
|
prompt: input,
|
|
1052
1607
|
options,
|
|
@@ -1058,59 +1613,78 @@ export class ClaudeAcpAgent {
|
|
|
1058
1613
|
catch (error) {
|
|
1059
1614
|
if (creationOpts.resume &&
|
|
1060
1615
|
error instanceof Error &&
|
|
1061
|
-
error.message === "Query closed before response received"
|
|
1616
|
+
(error.message === "Query closed before response received" ||
|
|
1617
|
+
error.message.includes("No conversation found with session ID"))) {
|
|
1062
1618
|
throw RequestError.resourceNotFound(sessionId);
|
|
1063
1619
|
}
|
|
1064
1620
|
throw error;
|
|
1065
1621
|
}
|
|
1066
1622
|
if (shouldHideClaudeAuth() &&
|
|
1067
1623
|
initializationResult.account.subscriptionType &&
|
|
1068
|
-
!this.
|
|
1624
|
+
!this.gatewayAuthRequest) {
|
|
1069
1625
|
throw RequestError.authRequired(undefined, "This integration does not support using claude.ai subscriptions.");
|
|
1070
1626
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1627
|
+
// Apply user's `availableModels` allowlist from settings.json before any
|
|
1628
|
+
// downstream model handling. The SDK only enforces this allowlist in its
|
|
1629
|
+
// own UI, not in `initializationResult.models`, so we filter here to keep
|
|
1630
|
+
// configOptions, the current-model resolver, and the stored modelInfos
|
|
1631
|
+
// consistent with what the user configured.
|
|
1632
|
+
const settingsAvailableModels = settingsManager.getSettings().availableModels;
|
|
1633
|
+
const allowedModels = Array.isArray(settingsAvailableModels)
|
|
1634
|
+
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels)
|
|
1635
|
+
: initializationResult.models;
|
|
1636
|
+
const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger);
|
|
1637
|
+
// Gate `auto` (and future model-specific modes) on the resolved model's
|
|
1638
|
+
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
|
|
1639
|
+
const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
|
|
1640
|
+
const availableModes = buildAvailableModes(currentModelInfo);
|
|
1641
|
+
// Clamp `permissionMode` if the resolved session does not offer it. The
|
|
1642
|
+
// common case is `permissions.defaultMode: "auto"` resolving to a model
|
|
1643
|
+
// that does not support auto mode (e.g. Haiku); without this clamp the
|
|
1644
|
+
// SDK would later throw `"auto mode unavailable for this model"` from
|
|
1645
|
+
// `setPermissionMode`. Keep `permissionMode` as the resolved user intent
|
|
1646
|
+
// (matches what was passed into `options.permissionMode` above) and use
|
|
1647
|
+
// `effectiveMode` for the post-clamp value the session actually runs in.
|
|
1648
|
+
let effectiveMode = permissionMode;
|
|
1649
|
+
if (!availableModes.some((m) => m.id === effectiveMode)) {
|
|
1650
|
+
if (effectiveMode === "auto") {
|
|
1651
|
+
this.logger.error(`permissions.defaultMode "auto" is not available for model ` +
|
|
1652
|
+
`"${models.currentModelId}"; falling back to "default".`);
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
this.logger.error(`permissions.defaultMode "${effectiveMode}" is not available in ` +
|
|
1656
|
+
`this session; falling back to "default".`);
|
|
1657
|
+
}
|
|
1658
|
+
effectiveMode = "default";
|
|
1659
|
+
// Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
|
|
1660
|
+
// try/catch since failing here would abort session creation entirely.
|
|
1661
|
+
try {
|
|
1662
|
+
await q.setPermissionMode("default");
|
|
1663
|
+
}
|
|
1664
|
+
catch (err) {
|
|
1665
|
+
this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
|
|
1666
|
+
}
|
|
1103
1667
|
}
|
|
1104
1668
|
const modes = {
|
|
1105
|
-
currentModeId:
|
|
1669
|
+
currentModeId: effectiveMode,
|
|
1106
1670
|
availableModes,
|
|
1107
1671
|
};
|
|
1108
|
-
const configOptions = buildConfigOptions(modes, models);
|
|
1672
|
+
const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel);
|
|
1673
|
+
// Apply the initial effort level to the SDK so it matches the UI default
|
|
1674
|
+
const initialEffort = configOptions.find((o) => o.id === "effort");
|
|
1675
|
+
if (initialEffort &&
|
|
1676
|
+
typeof initialEffort.currentValue === "string" &&
|
|
1677
|
+
initialEffort.currentValue !== "default") {
|
|
1678
|
+
await q.applyFlagSettings({
|
|
1679
|
+
effortLevel: initialEffort.currentValue,
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1109
1682
|
this.sessions[sessionId] = {
|
|
1110
1683
|
query: q,
|
|
1111
1684
|
input: input,
|
|
1112
1685
|
cancelled: false,
|
|
1113
1686
|
cwd: params.cwd,
|
|
1687
|
+
sessionFingerprint: computeSessionFingerprint(params),
|
|
1114
1688
|
settingsManager,
|
|
1115
1689
|
accumulatedUsage: {
|
|
1116
1690
|
inputTokens: 0,
|
|
@@ -1120,11 +1694,15 @@ export class ClaudeAcpAgent {
|
|
|
1120
1694
|
},
|
|
1121
1695
|
modes,
|
|
1122
1696
|
models,
|
|
1697
|
+
modelInfos: allowedModels,
|
|
1123
1698
|
configOptions,
|
|
1124
1699
|
promptRunning: false,
|
|
1125
1700
|
pendingMessages: new Map(),
|
|
1126
1701
|
nextPendingOrder: 0,
|
|
1127
1702
|
abortController,
|
|
1703
|
+
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
1704
|
+
contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
|
1705
|
+
taskState,
|
|
1128
1706
|
};
|
|
1129
1707
|
return {
|
|
1130
1708
|
sessionId,
|
|
@@ -1134,6 +1712,15 @@ export class ClaudeAcpAgent {
|
|
|
1134
1712
|
};
|
|
1135
1713
|
}
|
|
1136
1714
|
}
|
|
1715
|
+
function shouldEmitRawMessage(config, message) {
|
|
1716
|
+
if (config === true)
|
|
1717
|
+
return true;
|
|
1718
|
+
if (config === false)
|
|
1719
|
+
return false;
|
|
1720
|
+
return config.some((f) => f.type === message.type &&
|
|
1721
|
+
(f.subtype === undefined || f.subtype === message.subtype) &&
|
|
1722
|
+
(f.origin === undefined || f.origin === message.origin?.kind));
|
|
1723
|
+
}
|
|
1137
1724
|
function sessionUsage(session) {
|
|
1138
1725
|
return {
|
|
1139
1726
|
inputTokens: session.accumulatedUsage.inputTokens,
|
|
@@ -1146,20 +1733,119 @@ function sessionUsage(session) {
|
|
|
1146
1733
|
session.accumulatedUsage.cachedWriteTokens,
|
|
1147
1734
|
};
|
|
1148
1735
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1736
|
+
/** Sum all four fields as a proxy for post-turn context occupancy: the current
|
|
1737
|
+
* turn's output becomes next turn's input. Per the Anthropic API, input_tokens
|
|
1738
|
+
* excludes cache tokens — cache_read and cache_creation are reported
|
|
1739
|
+
* separately — so summing all four is not double-counting. */
|
|
1740
|
+
function totalTokens(usage) {
|
|
1741
|
+
return (usage.input_tokens +
|
|
1742
|
+
usage.output_tokens +
|
|
1743
|
+
usage.cache_read_input_tokens +
|
|
1744
|
+
usage.cache_creation_input_tokens);
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Build the `data` payload attached to a `RequestError.internalError` when we
|
|
1748
|
+
* have a categorical error from the Claude SDK. Returns `undefined` when no
|
|
1749
|
+
* categorical error is available, matching the previous behavior of passing
|
|
1750
|
+
* `undefined` to `RequestError.internalError`.
|
|
1751
|
+
*
|
|
1752
|
+
* The `errorKind` field is a convention for ACP clients to dispatch on
|
|
1753
|
+
* without having to pattern-match the human-readable message text. Clients
|
|
1754
|
+
* that don't understand it fall back to the existing message-based rendering.
|
|
1755
|
+
*/
|
|
1756
|
+
function errorKindData(errorKind) {
|
|
1757
|
+
return errorKind ? { errorKind } : undefined;
|
|
1758
|
+
}
|
|
1759
|
+
/** Project a nullable API usage object into our non-null snapshot shape.
|
|
1760
|
+
* Both SDK message_start and assistant message `usage` have `number | null`
|
|
1761
|
+
* cache fields; we coerce absent values to 0 so `totalTokens` never hits
|
|
1762
|
+
* NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but
|
|
1763
|
+
* synthetic or third-party-backend stream events have been observed emitting
|
|
1764
|
+
* them as null/undefined — coerce those too so a malformed upstream event
|
|
1765
|
+
* can't leak NaN into the wire `used` field. Delta events have different
|
|
1766
|
+
* semantics (cumulative + prev fallback) and are handled inline. */
|
|
1767
|
+
function snapshotFromUsage(usage) {
|
|
1768
|
+
return {
|
|
1769
|
+
input_tokens: usage.input_tokens ?? 0,
|
|
1770
|
+
output_tokens: usage.output_tokens ?? 0,
|
|
1771
|
+
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
|
1772
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function createEnvForGateway(request) {
|
|
1776
|
+
if (!request?._meta) {
|
|
1151
1777
|
return {};
|
|
1152
1778
|
}
|
|
1779
|
+
const customHeaders = Object.entries(request._meta.gateway.headers)
|
|
1780
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
1781
|
+
.join("\n");
|
|
1782
|
+
if (request.methodId === "gateway-bedrock") {
|
|
1783
|
+
return {
|
|
1784
|
+
CLAUDE_CODE_USE_BEDROCK: "1",
|
|
1785
|
+
AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
|
|
1786
|
+
ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
|
|
1787
|
+
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1153
1790
|
return {
|
|
1154
|
-
ANTHROPIC_BASE_URL:
|
|
1155
|
-
ANTHROPIC_CUSTOM_HEADERS:
|
|
1156
|
-
|
|
1157
|
-
.join("\n"),
|
|
1158
|
-
ANTHROPIC_AUTH_TOKEN: "", // Must be specified to bypass claude login requirement
|
|
1791
|
+
ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
|
|
1792
|
+
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
1793
|
+
ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
|
|
1159
1794
|
};
|
|
1160
1795
|
}
|
|
1161
|
-
|
|
1162
|
-
|
|
1796
|
+
/**
|
|
1797
|
+
* Build the list of permission modes the agent will advertise for the given
|
|
1798
|
+
* model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
|
|
1799
|
+
* the SDK's model-level availability signal. `undefined`/`false` both exclude
|
|
1800
|
+
* `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
|
|
1801
|
+
*/
|
|
1802
|
+
function buildAvailableModes(modelInfo) {
|
|
1803
|
+
const modes = [];
|
|
1804
|
+
// Only advertise "auto" when the SDK reports the model supports it.
|
|
1805
|
+
if (modelInfo?.supportsAutoMode === true) {
|
|
1806
|
+
modes.push({
|
|
1807
|
+
id: "auto",
|
|
1808
|
+
name: "Auto",
|
|
1809
|
+
description: "Use a model classifier to approve/deny permission prompts",
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
modes.push({
|
|
1813
|
+
id: "default",
|
|
1814
|
+
name: "Default",
|
|
1815
|
+
description: "Standard behavior, prompts for dangerous operations",
|
|
1816
|
+
}, {
|
|
1817
|
+
id: "acceptEdits",
|
|
1818
|
+
name: "Accept Edits",
|
|
1819
|
+
description: "Auto-accept file edit operations",
|
|
1820
|
+
}, {
|
|
1821
|
+
id: "plan",
|
|
1822
|
+
name: "Plan Mode",
|
|
1823
|
+
description: "Planning mode, no actual tool execution",
|
|
1824
|
+
}, {
|
|
1825
|
+
id: "dontAsk",
|
|
1826
|
+
name: "Don't Ask",
|
|
1827
|
+
description: "Don't prompt for permissions, deny if not pre-approved",
|
|
1828
|
+
});
|
|
1829
|
+
if (ALLOW_BYPASS) {
|
|
1830
|
+
modes.push({
|
|
1831
|
+
id: "bypassPermissions",
|
|
1832
|
+
name: "Bypass Permissions",
|
|
1833
|
+
description: "Bypass all permission checks",
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
return modes;
|
|
1837
|
+
}
|
|
1838
|
+
// Translate a UI effort value into the flag-layer payload. The SDK
|
|
1839
|
+
// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
|
|
1840
|
+
// and only clears a key when an explicit `null` is sent — see
|
|
1841
|
+
// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
|
|
1842
|
+
// `"default"` sentinel and `undefined` (effort option absent for the model) to
|
|
1843
|
+
// `null` ensures any previously-applied flag is actually cleared.
|
|
1844
|
+
function toSdkEffortLevel(value) {
|
|
1845
|
+
return value === undefined || value === "default" ? null : value;
|
|
1846
|
+
}
|
|
1847
|
+
function buildConfigOptions(modes, models, modelInfos, currentEffortLevel) {
|
|
1848
|
+
const options = [
|
|
1163
1849
|
{
|
|
1164
1850
|
id: "mode",
|
|
1165
1851
|
name: "Mode",
|
|
@@ -1187,10 +1873,58 @@ function buildConfigOptions(modes, models) {
|
|
|
1187
1873
|
})),
|
|
1188
1874
|
},
|
|
1189
1875
|
];
|
|
1876
|
+
// Add effort level option based on the currently selected model
|
|
1877
|
+
const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId);
|
|
1878
|
+
const supportedLevels = currentModelInfo?.supportsEffort
|
|
1879
|
+
? (currentModelInfo.supportedEffortLevels ?? [])
|
|
1880
|
+
: [];
|
|
1881
|
+
if (supportedLevels.length > 0) {
|
|
1882
|
+
const effortOptions = [
|
|
1883
|
+
{ value: "default", name: "Default" },
|
|
1884
|
+
...supportedLevels.map((level) => ({
|
|
1885
|
+
value: level,
|
|
1886
|
+
name: level
|
|
1887
|
+
.split(/[_-]/)
|
|
1888
|
+
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
|
1889
|
+
.join(" "),
|
|
1890
|
+
})),
|
|
1891
|
+
];
|
|
1892
|
+
const includes = (l) => l === "default" || supportedLevels.includes(l);
|
|
1893
|
+
const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
|
|
1894
|
+
options.push({
|
|
1895
|
+
id: "effort",
|
|
1896
|
+
name: "Effort",
|
|
1897
|
+
description: "Available effort levels for this model",
|
|
1898
|
+
category: "thought_level",
|
|
1899
|
+
type: "select",
|
|
1900
|
+
currentValue: validEffort,
|
|
1901
|
+
options: effortOptions,
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
return options;
|
|
1190
1905
|
}
|
|
1191
1906
|
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|
|
1192
1907
|
// but the SDK model list uses IDs like "claude-opus-4-6-1m".
|
|
1193
1908
|
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
|
|
1909
|
+
// Captures a model family version such as `4-6` or `4.7` so we can keep
|
|
1910
|
+
// `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that
|
|
1911
|
+
// alias currently resolves to a different family version (e.g. Opus 4.7).
|
|
1912
|
+
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/;
|
|
1913
|
+
function extractModelFamilyVersion(s) {
|
|
1914
|
+
const match = s.match(MODEL_FAMILY_VERSION_PATTERN);
|
|
1915
|
+
return match ? `${match[1]}.${match[2]}` : null;
|
|
1916
|
+
}
|
|
1917
|
+
function modelVersionsCompatible(preference, candidate) {
|
|
1918
|
+
const preferred = extractModelFamilyVersion(preference);
|
|
1919
|
+
if (!preferred)
|
|
1920
|
+
return true;
|
|
1921
|
+
const candidateVersion = extractModelFamilyVersion(candidate.value) ??
|
|
1922
|
+
extractModelFamilyVersion(candidate.displayName) ??
|
|
1923
|
+
extractModelFamilyVersion(candidate.description);
|
|
1924
|
+
if (!candidateVersion)
|
|
1925
|
+
return true;
|
|
1926
|
+
return preferred === candidateVersion;
|
|
1927
|
+
}
|
|
1194
1928
|
function tokenizeModelPreference(model) {
|
|
1195
1929
|
const lower = model.trim().toLowerCase();
|
|
1196
1930
|
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
|
|
@@ -1231,6 +1965,8 @@ function resolveModelPreference(models, preference) {
|
|
|
1231
1965
|
return directMatch;
|
|
1232
1966
|
// Substring match
|
|
1233
1967
|
const includesMatch = models.find((model) => {
|
|
1968
|
+
if (!modelVersionsCompatible(trimmed, model))
|
|
1969
|
+
return false;
|
|
1234
1970
|
const value = model.value.toLowerCase();
|
|
1235
1971
|
const display = model.displayName.toLowerCase();
|
|
1236
1972
|
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
|
@@ -1244,6 +1980,8 @@ function resolveModelPreference(models, preference) {
|
|
|
1244
1980
|
let bestMatch = null;
|
|
1245
1981
|
let bestScore = 0;
|
|
1246
1982
|
for (const model of models) {
|
|
1983
|
+
if (!modelVersionsCompatible(trimmed, model))
|
|
1984
|
+
continue;
|
|
1247
1985
|
const score = scoreModelMatch(model, tokens, contextHint);
|
|
1248
1986
|
if (0 < score && (!bestMatch || bestScore < score)) {
|
|
1249
1987
|
bestMatch = model;
|
|
@@ -1252,26 +1990,98 @@ function resolveModelPreference(models, preference) {
|
|
|
1252
1990
|
}
|
|
1253
1991
|
return bestMatch;
|
|
1254
1992
|
}
|
|
1255
|
-
|
|
1993
|
+
function resolveSettingsModel(models, settingsModel, logger) {
|
|
1994
|
+
if (settingsModel === undefined) {
|
|
1995
|
+
return null;
|
|
1996
|
+
}
|
|
1997
|
+
if (typeof settingsModel !== "string") {
|
|
1998
|
+
const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
|
|
1999
|
+
logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
return resolveModelPreference(models, settingsModel);
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Restrict the SDK's model list to the user's `availableModels` allowlist
|
|
2006
|
+
* (already merged-and-deduped across settings sources by `SettingsManager`).
|
|
2007
|
+
* The user's exact entries become the model IDs surfaced via configOptions
|
|
2008
|
+
* and passed to `setModel`, which prevents Claude Code from silently
|
|
2009
|
+
* substituting a date-pinned variant (e.g. `haiku` →
|
|
2010
|
+
* `claude-haiku-4-5-20251001`) that the user may not have access to.
|
|
2011
|
+
*
|
|
2012
|
+
* Display info and capability flags are copied from the closest SDK match so
|
|
2013
|
+
* the UI still renders sensible names and effort levels.
|
|
2014
|
+
*
|
|
2015
|
+
* Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
|
|
2016
|
+
* - `undefined` is handled by the caller (no allowlist applied).
|
|
2017
|
+
* - The Default option is unaffected by `availableModels` — it always remains
|
|
2018
|
+
* available, even when the allowlist is `[]`.
|
|
2019
|
+
*/
|
|
2020
|
+
function applyAvailableModelsAllowlist(sdkModels, allowlist) {
|
|
2021
|
+
// Default is always preserved per the docs. Synthesize one if the SDK
|
|
2022
|
+
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
|
2023
|
+
// `models[0]` as a fallback) still has something to work with.
|
|
2024
|
+
const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
|
|
2025
|
+
value: "default",
|
|
2026
|
+
displayName: "Default",
|
|
2027
|
+
description: "",
|
|
2028
|
+
};
|
|
2029
|
+
const result = [defaultModel];
|
|
2030
|
+
const seen = new Set([defaultModel.value]);
|
|
2031
|
+
const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
|
|
2032
|
+
for (const entry of allowlist) {
|
|
2033
|
+
const trimmed = entry.trim();
|
|
2034
|
+
if (!trimmed || seen.has(trimmed))
|
|
2035
|
+
continue;
|
|
2036
|
+
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
|
2037
|
+
if (sdkMatch) {
|
|
2038
|
+
result.push({ ...sdkMatch, value: trimmed });
|
|
2039
|
+
}
|
|
2040
|
+
else {
|
|
2041
|
+
result.push({ value: trimmed, displayName: trimmed, description: "" });
|
|
2042
|
+
}
|
|
2043
|
+
seen.add(trimmed);
|
|
2044
|
+
}
|
|
2045
|
+
return result;
|
|
2046
|
+
}
|
|
2047
|
+
async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
|
|
1256
2048
|
const settings = settingsManager.getSettings();
|
|
1257
2049
|
let currentModel = models[0];
|
|
1258
|
-
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
2050
|
+
let resolvedFromInput;
|
|
2051
|
+
// Model priority (highest to lowest):
|
|
2052
|
+
// 1. ANTHROPIC_MODEL environment variable
|
|
2053
|
+
// 2. settings.model (user configuration)
|
|
2054
|
+
// 3. models[0] (default first model)
|
|
1262
2055
|
if (process.env.ANTHROPIC_MODEL) {
|
|
1263
2056
|
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
|
1264
2057
|
if (match) {
|
|
1265
2058
|
currentModel = match;
|
|
2059
|
+
resolvedFromInput = process.env.ANTHROPIC_MODEL;
|
|
1266
2060
|
}
|
|
1267
2061
|
}
|
|
1268
|
-
else if (settings.model) {
|
|
1269
|
-
const match =
|
|
2062
|
+
else if (typeof settings.model === "string") {
|
|
2063
|
+
const match = resolveSettingsModel(models, settings.model, logger);
|
|
1270
2064
|
if (match) {
|
|
1271
2065
|
currentModel = match;
|
|
2066
|
+
resolvedFromInput = settings.model;
|
|
1272
2067
|
}
|
|
1273
2068
|
}
|
|
1274
|
-
|
|
2069
|
+
// Skip the setModel round-trip when we can prove the SDK has already landed
|
|
2070
|
+
// on the same model. Two cases qualify:
|
|
2071
|
+
// (a) No override applied — currentModel stayed at models[0]; the SDK is on
|
|
2072
|
+
// its own default and we have nothing to sync.
|
|
2073
|
+
// (b) The resolver returned the user's input verbatim AND that value exists
|
|
2074
|
+
// in the SDK's original model list — meaning no fuzzy match or
|
|
2075
|
+
// allowlist rewrite was involved, and the SDK (which reads the same
|
|
2076
|
+
// ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
|
|
2077
|
+
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
|
|
2078
|
+
// setModel call so we don't drift from the user's intended pin.
|
|
2079
|
+
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
|
|
2080
|
+
const skipSetModel = resolvedFromInput === undefined ||
|
|
2081
|
+
(currentModel.value === resolvedFromInput && sdkSawSameValue);
|
|
2082
|
+
if (!skipSetModel) {
|
|
2083
|
+
await query.setModel(currentModel.value);
|
|
2084
|
+
}
|
|
1275
2085
|
return {
|
|
1276
2086
|
availableModels: models.map((model) => ({
|
|
1277
2087
|
modelId: model.value,
|
|
@@ -1283,6 +2093,7 @@ async function getAvailableModels(query, models, settingsManager) {
|
|
|
1283
2093
|
}
|
|
1284
2094
|
function getAvailableSlashCommands(commands) {
|
|
1285
2095
|
const UNSUPPORTED_COMMANDS = [
|
|
2096
|
+
"clear",
|
|
1286
2097
|
"cost",
|
|
1287
2098
|
"keybindings-help",
|
|
1288
2099
|
"login",
|
|
@@ -1411,6 +2222,7 @@ export function promptToClaude(prompt) {
|
|
|
1411
2222
|
* Only handles text, image, and thinking chunks for now.
|
|
1412
2223
|
*/
|
|
1413
2224
|
export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
|
|
2225
|
+
const taskState = options?.taskState ?? new Map();
|
|
1414
2226
|
const registerHooks = options?.registerHooks !== false;
|
|
1415
2227
|
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
1416
2228
|
if (typeof content === "string") {
|
|
@@ -1474,14 +2286,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
1474
2286
|
const alreadyCached = chunk.id in toolUseCache;
|
|
1475
2287
|
toolUseCache[chunk.id] = chunk;
|
|
1476
2288
|
if (chunk.name === "TodoWrite") {
|
|
1477
|
-
// @ts-expect-error - sometimes input is empty object
|
|
1478
|
-
if (Array.isArray(chunk.input
|
|
2289
|
+
// @ts-expect-error - sometimes input is empty object or undefined
|
|
2290
|
+
if (Array.isArray(chunk.input?.todos)) {
|
|
1479
2291
|
update = {
|
|
1480
2292
|
sessionUpdate: "plan",
|
|
1481
2293
|
entries: planEntries(chunk.input),
|
|
1482
2294
|
};
|
|
1483
2295
|
}
|
|
1484
2296
|
}
|
|
2297
|
+
else if (chunk.name === "TaskCreate" ||
|
|
2298
|
+
chunk.name === "TaskUpdate" ||
|
|
2299
|
+
chunk.name === "TaskList" ||
|
|
2300
|
+
chunk.name === "TaskGet") {
|
|
2301
|
+
// Task* tool_use is suppressed; the plan update is emitted at
|
|
2302
|
+
// tool_result time once we have the task ID (for TaskCreate) and
|
|
2303
|
+
// confirmation that the change took effect.
|
|
2304
|
+
}
|
|
1485
2305
|
else {
|
|
1486
2306
|
// Only register hooks on first encounter to avoid double-firing
|
|
1487
2307
|
if (registerHooks && !alreadyCached) {
|
|
@@ -1489,7 +2309,17 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
1489
2309
|
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
|
1490
2310
|
const toolUse = toolUseCache[toolUseId];
|
|
1491
2311
|
if (toolUse) {
|
|
1492
|
-
|
|
2312
|
+
// Both `Edit` and `Write` produce a structuredPatch in their
|
|
2313
|
+
// PostToolUse tool_response. For Edit the diff replaces the
|
|
2314
|
+
// optimistic content built at tool_use time. For Write the
|
|
2315
|
+
// optimistic content (built from `input.content` alone with
|
|
2316
|
+
// `oldText: null`) shows "creation" semantics regardless of
|
|
2317
|
+
// whether the file existed; the structuredPatch from the
|
|
2318
|
+
// hook lets us emit the real diff for `type: "update"`. The
|
|
2319
|
+
// helper returns `{}` if the response shape isn't usable.
|
|
2320
|
+
const editDiff = toolUse.name === "Edit" || toolUse.name === "Write"
|
|
2321
|
+
? toolUpdateFromDiffToolResponse(toolResponse)
|
|
2322
|
+
: {};
|
|
1493
2323
|
const update = {
|
|
1494
2324
|
_meta: {
|
|
1495
2325
|
claudeCode: {
|
|
@@ -1570,7 +2400,32 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
1570
2400
|
logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
|
|
1571
2401
|
break;
|
|
1572
2402
|
}
|
|
1573
|
-
if (toolUse.name
|
|
2403
|
+
if (toolUse.name === "TaskCreate" ||
|
|
2404
|
+
toolUse.name === "TaskUpdate" ||
|
|
2405
|
+
toolUse.name === "TaskList" ||
|
|
2406
|
+
toolUse.name === "TaskGet") {
|
|
2407
|
+
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
|
2408
|
+
// TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
|
|
2409
|
+
// and TaskGet are read-only so we just suppress their tool_call /
|
|
2410
|
+
// tool_result events. The plan update is emitted as a snapshot of
|
|
2411
|
+
// the accumulated state, mirroring the legacy TodoWrite behavior.
|
|
2412
|
+
const isError = "is_error" in chunk && chunk.is_error;
|
|
2413
|
+
if (!isError) {
|
|
2414
|
+
if (toolUse.name === "TaskCreate") {
|
|
2415
|
+
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
|
|
2416
|
+
}
|
|
2417
|
+
else if (toolUse.name === "TaskUpdate") {
|
|
2418
|
+
applyTaskUpdate(taskState, toolUse.input);
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
|
|
2422
|
+
update = {
|
|
2423
|
+
sessionUpdate: "plan",
|
|
2424
|
+
entries: taskStateToPlanEntries(taskState),
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
else if (toolUse.name !== "TodoWrite") {
|
|
1574
2429
|
const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
|
|
1575
2430
|
// When terminal output is supported, send terminal_output as a
|
|
1576
2431
|
// separate notification to match codex-acp's streaming lifecycle:
|
|
@@ -1617,6 +2472,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
1617
2472
|
case "container_upload":
|
|
1618
2473
|
case "compaction":
|
|
1619
2474
|
case "compaction_delta":
|
|
2475
|
+
case "advisor_tool_result":
|
|
1620
2476
|
break;
|
|
1621
2477
|
default:
|
|
1622
2478
|
unreachable(chunk, logger);
|
|
@@ -1645,14 +2501,20 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
1645
2501
|
clientCapabilities: options?.clientCapabilities,
|
|
1646
2502
|
parentToolUseId: message.parent_tool_use_id,
|
|
1647
2503
|
cwd: options?.cwd,
|
|
2504
|
+
taskState: options?.taskState,
|
|
1648
2505
|
});
|
|
1649
2506
|
case "content_block_delta":
|
|
1650
2507
|
return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
|
|
1651
2508
|
clientCapabilities: options?.clientCapabilities,
|
|
1652
2509
|
parentToolUseId: message.parent_tool_use_id,
|
|
1653
2510
|
cwd: options?.cwd,
|
|
2511
|
+
taskState: options?.taskState,
|
|
1654
2512
|
});
|
|
1655
|
-
// No content
|
|
2513
|
+
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|
|
2514
|
+
// `BetaRawMessageStreamEvent` union doesn't include even though the
|
|
2515
|
+
// wire format emits it; the `as never` cast lets us no-op it here
|
|
2516
|
+
// instead of letting it fall through to `unreachable`.
|
|
2517
|
+
case "ping":
|
|
1656
2518
|
case "message_start":
|
|
1657
2519
|
case "message_delta":
|
|
1658
2520
|
case "message_stop":
|
|
@@ -1667,7 +2529,12 @@ export function runAcp() {
|
|
|
1667
2529
|
const input = nodeToWebWritable(process.stdout);
|
|
1668
2530
|
const output = nodeToWebReadable(process.stdin);
|
|
1669
2531
|
const stream = ndJsonStream(input, output);
|
|
1670
|
-
|
|
2532
|
+
let agent;
|
|
2533
|
+
const connection = new AgentSideConnection((client) => {
|
|
2534
|
+
agent = new ClaudeAcpAgent(client);
|
|
2535
|
+
return agent;
|
|
2536
|
+
}, stream);
|
|
2537
|
+
return { connection, agent };
|
|
1671
2538
|
}
|
|
1672
2539
|
function commonPrefixLength(a, b) {
|
|
1673
2540
|
let i = 0;
|
|
@@ -1676,6 +2543,30 @@ function commonPrefixLength(a, b) {
|
|
|
1676
2543
|
}
|
|
1677
2544
|
return i;
|
|
1678
2545
|
}
|
|
2546
|
+
/** Best-effort first guess of a model's context window from its ID, used only
|
|
2547
|
+
* until a `result` message arrives with the authoritative `modelUsage` value.
|
|
2548
|
+
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
|
2549
|
+
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
|
2550
|
+
* matching things like "10m" or embedded substrings. */
|
|
2551
|
+
function inferContextWindowFromModel(model) {
|
|
2552
|
+
if (/\b1m\b/i.test(model))
|
|
2553
|
+
return 1_000_000;
|
|
2554
|
+
return null;
|
|
2555
|
+
}
|
|
2556
|
+
function parseModelConfig(raw) {
|
|
2557
|
+
if (!raw)
|
|
2558
|
+
return undefined;
|
|
2559
|
+
const parsed = JSON.parse(raw);
|
|
2560
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2561
|
+
throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object");
|
|
2562
|
+
}
|
|
2563
|
+
const result = {};
|
|
2564
|
+
if (parsed.modelOverrides !== undefined)
|
|
2565
|
+
result.modelOverrides = parsed.modelOverrides;
|
|
2566
|
+
if (parsed.availableModels !== undefined)
|
|
2567
|
+
result.availableModels = parsed.availableModels;
|
|
2568
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
2569
|
+
}
|
|
1679
2570
|
function getMatchingModelUsage(modelUsage, currentModel) {
|
|
1680
2571
|
let bestKey = null;
|
|
1681
2572
|
let bestLen = 0;
|