@ape-egg/claude-code-acp 0.0.0-stage → 0.16.2
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 +222 -0
- package/README.md +54 -2
- package/dist/acp-agent.d.ts +121 -0
- package/dist/acp-agent.d.ts.map +1 -0
- package/dist/acp-agent.js +1363 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/lib.d.ts +7 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +6 -0
- package/dist/mcp-server.d.ts +21 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +841 -0
- package/dist/settings.d.ts +124 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +426 -0
- package/dist/tools.d.ts +52 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +622 -0
- package/dist/utils.d.ts +33 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +155 -0
- package/package.json +49 -4
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
|
+
import { SettingsManager } from "./settings.js";
|
|
3
|
+
import { query, } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import * as readline from "node:readline";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import { encodeProjectPath, nodeToWebReadable, nodeToWebWritable, Pushable, unreachable, } from "./utils.js";
|
|
9
|
+
import { createMcpServer } from "./mcp-server.js";
|
|
10
|
+
import { EDIT_TOOL_NAMES, acpToolNames } from "./tools.js";
|
|
11
|
+
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult, registerHookCallback, createPostToolUseHook, createPreToolUseHook, } from "./tools.js";
|
|
12
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
16
|
+
function sessionFilePath(cwd, sessionId) {
|
|
17
|
+
return path.join(CLAUDE_CONFIG_DIR, "projects", encodeProjectPath(cwd), `${sessionId}.jsonl`);
|
|
18
|
+
}
|
|
19
|
+
const MAX_TITLE_LENGTH = 128;
|
|
20
|
+
function sanitizeTitle(text) {
|
|
21
|
+
// Replace newlines and collapse whitespace
|
|
22
|
+
const sanitized = text
|
|
23
|
+
.replace(/[\r\n]+/g, " ")
|
|
24
|
+
.replace(/\s+/g, " ")
|
|
25
|
+
.trim();
|
|
26
|
+
if (sanitized.length <= MAX_TITLE_LENGTH) {
|
|
27
|
+
return sanitized;
|
|
28
|
+
}
|
|
29
|
+
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
|
30
|
+
}
|
|
31
|
+
// Bypass Permissions doesn't work if we are a root/sudo user
|
|
32
|
+
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
|
33
|
+
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
|
34
|
+
// Implement the ACP Agent interface
|
|
35
|
+
export class ClaudeAcpAgent {
|
|
36
|
+
constructor(client, logger) {
|
|
37
|
+
this.backgroundTerminals = {};
|
|
38
|
+
this.sessions = {};
|
|
39
|
+
this.client = client;
|
|
40
|
+
this.toolUseCache = {};
|
|
41
|
+
this.logger = logger ?? console;
|
|
42
|
+
}
|
|
43
|
+
async initialize(request) {
|
|
44
|
+
this.clientCapabilities = request.clientCapabilities;
|
|
45
|
+
// Default authMethod
|
|
46
|
+
const authMethod = {
|
|
47
|
+
description: "Run `claude /login` in the terminal",
|
|
48
|
+
name: "Log in with Claude Code",
|
|
49
|
+
id: "claude-login",
|
|
50
|
+
};
|
|
51
|
+
// If client supports terminal-auth capability, use that instead.
|
|
52
|
+
if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
|
53
|
+
const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
|
|
54
|
+
authMethod._meta = {
|
|
55
|
+
"terminal-auth": {
|
|
56
|
+
command: "node",
|
|
57
|
+
args: [cliPath],
|
|
58
|
+
label: "Claude Code Login",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
protocolVersion: 1,
|
|
64
|
+
agentCapabilities: {
|
|
65
|
+
promptCapabilities: {
|
|
66
|
+
image: true,
|
|
67
|
+
embeddedContext: true,
|
|
68
|
+
},
|
|
69
|
+
mcpCapabilities: {
|
|
70
|
+
http: true,
|
|
71
|
+
sse: true,
|
|
72
|
+
},
|
|
73
|
+
loadSession: true,
|
|
74
|
+
sessionCapabilities: {
|
|
75
|
+
fork: {},
|
|
76
|
+
list: {},
|
|
77
|
+
resume: {},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
agentInfo: {
|
|
81
|
+
name: packageJson.name,
|
|
82
|
+
title: "Claude Code",
|
|
83
|
+
version: packageJson.version,
|
|
84
|
+
},
|
|
85
|
+
authMethods: [authMethod],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async newSession(params) {
|
|
89
|
+
if (fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
|
|
90
|
+
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))) {
|
|
91
|
+
throw RequestError.authRequired();
|
|
92
|
+
}
|
|
93
|
+
const response = await this.createSession(params, {
|
|
94
|
+
// Revisit these meta values once we support resume
|
|
95
|
+
resume: params._meta?.claudeCode?.options?.resume,
|
|
96
|
+
});
|
|
97
|
+
// Needs to happen after we return the session
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
this.sendAvailableCommandsUpdate(response.sessionId);
|
|
100
|
+
}, 0);
|
|
101
|
+
return response;
|
|
102
|
+
}
|
|
103
|
+
async unstable_forkSession(params) {
|
|
104
|
+
const response = await this.createSession({
|
|
105
|
+
cwd: params.cwd,
|
|
106
|
+
mcpServers: params.mcpServers ?? [],
|
|
107
|
+
_meta: params._meta,
|
|
108
|
+
}, {
|
|
109
|
+
resume: params.sessionId,
|
|
110
|
+
forkSession: true,
|
|
111
|
+
});
|
|
112
|
+
// Needs to happen after we return the session
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
this.sendAvailableCommandsUpdate(response.sessionId);
|
|
115
|
+
}, 0);
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
async unstable_resumeSession(params) {
|
|
119
|
+
const response = await this.createSession({
|
|
120
|
+
cwd: params.cwd,
|
|
121
|
+
mcpServers: params.mcpServers ?? [],
|
|
122
|
+
_meta: params._meta,
|
|
123
|
+
}, {
|
|
124
|
+
resume: params.sessionId,
|
|
125
|
+
});
|
|
126
|
+
// Needs to happen after we return the session
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
this.sendAvailableCommandsUpdate(response.sessionId);
|
|
129
|
+
}, 0);
|
|
130
|
+
return response;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Find a session file by ID, first checking the given cwd's project directory,
|
|
134
|
+
* then falling back to scanning all project directories.
|
|
135
|
+
* Returns the absolute file path if found, or null if not found.
|
|
136
|
+
*/
|
|
137
|
+
async findSessionFile(sessionId, cwd) {
|
|
138
|
+
const fileName = `${sessionId}.jsonl`;
|
|
139
|
+
// Fast path: check the expected location based on cwd
|
|
140
|
+
const expectedPath = sessionFilePath(cwd, sessionId);
|
|
141
|
+
try {
|
|
142
|
+
await fs.promises.access(expectedPath);
|
|
143
|
+
return expectedPath;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Not found at expected path, scan all project directories
|
|
147
|
+
}
|
|
148
|
+
const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects");
|
|
149
|
+
try {
|
|
150
|
+
const projectDirs = await fs.promises.readdir(claudeDir);
|
|
151
|
+
for (const encodedPath of projectDirs) {
|
|
152
|
+
const projectDir = path.join(claudeDir, encodedPath);
|
|
153
|
+
const stat = await fs.promises.stat(projectDir);
|
|
154
|
+
if (!stat.isDirectory())
|
|
155
|
+
continue;
|
|
156
|
+
const candidatePath = path.join(projectDir, fileName);
|
|
157
|
+
try {
|
|
158
|
+
await fs.promises.access(candidatePath);
|
|
159
|
+
return candidatePath;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// projects directory doesn't exist or isn't readable
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
async loadSession(params) {
|
|
172
|
+
const filePath = await this.findSessionFile(params.sessionId, params.cwd);
|
|
173
|
+
if (!filePath) {
|
|
174
|
+
throw new Error("Session not found");
|
|
175
|
+
}
|
|
176
|
+
const response = await this.createSession({
|
|
177
|
+
cwd: params.cwd,
|
|
178
|
+
mcpServers: params.mcpServers ?? [],
|
|
179
|
+
_meta: params._meta,
|
|
180
|
+
}, {
|
|
181
|
+
resume: params.sessionId,
|
|
182
|
+
});
|
|
183
|
+
await this.replaySessionHistory(params.sessionId, filePath);
|
|
184
|
+
// Send available commands after replay so it doesn't interleave with history
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
this.sendAvailableCommandsUpdate(params.sessionId);
|
|
187
|
+
}, 0);
|
|
188
|
+
return {
|
|
189
|
+
modes: response.modes,
|
|
190
|
+
models: response.models,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* List Claude Code sessions by parsing JSONL files
|
|
195
|
+
* Sessions are stored in ~/.claude/projects/<path-encoded>/
|
|
196
|
+
* Implements the draft session/list RFD spec
|
|
197
|
+
*/
|
|
198
|
+
async unstable_listSessions(params) {
|
|
199
|
+
// Note: We load all sessions into memory for sorting, so pagination here is for
|
|
200
|
+
// API response size limits rather than memory efficiency. This matches the RFD spec.
|
|
201
|
+
const PAGE_SIZE = 50;
|
|
202
|
+
const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects");
|
|
203
|
+
try {
|
|
204
|
+
await fs.promises.access(claudeDir);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { sessions: [] };
|
|
208
|
+
}
|
|
209
|
+
// Collect all sessions across all project directories
|
|
210
|
+
const allSessions = [];
|
|
211
|
+
const encodedCwdFilter = params.cwd ? encodeProjectPath(params.cwd) : null;
|
|
212
|
+
try {
|
|
213
|
+
const projectDirs = await fs.promises.readdir(claudeDir);
|
|
214
|
+
for (const encodedPath of projectDirs) {
|
|
215
|
+
const projectDir = path.join(claudeDir, encodedPath);
|
|
216
|
+
const stat = await fs.promises.stat(projectDir);
|
|
217
|
+
if (!stat.isDirectory())
|
|
218
|
+
continue;
|
|
219
|
+
// Path encoding is not always reversible (hyphens can be separators or literals),
|
|
220
|
+
// so only use encoded value as a coarse pre-filter.
|
|
221
|
+
if (encodedCwdFilter && encodedPath !== encodedCwdFilter)
|
|
222
|
+
continue;
|
|
223
|
+
const files = await fs.promises.readdir(projectDir);
|
|
224
|
+
// Filter to user session files only. Skip agent-*.jsonl files which contain
|
|
225
|
+
// internal agent metadata and system logs, not user-visible conversation sessions.
|
|
226
|
+
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
|
|
227
|
+
for (const file of jsonlFiles) {
|
|
228
|
+
const filePath = path.join(projectDir, file);
|
|
229
|
+
try {
|
|
230
|
+
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
231
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
232
|
+
const sessionId = file.replace(".jsonl", "");
|
|
233
|
+
let parsedAnyEntry = false;
|
|
234
|
+
let sessionCwd;
|
|
235
|
+
// Find first user message for title
|
|
236
|
+
let title;
|
|
237
|
+
for (const line of lines) {
|
|
238
|
+
try {
|
|
239
|
+
const entry = JSON.parse(line);
|
|
240
|
+
parsedAnyEntry = true;
|
|
241
|
+
if (entry.isSidechain === true) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const entrySessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
|
|
245
|
+
if (typeof entry.sessionId === "string" && entry.sessionId !== entrySessionId) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (typeof entry.cwd === "string") {
|
|
249
|
+
sessionCwd = entry.cwd;
|
|
250
|
+
}
|
|
251
|
+
if (!title && entry.type === "user" && entry.message?.content) {
|
|
252
|
+
const msgContent = entry.message.content;
|
|
253
|
+
if (typeof msgContent === "string") {
|
|
254
|
+
title = sanitizeTitle(msgContent);
|
|
255
|
+
}
|
|
256
|
+
if (Array.isArray(msgContent) && msgContent.length > 0) {
|
|
257
|
+
const first = msgContent[0];
|
|
258
|
+
const text = typeof first === "string"
|
|
259
|
+
? first
|
|
260
|
+
: first && typeof first === "object" && typeof first.text === "string"
|
|
261
|
+
? first.text
|
|
262
|
+
: undefined;
|
|
263
|
+
if (text) {
|
|
264
|
+
title = sanitizeTitle(text);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Continue scanning until we have both fields, since cwd can appear
|
|
269
|
+
// in later entries even after the first user title-bearing message.
|
|
270
|
+
if (title && sessionCwd) {
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Skip malformed lines
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!parsedAnyEntry)
|
|
279
|
+
continue;
|
|
280
|
+
// SessionInfo.cwd is currently required. For entries that do not
|
|
281
|
+
// include an explicit cwd in the session JSONL (typically metadata-only files),
|
|
282
|
+
// we skip them instead of decoding folder names because path encoding is lossy.
|
|
283
|
+
if (!sessionCwd)
|
|
284
|
+
continue;
|
|
285
|
+
// Even after encoded-path pre-filtering, verify per-entry cwd to disambiguate
|
|
286
|
+
// collisions such as "/a-b" and "/a/b" that map to the same encoded folder name.
|
|
287
|
+
if (params.cwd && sessionCwd !== params.cwd)
|
|
288
|
+
continue;
|
|
289
|
+
// Get file modification time as updatedAt
|
|
290
|
+
const fileStat = await fs.promises.stat(filePath);
|
|
291
|
+
const updatedAt = fileStat.mtime.toISOString();
|
|
292
|
+
allSessions.push({
|
|
293
|
+
sessionId,
|
|
294
|
+
cwd: sessionCwd,
|
|
295
|
+
title: title ?? null,
|
|
296
|
+
updatedAt,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
this.logger.error(`[unstable_listSessions] Failed to parse session file: ${filePath}`, err);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
this.logger.error("[unstable_listSessions] Failed to list sessions", err);
|
|
307
|
+
return { sessions: [] };
|
|
308
|
+
}
|
|
309
|
+
// Sort by updatedAt descending (most recent first)
|
|
310
|
+
allSessions.sort((a, b) => {
|
|
311
|
+
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
312
|
+
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
313
|
+
return timeB - timeA;
|
|
314
|
+
});
|
|
315
|
+
// Handle pagination with cursor
|
|
316
|
+
let startIndex = 0;
|
|
317
|
+
if (params.cursor) {
|
|
318
|
+
try {
|
|
319
|
+
const decoded = Buffer.from(params.cursor, "base64").toString("utf-8");
|
|
320
|
+
const cursorData = JSON.parse(decoded);
|
|
321
|
+
startIndex = cursorData.offset ?? 0;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// Invalid cursor, start from beginning
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const pageOfSessions = allSessions.slice(startIndex, startIndex + PAGE_SIZE);
|
|
328
|
+
const hasMore = startIndex + PAGE_SIZE < allSessions.length;
|
|
329
|
+
const response = {
|
|
330
|
+
sessions: pageOfSessions,
|
|
331
|
+
};
|
|
332
|
+
if (hasMore) {
|
|
333
|
+
const nextCursor = Buffer.from(JSON.stringify({ offset: startIndex + PAGE_SIZE })).toString("base64");
|
|
334
|
+
response.nextCursor = nextCursor;
|
|
335
|
+
}
|
|
336
|
+
return response;
|
|
337
|
+
}
|
|
338
|
+
async authenticate(_params) {
|
|
339
|
+
throw new Error("Method not implemented.");
|
|
340
|
+
}
|
|
341
|
+
async prompt(params) {
|
|
342
|
+
const session = this.sessions[params.sessionId];
|
|
343
|
+
if (!session) {
|
|
344
|
+
throw new Error("Session not found");
|
|
345
|
+
}
|
|
346
|
+
if (session.ended) {
|
|
347
|
+
throw new Error("Session ended");
|
|
348
|
+
}
|
|
349
|
+
session.cancelled = false;
|
|
350
|
+
session.woke = null;
|
|
351
|
+
session.input.push(promptToClaude(params));
|
|
352
|
+
session.reading ??= this.read(params.sessionId);
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
session.turn = { resolve, reject };
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async read(sessionId) {
|
|
358
|
+
const session = this.sessions[sessionId];
|
|
359
|
+
while (true) {
|
|
360
|
+
let message, done;
|
|
361
|
+
try {
|
|
362
|
+
({ value: message, done } = await session.query.next());
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
session.ended = true;
|
|
366
|
+
this.settle(sessionId, null, error);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (done || !message) {
|
|
370
|
+
session.ended = true;
|
|
371
|
+
this.settle(sessionId, { stopReason: session.cancelled ? "cancelled" : "end_turn" });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
await this.handle(sessionId, message);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
this.settle(sessionId, null, error);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
settle(sessionId, result, error) {
|
|
383
|
+
const session = this.sessions[sessionId];
|
|
384
|
+
const turn = session.turn;
|
|
385
|
+
session.turn = null;
|
|
386
|
+
if (turn) {
|
|
387
|
+
if (error) {
|
|
388
|
+
turn.reject(error);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
turn.resolve(result);
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (result && !session.ended) {
|
|
396
|
+
const woke = session.woke;
|
|
397
|
+
session.woke = null;
|
|
398
|
+
void this.client.extNotification("_claudeCode/turn", {
|
|
399
|
+
sessionId,
|
|
400
|
+
...result,
|
|
401
|
+
...(woke && { _meta: { claudeCode: { task: woke } } }),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
task(sessionId, message) {
|
|
406
|
+
const session = this.sessions[sessionId];
|
|
407
|
+
if (message.tool_use_id) {
|
|
408
|
+
session.tasks[message.task_id] = message.tool_use_id;
|
|
409
|
+
}
|
|
410
|
+
const toolCallId = session.tasks[message.task_id];
|
|
411
|
+
if (message.subtype === "task_notification" && toolCallId) {
|
|
412
|
+
session.woke = { id: message.task_id, toolCallId, status: message.status };
|
|
413
|
+
}
|
|
414
|
+
if (!toolCallId || message.ambient) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const lifecycle = message.subtype === "task_started"
|
|
418
|
+
? "running"
|
|
419
|
+
: message.subtype === "task_notification"
|
|
420
|
+
? message.status
|
|
421
|
+
: message.patch?.status;
|
|
422
|
+
const backgrounded = message.patch?.is_backgrounded ?? message.is_backgrounded;
|
|
423
|
+
const task = {
|
|
424
|
+
id: message.task_id,
|
|
425
|
+
...(lifecycle && { status: lifecycle }),
|
|
426
|
+
...(message.description && { description: message.description }),
|
|
427
|
+
...(message.task_type && { type: message.task_type }),
|
|
428
|
+
...(message.subagent_type && { subagent: message.subagent_type }),
|
|
429
|
+
...(backgrounded !== undefined && { backgrounded }),
|
|
430
|
+
...(message.spawn_depth && { depth: message.spawn_depth }),
|
|
431
|
+
...(message.summary && { summary: message.summary }),
|
|
432
|
+
};
|
|
433
|
+
const status = message.subtype === "task_notification"
|
|
434
|
+
? { status: message.status === "completed" ? "completed" : "failed" }
|
|
435
|
+
: {};
|
|
436
|
+
return this.client.sessionUpdate({
|
|
437
|
+
sessionId,
|
|
438
|
+
update: {
|
|
439
|
+
sessionUpdate: "tool_call_update",
|
|
440
|
+
toolCallId,
|
|
441
|
+
...status,
|
|
442
|
+
_meta: { claudeCode: { task } },
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
async handle(sessionId, message) {
|
|
447
|
+
const params = { sessionId };
|
|
448
|
+
switch (message.type) {
|
|
449
|
+
case "system":
|
|
450
|
+
switch (message.subtype) {
|
|
451
|
+
case "init":
|
|
452
|
+
break;
|
|
453
|
+
case "task_started":
|
|
454
|
+
case "task_progress":
|
|
455
|
+
case "task_updated":
|
|
456
|
+
case "task_notification":
|
|
457
|
+
await this.task(params.sessionId, message);
|
|
458
|
+
break;
|
|
459
|
+
case "background_tasks_changed":
|
|
460
|
+
break;
|
|
461
|
+
case "compact_boundary":
|
|
462
|
+
case "hook_started":
|
|
463
|
+
case "hook_progress":
|
|
464
|
+
case "hook_response":
|
|
465
|
+
case "status":
|
|
466
|
+
case "files_persisted":
|
|
467
|
+
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
468
|
+
break;
|
|
469
|
+
default:
|
|
470
|
+
unreachable(message, this.logger);
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
474
|
+
case "result": {
|
|
475
|
+
if (this.sessions[params.sessionId].cancelled) {
|
|
476
|
+
return this.settle(sessionId, { stopReason: "cancelled" });
|
|
477
|
+
}
|
|
478
|
+
switch (message.subtype) {
|
|
479
|
+
case "success": {
|
|
480
|
+
if (message.result.includes("Please run /login")) {
|
|
481
|
+
throw RequestError.authRequired();
|
|
482
|
+
}
|
|
483
|
+
if (message.is_error) {
|
|
484
|
+
throw RequestError.internalError(undefined, message.result);
|
|
485
|
+
}
|
|
486
|
+
return this.settle(sessionId, { stopReason: "end_turn" });
|
|
487
|
+
}
|
|
488
|
+
case "error_during_execution":
|
|
489
|
+
if (message.is_error) {
|
|
490
|
+
throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
|
|
491
|
+
}
|
|
492
|
+
return this.settle(sessionId, { stopReason: "end_turn" });
|
|
493
|
+
case "error_max_budget_usd":
|
|
494
|
+
case "error_max_turns":
|
|
495
|
+
case "error_max_structured_output_retries":
|
|
496
|
+
if (message.is_error) {
|
|
497
|
+
throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
|
|
498
|
+
}
|
|
499
|
+
return this.settle(sessionId, { stopReason: "max_turn_requests" });
|
|
500
|
+
default:
|
|
501
|
+
unreachable(message, this.logger);
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
case "stream_event": {
|
|
507
|
+
for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.client, this.logger, { parentToolUseId: message.parent_tool_use_id })) {
|
|
508
|
+
await this.client.sessionUpdate(notification);
|
|
509
|
+
}
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
case "user":
|
|
513
|
+
case "assistant": {
|
|
514
|
+
if (this.sessions[params.sessionId].cancelled) {
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
// Slash commands like /compact can generate invalid output... doesn't match
|
|
518
|
+
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
|
|
519
|
+
if (typeof message.message.content === "string" &&
|
|
520
|
+
message.message.content.includes("<local-command-stdout>")) {
|
|
521
|
+
// Handle /context by sending its reply as regular agent message.
|
|
522
|
+
if (message.message.content.includes("Context Usage")) {
|
|
523
|
+
for (const notification of toAcpNotifications(message.message.content
|
|
524
|
+
.replace("<local-command-stdout>", "")
|
|
525
|
+
.replace("</local-command-stdout>", ""), "assistant", params.sessionId, this.toolUseCache, this.client, this.logger)) {
|
|
526
|
+
await this.client.sessionUpdate(notification);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
this.logger.log(message.message.content);
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
if (typeof message.message.content === "string" &&
|
|
533
|
+
message.message.content.includes("<local-command-stderr>")) {
|
|
534
|
+
this.logger.error(message.message.content);
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
// Skip these user messages for now, since they seem to just be messages we don't want in the feed
|
|
538
|
+
if (message.type === "user" &&
|
|
539
|
+
(typeof message.message.content === "string" ||
|
|
540
|
+
(Array.isArray(message.message.content) &&
|
|
541
|
+
message.message.content.length === 1 &&
|
|
542
|
+
message.message.content[0].type === "text"))) {
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
if (message.type === "assistant" &&
|
|
546
|
+
message.message.model === "<synthetic>" &&
|
|
547
|
+
Array.isArray(message.message.content) &&
|
|
548
|
+
message.message.content.length === 1 &&
|
|
549
|
+
message.message.content[0].type === "text" &&
|
|
550
|
+
message.message.content[0].text.includes("Please run /login")) {
|
|
551
|
+
throw RequestError.authRequired();
|
|
552
|
+
}
|
|
553
|
+
const content = message.type === "assistant"
|
|
554
|
+
? // Handled by stream events above
|
|
555
|
+
message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
|
|
556
|
+
: message.message.content;
|
|
557
|
+
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger, { parentToolUseId: message.parent_tool_use_id })) {
|
|
558
|
+
await this.client.sessionUpdate(notification);
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
case "tool_progress":
|
|
563
|
+
case "tool_use_summary":
|
|
564
|
+
break;
|
|
565
|
+
case "auth_status":
|
|
566
|
+
break;
|
|
567
|
+
default:
|
|
568
|
+
unreachable(message);
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async cancel(params) {
|
|
573
|
+
if (!this.sessions[params.sessionId]) {
|
|
574
|
+
throw new Error("Session not found");
|
|
575
|
+
}
|
|
576
|
+
this.sessions[params.sessionId].cancelled = true;
|
|
577
|
+
await this.sessions[params.sessionId].query.interrupt();
|
|
578
|
+
}
|
|
579
|
+
async unstable_setSessionModel(params) {
|
|
580
|
+
if (!this.sessions[params.sessionId]) {
|
|
581
|
+
throw new Error("Session not found");
|
|
582
|
+
}
|
|
583
|
+
await this.sessions[params.sessionId].query.setModel(params.modelId);
|
|
584
|
+
}
|
|
585
|
+
async setSessionMode(params) {
|
|
586
|
+
if (!this.sessions[params.sessionId]) {
|
|
587
|
+
throw new Error("Session not found");
|
|
588
|
+
}
|
|
589
|
+
switch (params.modeId) {
|
|
590
|
+
case "default":
|
|
591
|
+
case "acceptEdits":
|
|
592
|
+
case "bypassPermissions":
|
|
593
|
+
case "dontAsk":
|
|
594
|
+
case "plan":
|
|
595
|
+
this.sessions[params.sessionId].permissionMode = params.modeId;
|
|
596
|
+
try {
|
|
597
|
+
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
if (error instanceof Error) {
|
|
601
|
+
if (!error.message) {
|
|
602
|
+
error.message = "Invalid Mode";
|
|
603
|
+
}
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
// eslint-disable-next-line preserve-caught-error
|
|
608
|
+
throw new Error("Invalid Mode");
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return {};
|
|
612
|
+
default:
|
|
613
|
+
throw new Error("Invalid Mode");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async replaySessionHistory(sessionId, filePath) {
|
|
617
|
+
const toolUseCache = {};
|
|
618
|
+
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
|
|
619
|
+
const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
620
|
+
try {
|
|
621
|
+
for await (const line of reader) {
|
|
622
|
+
const trimmed = line.trim();
|
|
623
|
+
if (!trimmed) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
let entry;
|
|
627
|
+
try {
|
|
628
|
+
entry = JSON.parse(trimmed);
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (entry.type !== "user" && entry.type !== "assistant") {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (entry.isSidechain) {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (entry.sessionId && entry.sessionId !== sessionId) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const message = entry.message;
|
|
643
|
+
if (!message) {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const role = message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null;
|
|
647
|
+
if (!role) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const content = message.content;
|
|
651
|
+
if (typeof content !== "string" && !Array.isArray(content)) {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
for (const notification of toAcpNotifications(content, role, sessionId, toolUseCache, this.client, this.logger, { registerHooks: false })) {
|
|
655
|
+
await this.client.sessionUpdate(notification);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
reader.close();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async readTextFile(params) {
|
|
664
|
+
const response = await this.client.readTextFile(params);
|
|
665
|
+
return response;
|
|
666
|
+
}
|
|
667
|
+
async writeTextFile(params) {
|
|
668
|
+
const response = await this.client.writeTextFile(params);
|
|
669
|
+
return response;
|
|
670
|
+
}
|
|
671
|
+
canUseTool(sessionId) {
|
|
672
|
+
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
|
673
|
+
const session = this.sessions[sessionId];
|
|
674
|
+
if (!session) {
|
|
675
|
+
return {
|
|
676
|
+
behavior: "deny",
|
|
677
|
+
message: "Session not found",
|
|
678
|
+
interrupt: true,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (toolName === "ExitPlanMode") {
|
|
682
|
+
const response = await this.client.requestPermission({
|
|
683
|
+
options: [
|
|
684
|
+
{
|
|
685
|
+
kind: "allow_always",
|
|
686
|
+
name: "Yes, and auto-accept edits",
|
|
687
|
+
optionId: "acceptEdits",
|
|
688
|
+
},
|
|
689
|
+
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
|
|
690
|
+
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
|
691
|
+
],
|
|
692
|
+
sessionId,
|
|
693
|
+
toolCall: {
|
|
694
|
+
toolCallId: toolUseID,
|
|
695
|
+
rawInput: toolInput,
|
|
696
|
+
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
700
|
+
throw new Error("Tool use aborted");
|
|
701
|
+
}
|
|
702
|
+
if (response.outcome?.outcome === "selected" &&
|
|
703
|
+
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")) {
|
|
704
|
+
session.permissionMode = response.outcome.optionId;
|
|
705
|
+
await this.client.sessionUpdate({
|
|
706
|
+
sessionId,
|
|
707
|
+
update: {
|
|
708
|
+
sessionUpdate: "current_mode_update",
|
|
709
|
+
currentModeId: response.outcome.optionId,
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
return {
|
|
713
|
+
behavior: "allow",
|
|
714
|
+
updatedInput: toolInput,
|
|
715
|
+
updatedPermissions: suggestions ?? [
|
|
716
|
+
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
|
717
|
+
],
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
return {
|
|
722
|
+
behavior: "deny",
|
|
723
|
+
message: "User rejected request to exit plan mode.",
|
|
724
|
+
interrupt: true,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (session.permissionMode === "bypassPermissions" ||
|
|
729
|
+
(session.permissionMode === "acceptEdits" && EDIT_TOOL_NAMES.includes(toolName))) {
|
|
730
|
+
return {
|
|
731
|
+
behavior: "allow",
|
|
732
|
+
updatedInput: toolInput,
|
|
733
|
+
updatedPermissions: suggestions ?? [
|
|
734
|
+
{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
|
|
735
|
+
],
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
const response = await this.client.requestPermission({
|
|
739
|
+
options: [
|
|
740
|
+
{
|
|
741
|
+
kind: "allow_always",
|
|
742
|
+
name: "Always Allow",
|
|
743
|
+
optionId: "allow_always",
|
|
744
|
+
},
|
|
745
|
+
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
|
746
|
+
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
|
747
|
+
],
|
|
748
|
+
sessionId,
|
|
749
|
+
toolCall: {
|
|
750
|
+
toolCallId: toolUseID,
|
|
751
|
+
rawInput: toolInput,
|
|
752
|
+
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
756
|
+
throw new Error("Tool use aborted");
|
|
757
|
+
}
|
|
758
|
+
if (response.outcome?.outcome === "selected" &&
|
|
759
|
+
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")) {
|
|
760
|
+
// If Claude Code has suggestions, it will update their settings already
|
|
761
|
+
if (response.outcome.optionId === "allow_always") {
|
|
762
|
+
return {
|
|
763
|
+
behavior: "allow",
|
|
764
|
+
updatedInput: toolInput,
|
|
765
|
+
updatedPermissions: suggestions ?? [
|
|
766
|
+
{
|
|
767
|
+
type: "addRules",
|
|
768
|
+
rules: [{ toolName }],
|
|
769
|
+
behavior: "allow",
|
|
770
|
+
destination: "session",
|
|
771
|
+
},
|
|
772
|
+
],
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
behavior: "allow",
|
|
777
|
+
updatedInput: toolInput,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
return {
|
|
782
|
+
behavior: "deny",
|
|
783
|
+
message: "User refused permission to run tool",
|
|
784
|
+
interrupt: true,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
async sendAvailableCommandsUpdate(sessionId) {
|
|
790
|
+
const session = this.sessions[sessionId];
|
|
791
|
+
if (!session)
|
|
792
|
+
return;
|
|
793
|
+
const commands = await session.query.supportedCommands();
|
|
794
|
+
await this.client.sessionUpdate({
|
|
795
|
+
sessionId,
|
|
796
|
+
update: {
|
|
797
|
+
sessionUpdate: "available_commands_update",
|
|
798
|
+
availableCommands: getAvailableSlashCommands(commands),
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
async createSession(params, creationOpts = {}) {
|
|
803
|
+
// We want to create a new session id unless it is resume,
|
|
804
|
+
// but not resume + forkSession.
|
|
805
|
+
let sessionId;
|
|
806
|
+
if (creationOpts.forkSession) {
|
|
807
|
+
sessionId = randomUUID();
|
|
808
|
+
}
|
|
809
|
+
else if (creationOpts.resume) {
|
|
810
|
+
sessionId = creationOpts.resume;
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
sessionId = randomUUID();
|
|
814
|
+
}
|
|
815
|
+
const input = new Pushable();
|
|
816
|
+
const settingsManager = new SettingsManager(params.cwd, {
|
|
817
|
+
logger: this.logger,
|
|
818
|
+
});
|
|
819
|
+
await settingsManager.initialize();
|
|
820
|
+
const mcpServers = {};
|
|
821
|
+
if (Array.isArray(params.mcpServers)) {
|
|
822
|
+
for (const server of params.mcpServers) {
|
|
823
|
+
if ("type" in server) {
|
|
824
|
+
mcpServers[server.name] = {
|
|
825
|
+
type: server.type,
|
|
826
|
+
url: server.url,
|
|
827
|
+
headers: server.headers
|
|
828
|
+
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
|
|
829
|
+
: undefined,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
mcpServers[server.name] = {
|
|
834
|
+
type: "stdio",
|
|
835
|
+
command: server.command,
|
|
836
|
+
args: server.args,
|
|
837
|
+
env: server.env
|
|
838
|
+
? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
|
|
839
|
+
: undefined,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// Only add the acp MCP server if built-in tools are not disabled
|
|
845
|
+
if (!params._meta?.disableBuiltInTools) {
|
|
846
|
+
const server = createMcpServer(this, sessionId, this.clientCapabilities);
|
|
847
|
+
mcpServers["acp"] = {
|
|
848
|
+
type: "sdk",
|
|
849
|
+
name: "acp",
|
|
850
|
+
instance: server,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
let systemPrompt = { type: "preset", preset: "claude_code" };
|
|
854
|
+
if (params._meta?.systemPrompt) {
|
|
855
|
+
const customPrompt = params._meta.systemPrompt;
|
|
856
|
+
if (typeof customPrompt === "string") {
|
|
857
|
+
systemPrompt = customPrompt;
|
|
858
|
+
}
|
|
859
|
+
else if (typeof customPrompt === "object" &&
|
|
860
|
+
"append" in customPrompt &&
|
|
861
|
+
typeof customPrompt.append === "string") {
|
|
862
|
+
systemPrompt.append = customPrompt.append;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const permissionMode = "default";
|
|
866
|
+
// Extract options from _meta if provided
|
|
867
|
+
const userProvidedOptions = params._meta?.claudeCode?.options;
|
|
868
|
+
// Configure thinking tokens from environment variable
|
|
869
|
+
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
|
|
870
|
+
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
|
|
871
|
+
: undefined;
|
|
872
|
+
const options = {
|
|
873
|
+
systemPrompt,
|
|
874
|
+
settingSources: ["user", "project", "local"],
|
|
875
|
+
stderr: (err) => this.logger.error(err),
|
|
876
|
+
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
|
|
877
|
+
...userProvidedOptions,
|
|
878
|
+
// Override certain fields that must be controlled by ACP
|
|
879
|
+
cwd: params.cwd,
|
|
880
|
+
includePartialMessages: true,
|
|
881
|
+
mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
|
|
882
|
+
// If we want bypassPermissions to be an option, we have to allow it here.
|
|
883
|
+
// But it doesn't work in root mode, so we only activate it if it will work.
|
|
884
|
+
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
885
|
+
permissionMode,
|
|
886
|
+
canUseTool: this.canUseTool(sessionId),
|
|
887
|
+
// note: although not documented by the types, passing an absolute path
|
|
888
|
+
// here works to find zed's managed node version.
|
|
889
|
+
executable: process.execPath,
|
|
890
|
+
...(process.env.CLAUDE_CODE_EXECUTABLE && {
|
|
891
|
+
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
|
|
892
|
+
}),
|
|
893
|
+
tools: { type: "preset", preset: "claude_code" },
|
|
894
|
+
hooks: {
|
|
895
|
+
...userProvidedOptions?.hooks,
|
|
896
|
+
PreToolUse: [
|
|
897
|
+
...(userProvidedOptions?.hooks?.PreToolUse || []),
|
|
898
|
+
{
|
|
899
|
+
hooks: [createPreToolUseHook(settingsManager, this.logger)],
|
|
900
|
+
},
|
|
901
|
+
],
|
|
902
|
+
PostToolUse: [
|
|
903
|
+
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
|
904
|
+
{
|
|
905
|
+
hooks: [
|
|
906
|
+
createPostToolUseHook(this.logger, {
|
|
907
|
+
onEnterPlanMode: async () => {
|
|
908
|
+
const session = this.sessions[sessionId];
|
|
909
|
+
if (session) {
|
|
910
|
+
session.permissionMode = "plan";
|
|
911
|
+
}
|
|
912
|
+
await this.client.sessionUpdate({
|
|
913
|
+
sessionId,
|
|
914
|
+
update: {
|
|
915
|
+
sessionUpdate: "current_mode_update",
|
|
916
|
+
currentModeId: "plan",
|
|
917
|
+
},
|
|
918
|
+
});
|
|
919
|
+
},
|
|
920
|
+
}),
|
|
921
|
+
],
|
|
922
|
+
},
|
|
923
|
+
],
|
|
924
|
+
},
|
|
925
|
+
...creationOpts,
|
|
926
|
+
};
|
|
927
|
+
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
|
928
|
+
// Set our own session id if not resuming an existing session.
|
|
929
|
+
options.sessionId = sessionId;
|
|
930
|
+
}
|
|
931
|
+
const allowedTools = [];
|
|
932
|
+
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
|
|
933
|
+
const disallowedTools = ["AskUserQuestion"];
|
|
934
|
+
// Check if built-in tools should be disabled
|
|
935
|
+
const disableBuiltInTools = params._meta?.disableBuiltInTools === true;
|
|
936
|
+
if (!disableBuiltInTools) {
|
|
937
|
+
if (this.clientCapabilities?.fs?.readTextFile) {
|
|
938
|
+
allowedTools.push(acpToolNames.read);
|
|
939
|
+
disallowedTools.push("Read");
|
|
940
|
+
}
|
|
941
|
+
if (this.clientCapabilities?.fs?.writeTextFile) {
|
|
942
|
+
disallowedTools.push("Write", "Edit");
|
|
943
|
+
}
|
|
944
|
+
if (this.clientCapabilities?.terminal) {
|
|
945
|
+
allowedTools.push(acpToolNames.bashOutput, acpToolNames.killShell);
|
|
946
|
+
disallowedTools.push("Bash", "BashOutput", "KillShell");
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
// When built-in tools are disabled, explicitly disallow all of them
|
|
951
|
+
disallowedTools.push(acpToolNames.read, acpToolNames.write, acpToolNames.edit, acpToolNames.bash, acpToolNames.bashOutput, acpToolNames.killShell, "Read", "Write", "Edit", "Bash", "BashOutput", "KillShell", "Glob", "Grep", "Task", "TodoWrite", "ExitPlanMode", "WebSearch", "WebFetch", "AskUserQuestion", "SlashCommand", "Skill", "NotebookEdit");
|
|
952
|
+
}
|
|
953
|
+
if (allowedTools.length > 0) {
|
|
954
|
+
options.allowedTools = allowedTools;
|
|
955
|
+
}
|
|
956
|
+
if (disallowedTools.length > 0) {
|
|
957
|
+
options.disallowedTools = [...(options.disallowedTools || []), ...disallowedTools];
|
|
958
|
+
}
|
|
959
|
+
// Handle abort controller from meta options
|
|
960
|
+
const abortController = userProvidedOptions?.abortController;
|
|
961
|
+
if (abortController?.signal.aborted) {
|
|
962
|
+
throw new Error("Cancelled");
|
|
963
|
+
}
|
|
964
|
+
const q = query({
|
|
965
|
+
prompt: input,
|
|
966
|
+
options,
|
|
967
|
+
});
|
|
968
|
+
this.sessions[sessionId] = {
|
|
969
|
+
query: q,
|
|
970
|
+
input: input,
|
|
971
|
+
cancelled: false,
|
|
972
|
+
permissionMode,
|
|
973
|
+
settingsManager,
|
|
974
|
+
turn: null,
|
|
975
|
+
ended: false,
|
|
976
|
+
reading: null,
|
|
977
|
+
tasks: {},
|
|
978
|
+
woke: null,
|
|
979
|
+
};
|
|
980
|
+
const initializationResult = await q.initializationResult();
|
|
981
|
+
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
|
|
982
|
+
const availableModes = [
|
|
983
|
+
{
|
|
984
|
+
id: "default",
|
|
985
|
+
name: "Default",
|
|
986
|
+
description: "Standard behavior, prompts for dangerous operations",
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
id: "acceptEdits",
|
|
990
|
+
name: "Accept Edits",
|
|
991
|
+
description: "Auto-accept file edit operations",
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
id: "plan",
|
|
995
|
+
name: "Plan Mode",
|
|
996
|
+
description: "Planning mode, no actual tool execution",
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
id: "dontAsk",
|
|
1000
|
+
name: "Don't Ask",
|
|
1001
|
+
description: "Don't prompt for permissions, deny if not pre-approved",
|
|
1002
|
+
},
|
|
1003
|
+
];
|
|
1004
|
+
// Only works in non-root mode
|
|
1005
|
+
if (ALLOW_BYPASS) {
|
|
1006
|
+
availableModes.push({
|
|
1007
|
+
id: "bypassPermissions",
|
|
1008
|
+
name: "Bypass Permissions",
|
|
1009
|
+
description: "Bypass all permission checks",
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
return {
|
|
1013
|
+
sessionId,
|
|
1014
|
+
models,
|
|
1015
|
+
modes: {
|
|
1016
|
+
currentModeId: permissionMode,
|
|
1017
|
+
availableModes,
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function getAvailableModels(query, models, settingsManager) {
|
|
1023
|
+
const settings = settingsManager.getSettings();
|
|
1024
|
+
let currentModel = models[0];
|
|
1025
|
+
if (settings.model) {
|
|
1026
|
+
const match = models.find((m) => m.value === settings.model ||
|
|
1027
|
+
m.value.includes(settings.model) ||
|
|
1028
|
+
settings.model.includes(m.value) ||
|
|
1029
|
+
m.displayName.toLowerCase() === settings.model.toLowerCase() ||
|
|
1030
|
+
m.displayName.toLowerCase().includes(settings.model.toLowerCase()));
|
|
1031
|
+
if (match) {
|
|
1032
|
+
currentModel = match;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
await query.setModel(currentModel.value);
|
|
1036
|
+
return {
|
|
1037
|
+
availableModels: models.map((model) => ({
|
|
1038
|
+
modelId: model.value,
|
|
1039
|
+
name: model.displayName,
|
|
1040
|
+
description: model.description,
|
|
1041
|
+
})),
|
|
1042
|
+
currentModelId: currentModel.value,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
function getAvailableSlashCommands(commands) {
|
|
1046
|
+
const UNSUPPORTED_COMMANDS = [
|
|
1047
|
+
"cost",
|
|
1048
|
+
"keybindings-help",
|
|
1049
|
+
"login",
|
|
1050
|
+
"logout",
|
|
1051
|
+
"output-style:new",
|
|
1052
|
+
"release-notes",
|
|
1053
|
+
"todos",
|
|
1054
|
+
];
|
|
1055
|
+
return commands
|
|
1056
|
+
.map((command) => {
|
|
1057
|
+
const input = command.argumentHint
|
|
1058
|
+
? {
|
|
1059
|
+
hint: Array.isArray(command.argumentHint)
|
|
1060
|
+
? command.argumentHint.join(" ")
|
|
1061
|
+
: command.argumentHint,
|
|
1062
|
+
}
|
|
1063
|
+
: null;
|
|
1064
|
+
let name = command.name;
|
|
1065
|
+
if (command.name.endsWith(" (MCP)")) {
|
|
1066
|
+
name = `mcp:${name.replace(" (MCP)", "")}`;
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
name,
|
|
1070
|
+
description: command.description || "",
|
|
1071
|
+
input,
|
|
1072
|
+
};
|
|
1073
|
+
})
|
|
1074
|
+
.filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
|
|
1075
|
+
}
|
|
1076
|
+
function formatUriAsLink(uri) {
|
|
1077
|
+
try {
|
|
1078
|
+
if (uri.startsWith("file://")) {
|
|
1079
|
+
const path = uri.slice(7); // Remove "file://"
|
|
1080
|
+
const name = path.split("/").pop() || path;
|
|
1081
|
+
return `[@${name}](${uri})`;
|
|
1082
|
+
}
|
|
1083
|
+
else if (uri.startsWith("zed://")) {
|
|
1084
|
+
const parts = uri.split("/");
|
|
1085
|
+
const name = parts[parts.length - 1] || uri;
|
|
1086
|
+
return `[@${name}](${uri})`;
|
|
1087
|
+
}
|
|
1088
|
+
return uri;
|
|
1089
|
+
}
|
|
1090
|
+
catch {
|
|
1091
|
+
return uri;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
export function promptToClaude(prompt) {
|
|
1095
|
+
const content = [];
|
|
1096
|
+
const context = [];
|
|
1097
|
+
for (const chunk of prompt.prompt) {
|
|
1098
|
+
switch (chunk.type) {
|
|
1099
|
+
case "text": {
|
|
1100
|
+
let text = chunk.text;
|
|
1101
|
+
// change /mcp:server:command args -> /server:command (MCP) args
|
|
1102
|
+
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
|
|
1103
|
+
if (mcpMatch) {
|
|
1104
|
+
const [, server, command, args] = mcpMatch;
|
|
1105
|
+
text = `/${server}:${command} (MCP)${args || ""}`;
|
|
1106
|
+
}
|
|
1107
|
+
content.push({ type: "text", text });
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
case "resource_link": {
|
|
1111
|
+
const formattedUri = formatUriAsLink(chunk.uri);
|
|
1112
|
+
content.push({
|
|
1113
|
+
type: "text",
|
|
1114
|
+
text: formattedUri,
|
|
1115
|
+
});
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
case "resource": {
|
|
1119
|
+
if ("text" in chunk.resource) {
|
|
1120
|
+
const formattedUri = formatUriAsLink(chunk.resource.uri);
|
|
1121
|
+
content.push({
|
|
1122
|
+
type: "text",
|
|
1123
|
+
text: formattedUri,
|
|
1124
|
+
});
|
|
1125
|
+
context.push({
|
|
1126
|
+
type: "text",
|
|
1127
|
+
text: `\n<context ref="${chunk.resource.uri}">\n${chunk.resource.text}\n</context>`,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
// Ignore blob resources (unsupported)
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
case "image":
|
|
1134
|
+
if (chunk.data) {
|
|
1135
|
+
content.push({
|
|
1136
|
+
type: "image",
|
|
1137
|
+
source: {
|
|
1138
|
+
type: "base64",
|
|
1139
|
+
data: chunk.data,
|
|
1140
|
+
media_type: chunk.mimeType,
|
|
1141
|
+
},
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
else if (chunk.uri && chunk.uri.startsWith("http")) {
|
|
1145
|
+
content.push({
|
|
1146
|
+
type: "image",
|
|
1147
|
+
source: {
|
|
1148
|
+
type: "url",
|
|
1149
|
+
url: chunk.uri,
|
|
1150
|
+
},
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
break;
|
|
1154
|
+
// Ignore audio and other unsupported types
|
|
1155
|
+
default:
|
|
1156
|
+
break;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
content.push(...context);
|
|
1160
|
+
return {
|
|
1161
|
+
type: "user",
|
|
1162
|
+
message: {
|
|
1163
|
+
role: "user",
|
|
1164
|
+
content: content,
|
|
1165
|
+
},
|
|
1166
|
+
session_id: prompt.sessionId,
|
|
1167
|
+
parent_tool_use_id: null,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
1172
|
+
* Only handles text, image, and thinking chunks for now.
|
|
1173
|
+
*/
|
|
1174
|
+
export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
|
|
1175
|
+
const registerHooks = options?.registerHooks !== false;
|
|
1176
|
+
const parentToolUseId = options?.parentToolUseId ?? null;
|
|
1177
|
+
if (typeof content === "string") {
|
|
1178
|
+
return [
|
|
1179
|
+
{
|
|
1180
|
+
sessionId,
|
|
1181
|
+
update: {
|
|
1182
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
1183
|
+
content: {
|
|
1184
|
+
type: "text",
|
|
1185
|
+
text: content,
|
|
1186
|
+
},
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
];
|
|
1190
|
+
}
|
|
1191
|
+
const output = [];
|
|
1192
|
+
// Only handle the first chunk for streaming; extend as needed for batching
|
|
1193
|
+
for (const chunk of content) {
|
|
1194
|
+
let update = null;
|
|
1195
|
+
switch (chunk.type) {
|
|
1196
|
+
case "text":
|
|
1197
|
+
case "text_delta":
|
|
1198
|
+
update = {
|
|
1199
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
1200
|
+
content: {
|
|
1201
|
+
type: "text",
|
|
1202
|
+
text: chunk.text,
|
|
1203
|
+
},
|
|
1204
|
+
};
|
|
1205
|
+
break;
|
|
1206
|
+
case "image":
|
|
1207
|
+
update = {
|
|
1208
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
1209
|
+
content: {
|
|
1210
|
+
type: "image",
|
|
1211
|
+
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
|
1212
|
+
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
|
|
1213
|
+
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
|
|
1214
|
+
},
|
|
1215
|
+
};
|
|
1216
|
+
break;
|
|
1217
|
+
case "thinking":
|
|
1218
|
+
case "thinking_delta":
|
|
1219
|
+
update = {
|
|
1220
|
+
sessionUpdate: "agent_thought_chunk",
|
|
1221
|
+
content: {
|
|
1222
|
+
type: "text",
|
|
1223
|
+
text: chunk.thinking,
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
break;
|
|
1227
|
+
case "tool_use":
|
|
1228
|
+
case "server_tool_use":
|
|
1229
|
+
case "mcp_tool_use": {
|
|
1230
|
+
toolUseCache[chunk.id] = chunk;
|
|
1231
|
+
if (chunk.name === "TodoWrite") {
|
|
1232
|
+
// @ts-expect-error - sometimes input is empty object
|
|
1233
|
+
if (Array.isArray(chunk.input.todos)) {
|
|
1234
|
+
update = {
|
|
1235
|
+
sessionUpdate: "plan",
|
|
1236
|
+
entries: planEntries(chunk.input),
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
if (registerHooks) {
|
|
1242
|
+
registerHookCallback(chunk.id, {
|
|
1243
|
+
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
|
1244
|
+
const toolUse = toolUseCache[toolUseId];
|
|
1245
|
+
if (toolUse) {
|
|
1246
|
+
const update = {
|
|
1247
|
+
_meta: {
|
|
1248
|
+
claudeCode: {
|
|
1249
|
+
toolResponse,
|
|
1250
|
+
toolName: toolUse.name,
|
|
1251
|
+
},
|
|
1252
|
+
},
|
|
1253
|
+
toolCallId: toolUseId,
|
|
1254
|
+
sessionUpdate: "tool_call_update",
|
|
1255
|
+
};
|
|
1256
|
+
await client.sessionUpdate({
|
|
1257
|
+
sessionId,
|
|
1258
|
+
update,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
else {
|
|
1262
|
+
logger.error(`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`);
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
let rawInput;
|
|
1268
|
+
try {
|
|
1269
|
+
rawInput = JSON.parse(JSON.stringify(chunk.input));
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
// ignore if we can't turn it to JSON
|
|
1273
|
+
}
|
|
1274
|
+
update = {
|
|
1275
|
+
_meta: {
|
|
1276
|
+
claudeCode: {
|
|
1277
|
+
toolName: chunk.name,
|
|
1278
|
+
...(parentToolUseId && { parentToolUseId }),
|
|
1279
|
+
},
|
|
1280
|
+
},
|
|
1281
|
+
toolCallId: chunk.id,
|
|
1282
|
+
sessionUpdate: "tool_call",
|
|
1283
|
+
rawInput,
|
|
1284
|
+
status: "pending",
|
|
1285
|
+
...toolInfoFromToolUse(chunk),
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
break;
|
|
1289
|
+
}
|
|
1290
|
+
case "tool_result":
|
|
1291
|
+
case "tool_search_tool_result":
|
|
1292
|
+
case "web_fetch_tool_result":
|
|
1293
|
+
case "web_search_tool_result":
|
|
1294
|
+
case "code_execution_tool_result":
|
|
1295
|
+
case "bash_code_execution_tool_result":
|
|
1296
|
+
case "text_editor_code_execution_tool_result":
|
|
1297
|
+
case "mcp_tool_result": {
|
|
1298
|
+
const toolUse = toolUseCache[chunk.tool_use_id];
|
|
1299
|
+
if (!toolUse) {
|
|
1300
|
+
logger.error(`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
if (toolUse.name !== "TodoWrite") {
|
|
1304
|
+
update = {
|
|
1305
|
+
_meta: {
|
|
1306
|
+
claudeCode: {
|
|
1307
|
+
toolName: toolUse.name,
|
|
1308
|
+
...(parentToolUseId && { parentToolUseId }),
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
toolCallId: chunk.tool_use_id,
|
|
1312
|
+
sessionUpdate: "tool_call_update",
|
|
1313
|
+
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
|
|
1314
|
+
rawOutput: chunk.content,
|
|
1315
|
+
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
case "document":
|
|
1321
|
+
case "search_result":
|
|
1322
|
+
case "redacted_thinking":
|
|
1323
|
+
case "input_json_delta":
|
|
1324
|
+
case "citations_delta":
|
|
1325
|
+
case "signature_delta":
|
|
1326
|
+
case "container_upload":
|
|
1327
|
+
case "compaction":
|
|
1328
|
+
case "compaction_delta":
|
|
1329
|
+
break;
|
|
1330
|
+
default:
|
|
1331
|
+
unreachable(chunk, logger);
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
if (update) {
|
|
1335
|
+
output.push({ sessionId, update });
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return output;
|
|
1339
|
+
}
|
|
1340
|
+
export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
|
|
1341
|
+
const event = message.event;
|
|
1342
|
+
switch (event.type) {
|
|
1343
|
+
case "content_block_start":
|
|
1344
|
+
return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, options);
|
|
1345
|
+
case "content_block_delta":
|
|
1346
|
+
return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, options);
|
|
1347
|
+
// No content
|
|
1348
|
+
case "message_start":
|
|
1349
|
+
case "message_delta":
|
|
1350
|
+
case "message_stop":
|
|
1351
|
+
case "content_block_stop":
|
|
1352
|
+
return [];
|
|
1353
|
+
default:
|
|
1354
|
+
unreachable(event, logger);
|
|
1355
|
+
return [];
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
export function runAcp() {
|
|
1359
|
+
const input = nodeToWebWritable(process.stdout);
|
|
1360
|
+
const output = nodeToWebReadable(process.stdin);
|
|
1361
|
+
const stream = ndJsonStream(input, output);
|
|
1362
|
+
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
|
|
1363
|
+
}
|