@agentclientprotocol/claude-agent-acp 0.24.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/LICENSE +191 -0
- package/README.md +25 -0
- package/dist/acp-agent.d.ts +171 -0
- package/dist/acp-agent.d.ts.map +1 -0
- package/dist/acp-agent.js +1633 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +5 -0
- package/dist/settings.d.ts +106 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +222 -0
- package/dist/static-entry.d.ts +2 -0
- package/dist/static-entry.d.ts.map +1 -0
- package/dist/static-entry.js +9 -0
- package/dist/tools.d.ts +64 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +569 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +100 -0
- package/package.json +81 -0
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
|
+
import { getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
8
|
+
import { SettingsManager } from "./settings.js";
|
|
9
|
+
import { createPostToolUseHook, planEntries, registerHookCallback, toolInfoFromToolUse, toolUpdateFromEditToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
10
|
+
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
11
|
+
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
12
|
+
const MAX_TITLE_LENGTH = 256;
|
|
13
|
+
function sanitizeTitle(text) {
|
|
14
|
+
// Replace newlines and collapse whitespace
|
|
15
|
+
const sanitized = text
|
|
16
|
+
.replace(/[\r\n]+/g, " ")
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim();
|
|
19
|
+
if (sanitized.length <= MAX_TITLE_LENGTH) {
|
|
20
|
+
return sanitized;
|
|
21
|
+
}
|
|
22
|
+
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
|
23
|
+
}
|
|
24
|
+
function isStaticBinary() {
|
|
25
|
+
return process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN !== undefined;
|
|
26
|
+
}
|
|
27
|
+
export async function claudeCliPath() {
|
|
28
|
+
return isStaticBinary()
|
|
29
|
+
? (await import("@anthropic-ai/claude-agent-sdk/embed")).default
|
|
30
|
+
: import.meta.resolve("@anthropic-ai/claude-agent-sdk").replace("sdk.mjs", "cli.js");
|
|
31
|
+
}
|
|
32
|
+
function shouldHideClaudeAuth() {
|
|
33
|
+
return process.argv.includes("--hide-claude-auth");
|
|
34
|
+
}
|
|
35
|
+
// Bypass Permissions doesn't work if we are a root/sudo user
|
|
36
|
+
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
|
37
|
+
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
|
38
|
+
// Slash commands that the SDK handles locally without replaying the user
|
|
39
|
+
// message and without invoking the model.
|
|
40
|
+
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
|
41
|
+
const PERMISSION_MODE_ALIASES = {
|
|
42
|
+
default: "default",
|
|
43
|
+
acceptedits: "acceptEdits",
|
|
44
|
+
dontask: "dontAsk",
|
|
45
|
+
plan: "plan",
|
|
46
|
+
bypasspermissions: "bypassPermissions",
|
|
47
|
+
bypass: "bypassPermissions",
|
|
48
|
+
};
|
|
49
|
+
export function resolvePermissionMode(defaultMode) {
|
|
50
|
+
if (defaultMode === undefined) {
|
|
51
|
+
return "default";
|
|
52
|
+
}
|
|
53
|
+
if (typeof defaultMode !== "string") {
|
|
54
|
+
throw new Error("Invalid permissions.defaultMode: expected a string.");
|
|
55
|
+
}
|
|
56
|
+
const normalized = defaultMode.trim().toLowerCase();
|
|
57
|
+
if (normalized === "") {
|
|
58
|
+
throw new Error("Invalid permissions.defaultMode: expected a non-empty string.");
|
|
59
|
+
}
|
|
60
|
+
const mapped = PERMISSION_MODE_ALIASES[normalized];
|
|
61
|
+
if (!mapped) {
|
|
62
|
+
throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`);
|
|
63
|
+
}
|
|
64
|
+
if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
|
|
65
|
+
throw new Error("Invalid permissions.defaultMode: bypassPermissions is not available when running as root.");
|
|
66
|
+
}
|
|
67
|
+
return mapped;
|
|
68
|
+
}
|
|
69
|
+
// Implement the ACP Agent interface
|
|
70
|
+
export class ClaudeAcpAgent {
|
|
71
|
+
constructor(client, logger) {
|
|
72
|
+
this.backgroundTerminals = {};
|
|
73
|
+
this.sessions = {};
|
|
74
|
+
this.client = client;
|
|
75
|
+
this.toolUseCache = {};
|
|
76
|
+
this.logger = logger ?? console;
|
|
77
|
+
}
|
|
78
|
+
async initialize(request) {
|
|
79
|
+
this.clientCapabilities = request.clientCapabilities;
|
|
80
|
+
// Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
|
|
81
|
+
// Only offered when the client advertises `auth._meta.gateway` capability.
|
|
82
|
+
const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
|
|
83
|
+
const gatewayAuthMethod = {
|
|
84
|
+
id: "gateway",
|
|
85
|
+
name: "Custom model gateway",
|
|
86
|
+
description: "Use a custom gateway to authenticate and access models",
|
|
87
|
+
_meta: {
|
|
88
|
+
gateway: {
|
|
89
|
+
protocol: "anthropic",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const terminalAuthMethod = {
|
|
94
|
+
description: "Run `claude /login` in the terminal",
|
|
95
|
+
name: "Log in with Claude",
|
|
96
|
+
id: "claude-login",
|
|
97
|
+
type: "terminal",
|
|
98
|
+
args: ["--cli"],
|
|
99
|
+
};
|
|
100
|
+
const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
|
|
101
|
+
// If client supports terminal-auth capability, use that instead.
|
|
102
|
+
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
|
103
|
+
if (supportsMetaTerminalAuth) {
|
|
104
|
+
terminalAuthMethod._meta = {
|
|
105
|
+
"terminal-auth": {
|
|
106
|
+
command: process.execPath,
|
|
107
|
+
args: [...process.argv.slice(1), "--cli"],
|
|
108
|
+
label: "Claude Login",
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
protocolVersion: 1,
|
|
114
|
+
agentCapabilities: {
|
|
115
|
+
_meta: {
|
|
116
|
+
claudeCode: {
|
|
117
|
+
promptQueueing: true,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
promptCapabilities: {
|
|
121
|
+
image: true,
|
|
122
|
+
embeddedContext: true,
|
|
123
|
+
},
|
|
124
|
+
mcpCapabilities: {
|
|
125
|
+
http: true,
|
|
126
|
+
sse: true,
|
|
127
|
+
},
|
|
128
|
+
loadSession: true,
|
|
129
|
+
sessionCapabilities: {
|
|
130
|
+
fork: {},
|
|
131
|
+
list: {},
|
|
132
|
+
resume: {},
|
|
133
|
+
close: {},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
agentInfo: {
|
|
137
|
+
name: packageJson.name,
|
|
138
|
+
title: "Claude Agent",
|
|
139
|
+
version: packageJson.version,
|
|
140
|
+
},
|
|
141
|
+
authMethods: [
|
|
142
|
+
...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
|
|
143
|
+
? [terminalAuthMethod]
|
|
144
|
+
: []),
|
|
145
|
+
...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
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
|
+
const response = await this.createSession(params, {
|
|
156
|
+
// Revisit these meta values once we support resume
|
|
157
|
+
resume: params._meta?.claudeCode?.options?.resume,
|
|
158
|
+
});
|
|
159
|
+
// Needs to happen after we return the session
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
this.sendAvailableCommandsUpdate(response.sessionId);
|
|
162
|
+
}, 0);
|
|
163
|
+
return response;
|
|
164
|
+
}
|
|
165
|
+
async unstable_forkSession(params) {
|
|
166
|
+
const response = await this.createSession({
|
|
167
|
+
cwd: params.cwd,
|
|
168
|
+
mcpServers: params.mcpServers ?? [],
|
|
169
|
+
_meta: params._meta,
|
|
170
|
+
}, {
|
|
171
|
+
resume: params.sessionId,
|
|
172
|
+
forkSession: true,
|
|
173
|
+
});
|
|
174
|
+
// Needs to happen after we return the session
|
|
175
|
+
setTimeout(() => {
|
|
176
|
+
this.sendAvailableCommandsUpdate(response.sessionId);
|
|
177
|
+
}, 0);
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
async unstable_resumeSession(params) {
|
|
181
|
+
const result = await this.getOrCreateSession(params);
|
|
182
|
+
// Needs to happen after we return the session
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
this.sendAvailableCommandsUpdate(params.sessionId);
|
|
185
|
+
}, 0);
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
async loadSession(params) {
|
|
189
|
+
const result = await this.getOrCreateSession(params);
|
|
190
|
+
await this.replaySessionHistory(params.sessionId);
|
|
191
|
+
// Send available commands after replay so it doesn't interleave with history
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
this.sendAvailableCommandsUpdate(params.sessionId);
|
|
194
|
+
}, 0);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
async listSessions(params) {
|
|
198
|
+
const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
|
|
199
|
+
const sessions = [];
|
|
200
|
+
for (const session of sdk_sessions) {
|
|
201
|
+
if (!session.cwd)
|
|
202
|
+
continue;
|
|
203
|
+
sessions.push({
|
|
204
|
+
sessionId: session.sessionId,
|
|
205
|
+
cwd: session.cwd,
|
|
206
|
+
title: sanitizeTitle(session.summary),
|
|
207
|
+
updatedAt: new Date(session.lastModified).toISOString(),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
sessions,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async authenticate(_params) {
|
|
215
|
+
if (_params.methodId === "gateway") {
|
|
216
|
+
this.gatewayAuthMeta = _params._meta;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
throw new Error("Method not implemented.");
|
|
220
|
+
}
|
|
221
|
+
async prompt(params) {
|
|
222
|
+
const session = this.sessions[params.sessionId];
|
|
223
|
+
if (!session) {
|
|
224
|
+
throw new Error("Session not found");
|
|
225
|
+
}
|
|
226
|
+
session.cancelled = false;
|
|
227
|
+
session.accumulatedUsage = {
|
|
228
|
+
inputTokens: 0,
|
|
229
|
+
outputTokens: 0,
|
|
230
|
+
cachedReadTokens: 0,
|
|
231
|
+
cachedWriteTokens: 0,
|
|
232
|
+
};
|
|
233
|
+
let lastAssistantTotalUsage = null;
|
|
234
|
+
const userMessage = promptToClaude(params);
|
|
235
|
+
const promptUuid = randomUUID();
|
|
236
|
+
userMessage.uuid = promptUuid;
|
|
237
|
+
let promptReplayed = false;
|
|
238
|
+
// These local-only commands return a result without replaying the user
|
|
239
|
+
// message. Mark promptReplayed=true so their result isn't consumed as a
|
|
240
|
+
// background task result.
|
|
241
|
+
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
242
|
+
const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
|
243
|
+
if (isLocalOnlyCommand) {
|
|
244
|
+
promptReplayed = true;
|
|
245
|
+
}
|
|
246
|
+
if (session.promptRunning) {
|
|
247
|
+
session.input.push(userMessage);
|
|
248
|
+
const order = session.nextPendingOrder++;
|
|
249
|
+
const cancelled = await new Promise((resolve) => {
|
|
250
|
+
session.pendingMessages.set(promptUuid, { resolve, order });
|
|
251
|
+
});
|
|
252
|
+
if (cancelled) {
|
|
253
|
+
return { stopReason: "cancelled" };
|
|
254
|
+
}
|
|
255
|
+
// The replay resolved the promise, mark in this loop too,
|
|
256
|
+
// so we don't treat the next result as a background task's result.
|
|
257
|
+
promptReplayed = true;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
session.input.push(userMessage);
|
|
261
|
+
}
|
|
262
|
+
session.promptRunning = true;
|
|
263
|
+
let handedOff = false;
|
|
264
|
+
try {
|
|
265
|
+
while (true) {
|
|
266
|
+
const { value: message, done } = await session.query.next();
|
|
267
|
+
if (done || !message) {
|
|
268
|
+
if (session.cancelled) {
|
|
269
|
+
return { stopReason: "cancelled" };
|
|
270
|
+
}
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
switch (message.type) {
|
|
274
|
+
case "system":
|
|
275
|
+
switch (message.subtype) {
|
|
276
|
+
case "init":
|
|
277
|
+
break;
|
|
278
|
+
case "status": {
|
|
279
|
+
if (message.status === "compacting") {
|
|
280
|
+
await this.client.sessionUpdate({
|
|
281
|
+
sessionId: message.session_id,
|
|
282
|
+
update: {
|
|
283
|
+
sessionUpdate: "agent_message_chunk",
|
|
284
|
+
content: { type: "text", text: "Compacting..." },
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case "compact_boundary": {
|
|
291
|
+
// We don't know the exact size, but since we compacted,
|
|
292
|
+
// we set it to zero. The client gets the exact size on the next message.
|
|
293
|
+
lastAssistantTotalUsage = 0;
|
|
294
|
+
await this.client.sessionUpdate({
|
|
295
|
+
sessionId: message.session_id,
|
|
296
|
+
update: {
|
|
297
|
+
sessionUpdate: "agent_message_chunk",
|
|
298
|
+
content: { type: "text", text: "\n\nCompacting completed." },
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
promptReplayed = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
case "local_command_output": {
|
|
305
|
+
await this.client.sessionUpdate({
|
|
306
|
+
sessionId: message.session_id,
|
|
307
|
+
update: {
|
|
308
|
+
sessionUpdate: "agent_message_chunk",
|
|
309
|
+
content: { type: "text", text: message.content },
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
promptReplayed = true;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "session_state_changed": {
|
|
316
|
+
if (message.state === "idle") {
|
|
317
|
+
return { stopReason: "end_turn", usage: sessionUsage(session) };
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "hook_started":
|
|
322
|
+
case "hook_progress":
|
|
323
|
+
case "hook_response":
|
|
324
|
+
case "files_persisted":
|
|
325
|
+
case "task_started":
|
|
326
|
+
case "task_notification":
|
|
327
|
+
case "task_progress":
|
|
328
|
+
case "elicitation_complete":
|
|
329
|
+
case "api_retry":
|
|
330
|
+
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
331
|
+
break;
|
|
332
|
+
default:
|
|
333
|
+
unreachable(message, this.logger);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
break;
|
|
337
|
+
case "result": {
|
|
338
|
+
// Accumulate usage from this result
|
|
339
|
+
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
|
|
340
|
+
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
|
|
341
|
+
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
|
342
|
+
session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens;
|
|
343
|
+
// Calculate context window size from modelUsage (minimum across all models used)
|
|
344
|
+
const contextWindows = Object.values(message.modelUsage).map((m) => m.contextWindow);
|
|
345
|
+
const contextWindowSize = contextWindows.length > 0 ? Math.min(...contextWindows) : 200000;
|
|
346
|
+
// Send usage_update notification
|
|
347
|
+
if (lastAssistantTotalUsage !== null) {
|
|
348
|
+
await this.client.sessionUpdate({
|
|
349
|
+
sessionId: params.sessionId,
|
|
350
|
+
update: {
|
|
351
|
+
sessionUpdate: "usage_update",
|
|
352
|
+
used: lastAssistantTotalUsage,
|
|
353
|
+
size: contextWindowSize,
|
|
354
|
+
cost: {
|
|
355
|
+
amount: message.total_cost_usd,
|
|
356
|
+
currency: "USD",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
// Check cancelled before promptReplayed — when a cancel races
|
|
362
|
+
// with the first result, promptReplayed is still false and the
|
|
363
|
+
// result would be consumed as a background task, blocking the
|
|
364
|
+
// loop forever (see #442).
|
|
365
|
+
if (session.cancelled) {
|
|
366
|
+
return { stopReason: "cancelled" };
|
|
367
|
+
}
|
|
368
|
+
if (!promptReplayed) {
|
|
369
|
+
// This result is from a background task that finished after
|
|
370
|
+
// the previous prompt loop ended. Consume it and continue
|
|
371
|
+
// waiting for our own prompt's result.
|
|
372
|
+
this.logger.log(`Session ${params.sessionId}: consuming background task result`);
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
// Build the usage response
|
|
376
|
+
const usage = sessionUsage(session);
|
|
377
|
+
switch (message.subtype) {
|
|
378
|
+
case "success": {
|
|
379
|
+
if (message.result.includes("Please run /login")) {
|
|
380
|
+
throw RequestError.authRequired();
|
|
381
|
+
}
|
|
382
|
+
if (message.stop_reason === "max_tokens") {
|
|
383
|
+
return { stopReason: "max_tokens", usage };
|
|
384
|
+
}
|
|
385
|
+
if (message.is_error) {
|
|
386
|
+
throw RequestError.internalError(undefined, message.result);
|
|
387
|
+
}
|
|
388
|
+
// For local-only commands (no model invocation), the result
|
|
389
|
+
// text is the command output — forward it to the client.
|
|
390
|
+
if (isLocalOnlyCommand) {
|
|
391
|
+
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, this.toolUseCache, this.client, this.logger)) {
|
|
392
|
+
await this.client.sessionUpdate(notification);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "error_during_execution": {
|
|
398
|
+
if (message.stop_reason === "max_tokens") {
|
|
399
|
+
return { stopReason: "max_tokens", usage };
|
|
400
|
+
}
|
|
401
|
+
if (message.is_error) {
|
|
402
|
+
throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
|
|
403
|
+
}
|
|
404
|
+
return { stopReason: "end_turn", usage: sessionUsage(session) };
|
|
405
|
+
}
|
|
406
|
+
case "error_max_budget_usd":
|
|
407
|
+
case "error_max_turns":
|
|
408
|
+
case "error_max_structured_output_retries":
|
|
409
|
+
if (message.is_error) {
|
|
410
|
+
throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
|
|
411
|
+
}
|
|
412
|
+
return { stopReason: "max_turn_requests", usage };
|
|
413
|
+
default:
|
|
414
|
+
unreachable(message, this.logger);
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case "stream_event": {
|
|
420
|
+
for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.client, this.logger, {
|
|
421
|
+
clientCapabilities: this.clientCapabilities,
|
|
422
|
+
cwd: session.cwd,
|
|
423
|
+
})) {
|
|
424
|
+
await this.client.sessionUpdate(notification);
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
case "user":
|
|
429
|
+
case "assistant": {
|
|
430
|
+
if (session.cancelled) {
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
// Check for prompt replay
|
|
434
|
+
if (message.type === "user" && "uuid" in message && message.uuid) {
|
|
435
|
+
if (message.uuid === promptUuid) {
|
|
436
|
+
// Our own prompt was replayed back — we're now processing
|
|
437
|
+
// our prompt's response (not a background task's).
|
|
438
|
+
promptReplayed = true;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
const pending = session.pendingMessages.get(message.uuid);
|
|
442
|
+
if (pending) {
|
|
443
|
+
pending.resolve(false);
|
|
444
|
+
session.pendingMessages.delete(message.uuid);
|
|
445
|
+
handedOff = true;
|
|
446
|
+
// the current loop stops with end_turn,
|
|
447
|
+
// the loop of the next prompt continues running
|
|
448
|
+
return { stopReason: "end_turn", usage: sessionUsage(session) };
|
|
449
|
+
}
|
|
450
|
+
if ("isReplay" in message && message.isReplay) {
|
|
451
|
+
// not pending or unrelated replay message
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// Store latest assistant usage (excluding subagents)
|
|
456
|
+
if (message.message.usage && message.parent_tool_use_id === null) {
|
|
457
|
+
const messageWithUsage = message.message;
|
|
458
|
+
lastAssistantTotalUsage =
|
|
459
|
+
messageWithUsage.usage.input_tokens +
|
|
460
|
+
messageWithUsage.usage.output_tokens +
|
|
461
|
+
messageWithUsage.usage.cache_read_input_tokens +
|
|
462
|
+
messageWithUsage.usage.cache_creation_input_tokens;
|
|
463
|
+
}
|
|
464
|
+
// Slash commands like /compact can generate invalid output... doesn't match
|
|
465
|
+
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
|
|
466
|
+
if (typeof message.message.content === "string" &&
|
|
467
|
+
message.message.content.includes("<local-command-stdout>")) {
|
|
468
|
+
this.logger.log(message.message.content);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
if (typeof message.message.content === "string" &&
|
|
472
|
+
message.message.content.includes("<local-command-stderr>")) {
|
|
473
|
+
this.logger.error(message.message.content);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
|
|
477
|
+
if (message.type === "user" &&
|
|
478
|
+
(typeof message.message.content === "string" ||
|
|
479
|
+
(Array.isArray(message.message.content) &&
|
|
480
|
+
message.message.content.length === 1 &&
|
|
481
|
+
message.message.content[0].type === "text"))) {
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
if (message.type === "assistant" &&
|
|
485
|
+
message.message.model === "<synthetic>" &&
|
|
486
|
+
Array.isArray(message.message.content) &&
|
|
487
|
+
message.message.content.length === 1 &&
|
|
488
|
+
message.message.content[0].type === "text" &&
|
|
489
|
+
message.message.content[0].text.includes("Please run /login")) {
|
|
490
|
+
throw RequestError.authRequired();
|
|
491
|
+
}
|
|
492
|
+
const content = message.type === "assistant"
|
|
493
|
+
? // Handled by stream events above
|
|
494
|
+
message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
|
|
495
|
+
: message.message.content;
|
|
496
|
+
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger, {
|
|
497
|
+
clientCapabilities: this.clientCapabilities,
|
|
498
|
+
parentToolUseId: message.parent_tool_use_id,
|
|
499
|
+
cwd: session.cwd,
|
|
500
|
+
})) {
|
|
501
|
+
await this.client.sessionUpdate(notification);
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case "tool_progress":
|
|
506
|
+
case "tool_use_summary":
|
|
507
|
+
case "auth_status":
|
|
508
|
+
case "prompt_suggestion":
|
|
509
|
+
case "rate_limit_event":
|
|
510
|
+
break;
|
|
511
|
+
default:
|
|
512
|
+
unreachable(message);
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
throw new Error("Session did not end in result");
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
if (error instanceof RequestError || !(error instanceof Error)) {
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
const message = error.message;
|
|
523
|
+
if (message.includes("ProcessTransport") ||
|
|
524
|
+
message.includes("terminated process") ||
|
|
525
|
+
message.includes("process exited with") ||
|
|
526
|
+
message.includes("process terminated by signal") ||
|
|
527
|
+
message.includes("Failed to write to process stdin")) {
|
|
528
|
+
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
|
|
529
|
+
session.settingsManager.dispose();
|
|
530
|
+
session.input.end();
|
|
531
|
+
delete this.sessions[params.sessionId];
|
|
532
|
+
throw RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session.");
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
if (!handedOff) {
|
|
538
|
+
session.promptRunning = false;
|
|
539
|
+
// This usually should not happen, but in case the loop finishes
|
|
540
|
+
// without claude sending all message replays, we resolve the
|
|
541
|
+
// next pending prompt call to ensure no prompts get stuck.
|
|
542
|
+
if (session.pendingMessages.size > 0) {
|
|
543
|
+
const next = [...session.pendingMessages.entries()].sort((a, b) => a[1].order - b[1].order)[0];
|
|
544
|
+
if (next) {
|
|
545
|
+
next[1].resolve(false);
|
|
546
|
+
session.pendingMessages.delete(next[0]);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async cancel(params) {
|
|
553
|
+
const session = this.sessions[params.sessionId];
|
|
554
|
+
if (!session) {
|
|
555
|
+
throw new Error("Session not found");
|
|
556
|
+
}
|
|
557
|
+
session.cancelled = true;
|
|
558
|
+
for (const [, pending] of session.pendingMessages) {
|
|
559
|
+
pending.resolve(true);
|
|
560
|
+
}
|
|
561
|
+
session.pendingMessages.clear();
|
|
562
|
+
await session.query.interrupt();
|
|
563
|
+
}
|
|
564
|
+
async unstable_closeSession(params) {
|
|
565
|
+
const session = this.sessions[params.sessionId];
|
|
566
|
+
if (!session) {
|
|
567
|
+
throw new Error("Session not found");
|
|
568
|
+
}
|
|
569
|
+
await this.cancel({ sessionId: params.sessionId });
|
|
570
|
+
session.settingsManager.dispose();
|
|
571
|
+
session.abortController.abort();
|
|
572
|
+
delete this.sessions[params.sessionId];
|
|
573
|
+
return {};
|
|
574
|
+
}
|
|
575
|
+
async unstable_setSessionModel(params) {
|
|
576
|
+
if (!this.sessions[params.sessionId]) {
|
|
577
|
+
throw new Error("Session not found");
|
|
578
|
+
}
|
|
579
|
+
await this.sessions[params.sessionId].query.setModel(params.modelId);
|
|
580
|
+
await this.updateConfigOption(params.sessionId, "model", params.modelId);
|
|
581
|
+
}
|
|
582
|
+
async setSessionMode(params) {
|
|
583
|
+
if (!this.sessions[params.sessionId]) {
|
|
584
|
+
throw new Error("Session not found");
|
|
585
|
+
}
|
|
586
|
+
await this.applySessionMode(params.sessionId, params.modeId);
|
|
587
|
+
await this.updateConfigOption(params.sessionId, "mode", params.modeId);
|
|
588
|
+
return {};
|
|
589
|
+
}
|
|
590
|
+
async setSessionConfigOption(params) {
|
|
591
|
+
const session = this.sessions[params.sessionId];
|
|
592
|
+
if (!session) {
|
|
593
|
+
throw new Error("Session not found");
|
|
594
|
+
}
|
|
595
|
+
if (typeof params.value !== "string") {
|
|
596
|
+
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
|
597
|
+
}
|
|
598
|
+
const option = session.configOptions.find((o) => o.id === params.configId);
|
|
599
|
+
if (!option) {
|
|
600
|
+
throw new Error(`Unknown config option: ${params.configId}`);
|
|
601
|
+
}
|
|
602
|
+
const allValues = "options" in option && Array.isArray(option.options)
|
|
603
|
+
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
|
|
604
|
+
: [];
|
|
605
|
+
let validValue = allValues.find((o) => o.value === params.value);
|
|
606
|
+
// For model options, fall back to resolveModelPreference when the exact
|
|
607
|
+
// value doesn't match. This lets callers use human-friendly aliases like
|
|
608
|
+
// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
|
|
609
|
+
if (!validValue && params.configId === "model") {
|
|
610
|
+
const modelInfos = allValues.map((o) => ({
|
|
611
|
+
value: o.value,
|
|
612
|
+
displayName: o.name,
|
|
613
|
+
description: o.description ?? "",
|
|
614
|
+
}));
|
|
615
|
+
const resolved = resolveModelPreference(modelInfos, params.value);
|
|
616
|
+
if (resolved) {
|
|
617
|
+
validValue = allValues.find((o) => o.value === resolved.value);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (!validValue) {
|
|
621
|
+
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
|
622
|
+
}
|
|
623
|
+
// Use the canonical option value so downstream code always receives the
|
|
624
|
+
// model ID rather than the caller-supplied alias.
|
|
625
|
+
const resolvedValue = validValue.value;
|
|
626
|
+
if (params.configId === "mode") {
|
|
627
|
+
await this.applySessionMode(params.sessionId, resolvedValue);
|
|
628
|
+
await this.client.sessionUpdate({
|
|
629
|
+
sessionId: params.sessionId,
|
|
630
|
+
update: {
|
|
631
|
+
sessionUpdate: "current_mode_update",
|
|
632
|
+
currentModeId: resolvedValue,
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
else if (params.configId === "model") {
|
|
637
|
+
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
|
638
|
+
}
|
|
639
|
+
this.syncSessionConfigState(session, params.configId, params.value);
|
|
640
|
+
session.configOptions = session.configOptions.map((o) => o.id === params.configId && typeof o.currentValue === "string"
|
|
641
|
+
? { ...o, currentValue: resolvedValue }
|
|
642
|
+
: o);
|
|
643
|
+
return { configOptions: session.configOptions };
|
|
644
|
+
}
|
|
645
|
+
async applySessionMode(sessionId, modeId) {
|
|
646
|
+
switch (modeId) {
|
|
647
|
+
case "default":
|
|
648
|
+
case "acceptEdits":
|
|
649
|
+
case "bypassPermissions":
|
|
650
|
+
case "dontAsk":
|
|
651
|
+
case "plan":
|
|
652
|
+
break;
|
|
653
|
+
default:
|
|
654
|
+
throw new Error("Invalid Mode");
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
await this.sessions[sessionId].query.setPermissionMode(modeId);
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (error instanceof Error) {
|
|
661
|
+
if (!error.message) {
|
|
662
|
+
error.message = "Invalid Mode";
|
|
663
|
+
}
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
// eslint-disable-next-line preserve-caught-error
|
|
668
|
+
throw new Error("Invalid Mode");
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async replaySessionHistory(sessionId) {
|
|
673
|
+
const toolUseCache = {};
|
|
674
|
+
const messages = await getSessionMessages(sessionId);
|
|
675
|
+
for (const message of messages) {
|
|
676
|
+
for (const notification of toAcpNotifications(
|
|
677
|
+
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
678
|
+
message.message.content,
|
|
679
|
+
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
680
|
+
message.message.role, sessionId, toolUseCache, this.client, this.logger, {
|
|
681
|
+
registerHooks: false,
|
|
682
|
+
clientCapabilities: this.clientCapabilities,
|
|
683
|
+
cwd: this.sessions[sessionId]?.cwd,
|
|
684
|
+
})) {
|
|
685
|
+
await this.client.sessionUpdate(notification);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async readTextFile(params) {
|
|
690
|
+
const response = await this.client.readTextFile(params);
|
|
691
|
+
return response;
|
|
692
|
+
}
|
|
693
|
+
async writeTextFile(params) {
|
|
694
|
+
const response = await this.client.writeTextFile(params);
|
|
695
|
+
return response;
|
|
696
|
+
}
|
|
697
|
+
canUseTool(sessionId) {
|
|
698
|
+
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
|
699
|
+
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
700
|
+
const session = this.sessions[sessionId];
|
|
701
|
+
if (!session) {
|
|
702
|
+
return {
|
|
703
|
+
behavior: "deny",
|
|
704
|
+
message: "Session not found",
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
if (toolName === "ExitPlanMode") {
|
|
708
|
+
const options = [
|
|
709
|
+
{
|
|
710
|
+
kind: "allow_always",
|
|
711
|
+
name: "Yes, and auto-accept edits",
|
|
712
|
+
optionId: "acceptEdits",
|
|
713
|
+
},
|
|
714
|
+
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
|
|
715
|
+
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
|
716
|
+
];
|
|
717
|
+
if (ALLOW_BYPASS) {
|
|
718
|
+
options.unshift({
|
|
719
|
+
kind: "allow_always",
|
|
720
|
+
name: "Yes, and bypass permissions",
|
|
721
|
+
optionId: "bypassPermissions",
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
const response = await this.client.requestPermission({
|
|
725
|
+
options,
|
|
726
|
+
sessionId,
|
|
727
|
+
toolCall: {
|
|
728
|
+
toolCallId: toolUseID,
|
|
729
|
+
rawInput: toolInput,
|
|
730
|
+
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
734
|
+
throw new Error("Tool use aborted");
|
|
735
|
+
}
|
|
736
|
+
if (response.outcome?.outcome === "selected" &&
|
|
737
|
+
(response.outcome.optionId === "default" ||
|
|
738
|
+
response.outcome.optionId === "acceptEdits" ||
|
|
739
|
+
response.outcome.optionId === "bypassPermissions")) {
|
|
740
|
+
await this.client.sessionUpdate({
|
|
741
|
+
sessionId,
|
|
742
|
+
update: {
|
|
743
|
+
sessionUpdate: "current_mode_update",
|
|
744
|
+
currentModeId: response.outcome.optionId,
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
await this.updateConfigOption(sessionId, "mode", response.outcome.optionId);
|
|
748
|
+
return {
|
|
749
|
+
behavior: "allow",
|
|
750
|
+
updatedInput: toolInput,
|
|
751
|
+
updatedPermissions: suggestions ?? [
|
|
752
|
+
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
|
753
|
+
],
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
return {
|
|
758
|
+
behavior: "deny",
|
|
759
|
+
message: "User rejected request to exit plan mode.",
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (session.modes.currentModeId === "bypassPermissions") {
|
|
764
|
+
return {
|
|
765
|
+
behavior: "allow",
|
|
766
|
+
updatedInput: toolInput,
|
|
767
|
+
updatedPermissions: suggestions ?? [
|
|
768
|
+
{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
|
|
769
|
+
],
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
const response = await this.client.requestPermission({
|
|
773
|
+
options: [
|
|
774
|
+
{
|
|
775
|
+
kind: "allow_always",
|
|
776
|
+
name: "Always Allow",
|
|
777
|
+
optionId: "allow_always",
|
|
778
|
+
},
|
|
779
|
+
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
|
780
|
+
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
|
781
|
+
],
|
|
782
|
+
sessionId,
|
|
783
|
+
toolCall: {
|
|
784
|
+
toolCallId: toolUseID,
|
|
785
|
+
rawInput: toolInput,
|
|
786
|
+
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
790
|
+
throw new Error("Tool use aborted");
|
|
791
|
+
}
|
|
792
|
+
if (response.outcome?.outcome === "selected" &&
|
|
793
|
+
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")) {
|
|
794
|
+
// If Claude Code has suggestions, it will update their settings already
|
|
795
|
+
if (response.outcome.optionId === "allow_always") {
|
|
796
|
+
return {
|
|
797
|
+
behavior: "allow",
|
|
798
|
+
updatedInput: toolInput,
|
|
799
|
+
updatedPermissions: suggestions ?? [
|
|
800
|
+
{
|
|
801
|
+
type: "addRules",
|
|
802
|
+
rules: [{ toolName }],
|
|
803
|
+
behavior: "allow",
|
|
804
|
+
destination: "session",
|
|
805
|
+
},
|
|
806
|
+
],
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
behavior: "allow",
|
|
811
|
+
updatedInput: toolInput,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
return {
|
|
816
|
+
behavior: "deny",
|
|
817
|
+
message: "User refused permission to run tool",
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
async sendAvailableCommandsUpdate(sessionId) {
|
|
823
|
+
const session = this.sessions[sessionId];
|
|
824
|
+
if (!session)
|
|
825
|
+
return;
|
|
826
|
+
const commands = await session.query.supportedCommands();
|
|
827
|
+
await this.client.sessionUpdate({
|
|
828
|
+
sessionId,
|
|
829
|
+
update: {
|
|
830
|
+
sessionUpdate: "available_commands_update",
|
|
831
|
+
availableCommands: getAvailableSlashCommands(commands),
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
async updateConfigOption(sessionId, configId, value) {
|
|
836
|
+
const session = this.sessions[sessionId];
|
|
837
|
+
if (!session)
|
|
838
|
+
return;
|
|
839
|
+
this.syncSessionConfigState(session, configId, value);
|
|
840
|
+
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
841
|
+
await this.client.sessionUpdate({
|
|
842
|
+
sessionId,
|
|
843
|
+
update: {
|
|
844
|
+
sessionUpdate: "config_option_update",
|
|
845
|
+
configOptions: session.configOptions,
|
|
846
|
+
},
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
syncSessionConfigState(session, configId, value) {
|
|
850
|
+
if (configId === "mode") {
|
|
851
|
+
session.modes = { ...session.modes, currentModeId: value };
|
|
852
|
+
}
|
|
853
|
+
else if (configId === "model") {
|
|
854
|
+
session.models = { ...session.models, currentModelId: value };
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
async getOrCreateSession(params) {
|
|
858
|
+
const existingSession = this.sessions[params.sessionId];
|
|
859
|
+
if (existingSession) {
|
|
860
|
+
return {
|
|
861
|
+
sessionId: params.sessionId,
|
|
862
|
+
modes: existingSession.modes,
|
|
863
|
+
models: existingSession.models,
|
|
864
|
+
configOptions: existingSession.configOptions,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const response = await this.createSession({
|
|
868
|
+
cwd: params.cwd,
|
|
869
|
+
mcpServers: params.mcpServers ?? [],
|
|
870
|
+
_meta: params._meta,
|
|
871
|
+
}, {
|
|
872
|
+
resume: params.sessionId,
|
|
873
|
+
});
|
|
874
|
+
return {
|
|
875
|
+
sessionId: response.sessionId,
|
|
876
|
+
modes: response.modes,
|
|
877
|
+
models: response.models,
|
|
878
|
+
configOptions: response.configOptions,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
async createSession(params, creationOpts = {}) {
|
|
882
|
+
// We want to create a new session id unless it is resume,
|
|
883
|
+
// but not resume + forkSession.
|
|
884
|
+
let sessionId;
|
|
885
|
+
if (creationOpts.forkSession) {
|
|
886
|
+
sessionId = randomUUID();
|
|
887
|
+
}
|
|
888
|
+
else if (creationOpts.resume) {
|
|
889
|
+
sessionId = creationOpts.resume;
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
sessionId = randomUUID();
|
|
893
|
+
}
|
|
894
|
+
const input = new Pushable();
|
|
895
|
+
const settingsManager = new SettingsManager(params.cwd, {
|
|
896
|
+
logger: this.logger,
|
|
897
|
+
});
|
|
898
|
+
await settingsManager.initialize();
|
|
899
|
+
const mcpServers = {};
|
|
900
|
+
if (Array.isArray(params.mcpServers)) {
|
|
901
|
+
for (const server of params.mcpServers) {
|
|
902
|
+
if ("type" in server) {
|
|
903
|
+
mcpServers[server.name] = {
|
|
904
|
+
type: server.type,
|
|
905
|
+
url: server.url,
|
|
906
|
+
headers: server.headers
|
|
907
|
+
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
|
|
908
|
+
: undefined,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
mcpServers[server.name] = {
|
|
913
|
+
type: "stdio",
|
|
914
|
+
command: server.command,
|
|
915
|
+
args: server.args,
|
|
916
|
+
env: server.env
|
|
917
|
+
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
|
|
918
|
+
: undefined,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
let systemPrompt = { type: "preset", preset: "claude_code" };
|
|
924
|
+
if (params._meta?.systemPrompt) {
|
|
925
|
+
const customPrompt = params._meta.systemPrompt;
|
|
926
|
+
if (typeof customPrompt === "string") {
|
|
927
|
+
systemPrompt = customPrompt;
|
|
928
|
+
}
|
|
929
|
+
else if (typeof customPrompt === "object" &&
|
|
930
|
+
"append" in customPrompt &&
|
|
931
|
+
typeof customPrompt.append === "string") {
|
|
932
|
+
systemPrompt.append = customPrompt.append;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode);
|
|
936
|
+
// Extract options from _meta if provided
|
|
937
|
+
const sessionMeta = params._meta;
|
|
938
|
+
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
939
|
+
// Configure thinking tokens from environment variable
|
|
940
|
+
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
|
|
941
|
+
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
|
|
942
|
+
: undefined;
|
|
943
|
+
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
|
|
944
|
+
const disallowedTools = ["AskUserQuestion"];
|
|
945
|
+
// Resolve which built-in tools to expose.
|
|
946
|
+
// Explicit tools array from _meta.claudeCode.options takes precedence.
|
|
947
|
+
// disableBuiltInTools is a legacy shorthand for tools: [] — kept for
|
|
948
|
+
// backward compatibility but callers should prefer the tools array.
|
|
949
|
+
const tools = userProvidedOptions?.tools ??
|
|
950
|
+
(params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" });
|
|
951
|
+
const abortController = userProvidedOptions?.abortController || new AbortController();
|
|
952
|
+
const options = {
|
|
953
|
+
systemPrompt,
|
|
954
|
+
settingSources: ["user", "project", "local"],
|
|
955
|
+
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
|
|
956
|
+
...userProvidedOptions,
|
|
957
|
+
env: {
|
|
958
|
+
...process.env,
|
|
959
|
+
...userProvidedOptions?.env,
|
|
960
|
+
...createEnvForGateway(this.gatewayAuthMeta),
|
|
961
|
+
// Opt-in to session state events like when the agent is idle
|
|
962
|
+
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
963
|
+
},
|
|
964
|
+
// Override certain fields that must be controlled by ACP
|
|
965
|
+
cwd: params.cwd,
|
|
966
|
+
includePartialMessages: true,
|
|
967
|
+
mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
|
|
968
|
+
// If we want bypassPermissions to be an option, we have to allow it here.
|
|
969
|
+
// But it doesn't work in root mode, so we only activate it if it will work.
|
|
970
|
+
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
971
|
+
permissionMode,
|
|
972
|
+
canUseTool: this.canUseTool(sessionId),
|
|
973
|
+
// note: although not documented by the types, passing an absolute path
|
|
974
|
+
// here works to find zed's managed node version.
|
|
975
|
+
executable: isStaticBinary() ? undefined : process.execPath,
|
|
976
|
+
...(process.env.CLAUDE_CODE_EXECUTABLE
|
|
977
|
+
? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE }
|
|
978
|
+
: isStaticBinary()
|
|
979
|
+
? { pathToClaudeCodeExecutable: await claudeCliPath() }
|
|
980
|
+
: {}),
|
|
981
|
+
extraArgs: {
|
|
982
|
+
...userProvidedOptions?.extraArgs,
|
|
983
|
+
"replay-user-messages": "",
|
|
984
|
+
},
|
|
985
|
+
disallowedTools: [...(userProvidedOptions?.disallowedTools || []), ...disallowedTools],
|
|
986
|
+
tools,
|
|
987
|
+
hooks: {
|
|
988
|
+
...userProvidedOptions?.hooks,
|
|
989
|
+
PostToolUse: [
|
|
990
|
+
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
|
991
|
+
{
|
|
992
|
+
hooks: [
|
|
993
|
+
createPostToolUseHook(this.logger, {
|
|
994
|
+
onEnterPlanMode: async () => {
|
|
995
|
+
await this.client.sessionUpdate({
|
|
996
|
+
sessionId,
|
|
997
|
+
update: {
|
|
998
|
+
sessionUpdate: "current_mode_update",
|
|
999
|
+
currentModeId: "plan",
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
await this.updateConfigOption(sessionId, "mode", "plan");
|
|
1003
|
+
},
|
|
1004
|
+
}),
|
|
1005
|
+
],
|
|
1006
|
+
},
|
|
1007
|
+
],
|
|
1008
|
+
},
|
|
1009
|
+
...creationOpts,
|
|
1010
|
+
abortController,
|
|
1011
|
+
};
|
|
1012
|
+
options.additionalDirectories = [
|
|
1013
|
+
...(userProvidedOptions?.additionalDirectories ?? []),
|
|
1014
|
+
...(sessionMeta?.additionalRoots ?? []),
|
|
1015
|
+
];
|
|
1016
|
+
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
|
1017
|
+
// Set our own session id if not resuming an existing session.
|
|
1018
|
+
options.sessionId = sessionId;
|
|
1019
|
+
}
|
|
1020
|
+
// Handle abort controller from meta options
|
|
1021
|
+
if (abortController?.signal.aborted) {
|
|
1022
|
+
throw new Error("Cancelled");
|
|
1023
|
+
}
|
|
1024
|
+
const q = query({
|
|
1025
|
+
prompt: input,
|
|
1026
|
+
options,
|
|
1027
|
+
});
|
|
1028
|
+
let initializationResult;
|
|
1029
|
+
try {
|
|
1030
|
+
initializationResult = await q.initializationResult();
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
if (creationOpts.resume &&
|
|
1034
|
+
error instanceof Error &&
|
|
1035
|
+
error.message === "Query closed before response received") {
|
|
1036
|
+
throw RequestError.resourceNotFound(sessionId);
|
|
1037
|
+
}
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
if (shouldHideClaudeAuth() &&
|
|
1041
|
+
initializationResult.account.subscriptionType &&
|
|
1042
|
+
!this.gatewayAuthMeta) {
|
|
1043
|
+
throw RequestError.authRequired(undefined, "This integration does not support using claude.ai subscriptions.");
|
|
1044
|
+
}
|
|
1045
|
+
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
|
|
1046
|
+
const availableModes = [
|
|
1047
|
+
{
|
|
1048
|
+
id: "default",
|
|
1049
|
+
name: "Default",
|
|
1050
|
+
description: "Standard behavior, prompts for dangerous operations",
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
id: "acceptEdits",
|
|
1054
|
+
name: "Accept Edits",
|
|
1055
|
+
description: "Auto-accept file edit operations",
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
id: "plan",
|
|
1059
|
+
name: "Plan Mode",
|
|
1060
|
+
description: "Planning mode, no actual tool execution",
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
id: "dontAsk",
|
|
1064
|
+
name: "Don't Ask",
|
|
1065
|
+
description: "Don't prompt for permissions, deny if not pre-approved",
|
|
1066
|
+
},
|
|
1067
|
+
];
|
|
1068
|
+
// Only works in non-root mode
|
|
1069
|
+
if (ALLOW_BYPASS) {
|
|
1070
|
+
availableModes.push({
|
|
1071
|
+
id: "bypassPermissions",
|
|
1072
|
+
name: "Bypass Permissions",
|
|
1073
|
+
description: "Bypass all permission checks",
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
const modes = {
|
|
1077
|
+
currentModeId: permissionMode,
|
|
1078
|
+
availableModes,
|
|
1079
|
+
};
|
|
1080
|
+
const configOptions = buildConfigOptions(modes, models);
|
|
1081
|
+
this.sessions[sessionId] = {
|
|
1082
|
+
query: q,
|
|
1083
|
+
input: input,
|
|
1084
|
+
cancelled: false,
|
|
1085
|
+
cwd: params.cwd,
|
|
1086
|
+
settingsManager,
|
|
1087
|
+
accumulatedUsage: {
|
|
1088
|
+
inputTokens: 0,
|
|
1089
|
+
outputTokens: 0,
|
|
1090
|
+
cachedReadTokens: 0,
|
|
1091
|
+
cachedWriteTokens: 0,
|
|
1092
|
+
},
|
|
1093
|
+
modes,
|
|
1094
|
+
models,
|
|
1095
|
+
configOptions,
|
|
1096
|
+
promptRunning: false,
|
|
1097
|
+
pendingMessages: new Map(),
|
|
1098
|
+
nextPendingOrder: 0,
|
|
1099
|
+
abortController,
|
|
1100
|
+
};
|
|
1101
|
+
return {
|
|
1102
|
+
sessionId,
|
|
1103
|
+
models,
|
|
1104
|
+
modes,
|
|
1105
|
+
configOptions,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
function sessionUsage(session) {
|
|
1110
|
+
return {
|
|
1111
|
+
inputTokens: session.accumulatedUsage.inputTokens,
|
|
1112
|
+
outputTokens: session.accumulatedUsage.outputTokens,
|
|
1113
|
+
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
|
|
1114
|
+
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
|
|
1115
|
+
totalTokens: session.accumulatedUsage.inputTokens +
|
|
1116
|
+
session.accumulatedUsage.outputTokens +
|
|
1117
|
+
session.accumulatedUsage.cachedReadTokens +
|
|
1118
|
+
session.accumulatedUsage.cachedWriteTokens,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
function createEnvForGateway(gatewayMeta) {
|
|
1122
|
+
if (!gatewayMeta) {
|
|
1123
|
+
return {};
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
ANTHROPIC_BASE_URL: gatewayMeta.gateway.baseUrl,
|
|
1127
|
+
ANTHROPIC_CUSTOM_HEADERS: Object.entries(gatewayMeta.gateway.headers)
|
|
1128
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
1129
|
+
.join("\n"),
|
|
1130
|
+
ANTHROPIC_AUTH_TOKEN: "", // Must be specified to bypass claude login requirement
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
function buildConfigOptions(modes, models) {
|
|
1134
|
+
return [
|
|
1135
|
+
{
|
|
1136
|
+
id: "mode",
|
|
1137
|
+
name: "Mode",
|
|
1138
|
+
description: "Session permission mode",
|
|
1139
|
+
category: "mode",
|
|
1140
|
+
type: "select",
|
|
1141
|
+
currentValue: modes.currentModeId,
|
|
1142
|
+
options: modes.availableModes.map((m) => ({
|
|
1143
|
+
value: m.id,
|
|
1144
|
+
name: m.name,
|
|
1145
|
+
description: m.description,
|
|
1146
|
+
})),
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
id: "model",
|
|
1150
|
+
name: "Model",
|
|
1151
|
+
description: "AI model to use",
|
|
1152
|
+
category: "model",
|
|
1153
|
+
type: "select",
|
|
1154
|
+
currentValue: models.currentModelId,
|
|
1155
|
+
options: models.availableModels.map((m) => ({
|
|
1156
|
+
value: m.modelId,
|
|
1157
|
+
name: m.name,
|
|
1158
|
+
description: m.description ?? undefined,
|
|
1159
|
+
})),
|
|
1160
|
+
},
|
|
1161
|
+
];
|
|
1162
|
+
}
|
|
1163
|
+
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|
|
1164
|
+
// but the SDK model list uses IDs like "claude-opus-4-6-1m".
|
|
1165
|
+
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
|
|
1166
|
+
function tokenizeModelPreference(model) {
|
|
1167
|
+
const lower = model.trim().toLowerCase();
|
|
1168
|
+
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
|
|
1169
|
+
const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ");
|
|
1170
|
+
const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
|
|
1171
|
+
const tokens = rawTokens
|
|
1172
|
+
.map((token) => {
|
|
1173
|
+
if (token === "opusplan")
|
|
1174
|
+
return "opus";
|
|
1175
|
+
if (token === "best" || token === "default")
|
|
1176
|
+
return "";
|
|
1177
|
+
return token;
|
|
1178
|
+
})
|
|
1179
|
+
.filter((token) => token && token !== "claude")
|
|
1180
|
+
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
|
|
1181
|
+
return { tokens, contextHint };
|
|
1182
|
+
}
|
|
1183
|
+
function scoreModelMatch(model, tokens, contextHint) {
|
|
1184
|
+
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
|
|
1185
|
+
let score = 0;
|
|
1186
|
+
for (const token of tokens) {
|
|
1187
|
+
if (haystack.includes(token)) {
|
|
1188
|
+
score += token === contextHint ? 3 : 1;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return score;
|
|
1192
|
+
}
|
|
1193
|
+
function resolveModelPreference(models, preference) {
|
|
1194
|
+
const trimmed = preference.trim();
|
|
1195
|
+
if (!trimmed)
|
|
1196
|
+
return null;
|
|
1197
|
+
const lower = trimmed.toLowerCase();
|
|
1198
|
+
// Exact match on value or display name
|
|
1199
|
+
const directMatch = models.find((model) => model.value === trimmed ||
|
|
1200
|
+
model.value.toLowerCase() === lower ||
|
|
1201
|
+
model.displayName.toLowerCase() === lower);
|
|
1202
|
+
if (directMatch)
|
|
1203
|
+
return directMatch;
|
|
1204
|
+
// Substring match
|
|
1205
|
+
const includesMatch = models.find((model) => {
|
|
1206
|
+
const value = model.value.toLowerCase();
|
|
1207
|
+
const display = model.displayName.toLowerCase();
|
|
1208
|
+
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
|
1209
|
+
});
|
|
1210
|
+
if (includesMatch)
|
|
1211
|
+
return includesMatch;
|
|
1212
|
+
// Tokenized matching for aliases like "opus[1m]"
|
|
1213
|
+
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
|
|
1214
|
+
if (tokens.length === 0)
|
|
1215
|
+
return null;
|
|
1216
|
+
let bestMatch = null;
|
|
1217
|
+
let bestScore = 0;
|
|
1218
|
+
for (const model of models) {
|
|
1219
|
+
const score = scoreModelMatch(model, tokens, contextHint);
|
|
1220
|
+
if (0 < score && (!bestMatch || bestScore < score)) {
|
|
1221
|
+
bestMatch = model;
|
|
1222
|
+
bestScore = score;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return bestMatch;
|
|
1226
|
+
}
|
|
1227
|
+
async function getAvailableModels(query, models, settingsManager) {
|
|
1228
|
+
const settings = settingsManager.getSettings();
|
|
1229
|
+
let currentModel = models[0];
|
|
1230
|
+
if (settings.model) {
|
|
1231
|
+
const match = resolveModelPreference(models, settings.model);
|
|
1232
|
+
if (match) {
|
|
1233
|
+
currentModel = match;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
await query.setModel(currentModel.value);
|
|
1237
|
+
return {
|
|
1238
|
+
availableModels: models.map((model) => ({
|
|
1239
|
+
modelId: model.value,
|
|
1240
|
+
name: model.displayName,
|
|
1241
|
+
description: model.description,
|
|
1242
|
+
})),
|
|
1243
|
+
currentModelId: currentModel.value,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
function getAvailableSlashCommands(commands) {
|
|
1247
|
+
const UNSUPPORTED_COMMANDS = [
|
|
1248
|
+
"cost",
|
|
1249
|
+
"keybindings-help",
|
|
1250
|
+
"login",
|
|
1251
|
+
"logout",
|
|
1252
|
+
"output-style:new",
|
|
1253
|
+
"release-notes",
|
|
1254
|
+
"todos",
|
|
1255
|
+
];
|
|
1256
|
+
return commands
|
|
1257
|
+
.map((command) => {
|
|
1258
|
+
const input = command.argumentHint
|
|
1259
|
+
? {
|
|
1260
|
+
hint: Array.isArray(command.argumentHint)
|
|
1261
|
+
? command.argumentHint.join(" ")
|
|
1262
|
+
: command.argumentHint,
|
|
1263
|
+
}
|
|
1264
|
+
: null;
|
|
1265
|
+
let name = command.name;
|
|
1266
|
+
if (command.name.endsWith(" (MCP)")) {
|
|
1267
|
+
name = `mcp:${name.replace(" (MCP)", "")}`;
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
name,
|
|
1271
|
+
description: command.description || "",
|
|
1272
|
+
input,
|
|
1273
|
+
};
|
|
1274
|
+
})
|
|
1275
|
+
.filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
|
|
1276
|
+
}
|
|
1277
|
+
function formatUriAsLink(uri) {
|
|
1278
|
+
try {
|
|
1279
|
+
if (uri.startsWith("file://")) {
|
|
1280
|
+
const path = uri.slice(7); // Remove "file://"
|
|
1281
|
+
const name = path.split("/").pop() || path;
|
|
1282
|
+
return `[@${name}](${uri})`;
|
|
1283
|
+
}
|
|
1284
|
+
else if (uri.startsWith("zed://")) {
|
|
1285
|
+
const parts = uri.split("/");
|
|
1286
|
+
const name = parts[parts.length - 1] || uri;
|
|
1287
|
+
return `[@${name}](${uri})`;
|
|
1288
|
+
}
|
|
1289
|
+
return uri;
|
|
1290
|
+
}
|
|
1291
|
+
catch {
|
|
1292
|
+
return uri;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
export function promptToClaude(prompt) {
|
|
1296
|
+
const content = [];
|
|
1297
|
+
const context = [];
|
|
1298
|
+
for (const chunk of prompt.prompt) {
|
|
1299
|
+
switch (chunk.type) {
|
|
1300
|
+
case "text": {
|
|
1301
|
+
let text = chunk.text;
|
|
1302
|
+
// change /mcp:server:command args -> /server:command (MCP) args
|
|
1303
|
+
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
|
|
1304
|
+
if (mcpMatch) {
|
|
1305
|
+
const [, server, command, args] = mcpMatch;
|
|
1306
|
+
text = `/${server}:${command} (MCP)${args || ""}`;
|
|
1307
|
+
}
|
|
1308
|
+
content.push({ type: "text", text });
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
case "resource_link": {
|
|
1312
|
+
const formattedUri = formatUriAsLink(chunk.uri);
|
|
1313
|
+
content.push({
|
|
1314
|
+
type: "text",
|
|
1315
|
+
text: formattedUri,
|
|
1316
|
+
});
|
|
1317
|
+
break;
|
|
1318
|
+
}
|
|
1319
|
+
case "resource": {
|
|
1320
|
+
if ("text" in chunk.resource) {
|
|
1321
|
+
const formattedUri = formatUriAsLink(chunk.resource.uri);
|
|
1322
|
+
content.push({
|
|
1323
|
+
type: "text",
|
|
1324
|
+
text: formattedUri,
|
|
1325
|
+
});
|
|
1326
|
+
context.push({
|
|
1327
|
+
type: "text",
|
|
1328
|
+
text: `\n<context ref="${chunk.resource.uri}">\n${chunk.resource.text}\n</context>`,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
// Ignore blob resources (unsupported)
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
case "image":
|
|
1335
|
+
if (chunk.data) {
|
|
1336
|
+
content.push({
|
|
1337
|
+
type: "image",
|
|
1338
|
+
source: {
|
|
1339
|
+
type: "base64",
|
|
1340
|
+
data: chunk.data,
|
|
1341
|
+
media_type: chunk.mimeType,
|
|
1342
|
+
},
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
else if (chunk.uri && chunk.uri.startsWith("http")) {
|
|
1346
|
+
content.push({
|
|
1347
|
+
type: "image",
|
|
1348
|
+
source: {
|
|
1349
|
+
type: "url",
|
|
1350
|
+
url: chunk.uri,
|
|
1351
|
+
},
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
break;
|
|
1355
|
+
// Ignore audio and other unsupported types
|
|
1356
|
+
default:
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
content.push(...context);
|
|
1361
|
+
return {
|
|
1362
|
+
type: "user",
|
|
1363
|
+
message: {
|
|
1364
|
+
role: "user",
|
|
1365
|
+
content: content,
|
|
1366
|
+
},
|
|
1367
|
+
session_id: prompt.sessionId,
|
|
1368
|
+
parent_tool_use_id: null,
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
1373
|
+
* Only handles text, image, and thinking chunks for now.
|
|
1374
|
+
*/
|
|
1375
|
+
export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
|
|
1376
|
+
const registerHooks = options?.registerHooks !== false;
|
|
1377
|
+
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
1378
|
+
if (typeof content === "string") {
|
|
1379
|
+
const update = {
|
|
1380
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
1381
|
+
content: {
|
|
1382
|
+
type: "text",
|
|
1383
|
+
text: content,
|
|
1384
|
+
},
|
|
1385
|
+
};
|
|
1386
|
+
if (options?.parentToolUseId) {
|
|
1387
|
+
update._meta = {
|
|
1388
|
+
...update._meta,
|
|
1389
|
+
claudeCode: {
|
|
1390
|
+
...(update._meta?.claudeCode || {}),
|
|
1391
|
+
parentToolUseId: options.parentToolUseId,
|
|
1392
|
+
},
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
return [{ sessionId, update }];
|
|
1396
|
+
}
|
|
1397
|
+
const output = [];
|
|
1398
|
+
// Only handle the first chunk for streaming; extend as needed for batching
|
|
1399
|
+
for (const chunk of content) {
|
|
1400
|
+
let update = null;
|
|
1401
|
+
switch (chunk.type) {
|
|
1402
|
+
case "text":
|
|
1403
|
+
case "text_delta":
|
|
1404
|
+
update = {
|
|
1405
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
1406
|
+
content: {
|
|
1407
|
+
type: "text",
|
|
1408
|
+
text: chunk.text,
|
|
1409
|
+
},
|
|
1410
|
+
};
|
|
1411
|
+
break;
|
|
1412
|
+
case "image":
|
|
1413
|
+
update = {
|
|
1414
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
1415
|
+
content: {
|
|
1416
|
+
type: "image",
|
|
1417
|
+
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
|
1418
|
+
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
|
|
1419
|
+
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
|
|
1420
|
+
},
|
|
1421
|
+
};
|
|
1422
|
+
break;
|
|
1423
|
+
case "thinking":
|
|
1424
|
+
case "thinking_delta":
|
|
1425
|
+
update = {
|
|
1426
|
+
sessionUpdate: "agent_thought_chunk",
|
|
1427
|
+
content: {
|
|
1428
|
+
type: "text",
|
|
1429
|
+
text: chunk.thinking,
|
|
1430
|
+
},
|
|
1431
|
+
};
|
|
1432
|
+
break;
|
|
1433
|
+
case "tool_use":
|
|
1434
|
+
case "server_tool_use":
|
|
1435
|
+
case "mcp_tool_use": {
|
|
1436
|
+
const alreadyCached = chunk.id in toolUseCache;
|
|
1437
|
+
toolUseCache[chunk.id] = chunk;
|
|
1438
|
+
if (chunk.name === "TodoWrite") {
|
|
1439
|
+
// @ts-expect-error - sometimes input is empty object
|
|
1440
|
+
if (Array.isArray(chunk.input.todos)) {
|
|
1441
|
+
update = {
|
|
1442
|
+
sessionUpdate: "plan",
|
|
1443
|
+
entries: planEntries(chunk.input),
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
else {
|
|
1448
|
+
// Only register hooks on first encounter to avoid double-firing
|
|
1449
|
+
if (registerHooks && !alreadyCached) {
|
|
1450
|
+
registerHookCallback(chunk.id, {
|
|
1451
|
+
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
|
1452
|
+
const toolUse = toolUseCache[toolUseId];
|
|
1453
|
+
if (toolUse) {
|
|
1454
|
+
const editDiff = toolUse.name === "Edit" ? toolUpdateFromEditToolResponse(toolResponse) : {};
|
|
1455
|
+
const update = {
|
|
1456
|
+
_meta: {
|
|
1457
|
+
claudeCode: {
|
|
1458
|
+
toolResponse,
|
|
1459
|
+
toolName: toolUse.name,
|
|
1460
|
+
},
|
|
1461
|
+
},
|
|
1462
|
+
toolCallId: toolUseId,
|
|
1463
|
+
sessionUpdate: "tool_call_update",
|
|
1464
|
+
...editDiff,
|
|
1465
|
+
};
|
|
1466
|
+
await client.sessionUpdate({
|
|
1467
|
+
sessionId,
|
|
1468
|
+
update,
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
logger.error(`[claude-agent-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`);
|
|
1473
|
+
}
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
let rawInput;
|
|
1478
|
+
try {
|
|
1479
|
+
rawInput = JSON.parse(JSON.stringify(chunk.input));
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
// ignore if we can't turn it to JSON
|
|
1483
|
+
}
|
|
1484
|
+
if (alreadyCached) {
|
|
1485
|
+
// Second encounter (full assistant message after streaming) —
|
|
1486
|
+
// send as tool_call_update to refine the existing tool_call
|
|
1487
|
+
// rather than emitting a duplicate tool_call.
|
|
1488
|
+
update = {
|
|
1489
|
+
_meta: {
|
|
1490
|
+
claudeCode: {
|
|
1491
|
+
toolName: chunk.name,
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
toolCallId: chunk.id,
|
|
1495
|
+
sessionUpdate: "tool_call_update",
|
|
1496
|
+
rawInput,
|
|
1497
|
+
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
else {
|
|
1501
|
+
// First encounter (streaming content_block_start or replay) —
|
|
1502
|
+
// send as tool_call with terminal_info for Bash tools.
|
|
1503
|
+
update = {
|
|
1504
|
+
_meta: {
|
|
1505
|
+
claudeCode: {
|
|
1506
|
+
toolName: chunk.name,
|
|
1507
|
+
},
|
|
1508
|
+
...(chunk.name === "Bash" && supportsTerminalOutput
|
|
1509
|
+
? { terminal_info: { terminal_id: chunk.id } }
|
|
1510
|
+
: {}),
|
|
1511
|
+
},
|
|
1512
|
+
toolCallId: chunk.id,
|
|
1513
|
+
sessionUpdate: "tool_call",
|
|
1514
|
+
rawInput,
|
|
1515
|
+
status: "pending",
|
|
1516
|
+
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
break;
|
|
1521
|
+
}
|
|
1522
|
+
case "tool_result":
|
|
1523
|
+
case "tool_search_tool_result":
|
|
1524
|
+
case "web_fetch_tool_result":
|
|
1525
|
+
case "web_search_tool_result":
|
|
1526
|
+
case "code_execution_tool_result":
|
|
1527
|
+
case "bash_code_execution_tool_result":
|
|
1528
|
+
case "text_editor_code_execution_tool_result":
|
|
1529
|
+
case "mcp_tool_result": {
|
|
1530
|
+
const toolUse = toolUseCache[chunk.tool_use_id];
|
|
1531
|
+
if (!toolUse) {
|
|
1532
|
+
logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
|
|
1533
|
+
break;
|
|
1534
|
+
}
|
|
1535
|
+
if (toolUse.name !== "TodoWrite") {
|
|
1536
|
+
const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
|
|
1537
|
+
// When terminal output is supported, send terminal_output as a
|
|
1538
|
+
// separate notification to match codex-acp's streaming lifecycle:
|
|
1539
|
+
// 1. tool_call → _meta.terminal_info (already sent above)
|
|
1540
|
+
// 2. tool_call_update → _meta.terminal_output (sent here)
|
|
1541
|
+
// 3. tool_call_update → _meta.terminal_exit (sent below with status)
|
|
1542
|
+
if (toolMeta?.terminal_output) {
|
|
1543
|
+
output.push({
|
|
1544
|
+
sessionId,
|
|
1545
|
+
update: {
|
|
1546
|
+
_meta: {
|
|
1547
|
+
terminal_output: toolMeta.terminal_output,
|
|
1548
|
+
...(options?.parentToolUseId
|
|
1549
|
+
? { claudeCode: { parentToolUseId: options.parentToolUseId } }
|
|
1550
|
+
: {}),
|
|
1551
|
+
},
|
|
1552
|
+
toolCallId: chunk.tool_use_id,
|
|
1553
|
+
sessionUpdate: "tool_call_update",
|
|
1554
|
+
},
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
update = {
|
|
1558
|
+
_meta: {
|
|
1559
|
+
claudeCode: {
|
|
1560
|
+
toolName: toolUse.name,
|
|
1561
|
+
},
|
|
1562
|
+
...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
|
|
1563
|
+
},
|
|
1564
|
+
toolCallId: chunk.tool_use_id,
|
|
1565
|
+
sessionUpdate: "tool_call_update",
|
|
1566
|
+
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
|
1567
|
+
rawOutput: chunk.content,
|
|
1568
|
+
...toolUpdate,
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
case "document":
|
|
1574
|
+
case "search_result":
|
|
1575
|
+
case "redacted_thinking":
|
|
1576
|
+
case "input_json_delta":
|
|
1577
|
+
case "citations_delta":
|
|
1578
|
+
case "signature_delta":
|
|
1579
|
+
case "container_upload":
|
|
1580
|
+
case "compaction":
|
|
1581
|
+
case "compaction_delta":
|
|
1582
|
+
break;
|
|
1583
|
+
default:
|
|
1584
|
+
unreachable(chunk, logger);
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
if (update) {
|
|
1588
|
+
if (options?.parentToolUseId) {
|
|
1589
|
+
update._meta = {
|
|
1590
|
+
...update._meta,
|
|
1591
|
+
claudeCode: {
|
|
1592
|
+
...(update._meta?.claudeCode || {}),
|
|
1593
|
+
parentToolUseId: options.parentToolUseId,
|
|
1594
|
+
},
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
output.push({ sessionId, update });
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return output;
|
|
1601
|
+
}
|
|
1602
|
+
export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
|
|
1603
|
+
const event = message.event;
|
|
1604
|
+
switch (event.type) {
|
|
1605
|
+
case "content_block_start":
|
|
1606
|
+
return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
|
|
1607
|
+
clientCapabilities: options?.clientCapabilities,
|
|
1608
|
+
parentToolUseId: message.parent_tool_use_id,
|
|
1609
|
+
cwd: options?.cwd,
|
|
1610
|
+
});
|
|
1611
|
+
case "content_block_delta":
|
|
1612
|
+
return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
|
|
1613
|
+
clientCapabilities: options?.clientCapabilities,
|
|
1614
|
+
parentToolUseId: message.parent_tool_use_id,
|
|
1615
|
+
cwd: options?.cwd,
|
|
1616
|
+
});
|
|
1617
|
+
// No content
|
|
1618
|
+
case "message_start":
|
|
1619
|
+
case "message_delta":
|
|
1620
|
+
case "message_stop":
|
|
1621
|
+
case "content_block_stop":
|
|
1622
|
+
return [];
|
|
1623
|
+
default:
|
|
1624
|
+
unreachable(event, logger);
|
|
1625
|
+
return [];
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
export function runAcp() {
|
|
1629
|
+
const input = nodeToWebWritable(process.stdout);
|
|
1630
|
+
const output = nodeToWebReadable(process.stdin);
|
|
1631
|
+
const stream = ndJsonStream(input, output);
|
|
1632
|
+
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
|
|
1633
|
+
}
|