@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.
@@ -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
+ }