claude-code-acp-ts 0.16.0 → 0.24.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/dist/acp-agent.js CHANGED
@@ -1,22 +1,15 @@
1
1
  import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
- import { SettingsManager } from "./settings.js";
3
- import { query, } from "@anthropic-ai/claude-agent-sdk";
2
+ import { getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
3
+ import { randomUUID } from "node:crypto";
4
4
  import * as fs from "node:fs";
5
- import * as path from "node:path";
6
- import * as readline from "node:readline";
7
5
  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";
6
+ import * as path from "node:path";
12
7
  import packageJson from "../package.json" with { type: "json" };
13
- import { randomUUID } from "node:crypto";
14
- import { fileURLToPath } from "node:url";
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";
15
11
  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;
12
+ const MAX_TITLE_LENGTH = 256;
20
13
  function sanitizeTitle(text) {
21
14
  // Replace newlines and collapse whitespace
22
15
  const sanitized = text
@@ -28,49 +21,51 @@ function sanitizeTitle(text) {
28
21
  }
29
22
  return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
30
23
  }
31
- // ==================== 超时配置 ====================
32
- // 可通过环境变量覆盖默认值
33
- /** 获取模型列表超时时间(毫秒),默认 30 秒 */
34
- const MODEL_FETCH_TIMEOUT_MS = parseInt(process.env.ACP_MODEL_FETCH_TIMEOUT_MS || "30000", 10);
35
- /** 获取命令列表超时时间(毫秒),默认 30 秒 */
36
- const COMMAND_FETCH_TIMEOUT_MS = parseInt(process.env.ACP_COMMAND_FETCH_TIMEOUT_MS || "30000", 10);
37
- /** 超时后是否使用默认值继续(而不是抛出错误),默认 true */
38
- const USE_DEFAULTS_ON_TIMEOUT = process.env.ACP_USE_DEFAULTS_ON_TIMEOUT !== "false";
39
- /**
40
- * 为 Promise 添加超时保护
41
- * @param promise 要执行的 Promise
42
- * @param timeoutMs 超时时间(毫秒)
43
- * @param operationName 操作名称(用于日志)
44
- * @param logger 日志记录器
45
- * @returns Promise 执行结果
46
- */
47
- async function withTimeout(promise, timeoutMs, operationName, logger = console) {
48
- const start = Date.now();
49
- const timeoutPromise = new Promise((_, reject) => {
50
- setTimeout(() => {
51
- const elapsed = Date.now() - start;
52
- logger.error(`⏰ [ACP] ${operationName} 超时 (${timeoutMs}ms), 实际耗时: ${elapsed}ms`);
53
- reject(new Error(`${operationName} timed out after ${timeoutMs}ms`));
54
- }, timeoutMs);
55
- });
56
- try {
57
- const result = await Promise.race([promise, timeoutPromise]);
58
- const elapsed = Date.now() - start;
59
- if (elapsed > 5000) {
60
- logger.log(`⚠️ [ACP] ${operationName} 耗时较长: ${elapsed}ms`);
61
- }
62
- else {
63
- logger.log(`✓ [ACP] ${operationName} 完成, 耗时: ${elapsed}ms`);
64
- }
65
- return result;
66
- }
67
- catch (error) {
68
- throw error;
69
- }
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");
70
34
  }
71
35
  // Bypass Permissions doesn't work if we are a root/sudo user
72
36
  const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
73
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
+ }
74
69
  // Implement the ACP Agent interface
75
70
  export class ClaudeAcpAgent {
76
71
  constructor(client, logger) {
@@ -82,26 +77,46 @@ export class ClaudeAcpAgent {
82
77
  }
83
78
  async initialize(request) {
84
79
  this.clientCapabilities = request.clientCapabilities;
85
- // Default authMethod
86
- const authMethod = {
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 = {
87
94
  description: "Run `claude /login` in the terminal",
88
- name: "Log in with Claude Code",
95
+ name: "Log in with Claude",
89
96
  id: "claude-login",
97
+ type: "terminal",
98
+ args: ["--cli"],
90
99
  };
100
+ const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
91
101
  // If client supports terminal-auth capability, use that instead.
92
- if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
93
- const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
94
- authMethod._meta = {
102
+ const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
103
+ if (supportsMetaTerminalAuth) {
104
+ terminalAuthMethod._meta = {
95
105
  "terminal-auth": {
96
- command: "node",
97
- args: [cliPath, "/login"],
98
- label: "Claude Code Login",
106
+ command: process.execPath,
107
+ args: [...process.argv.slice(1), "--cli"],
108
+ label: "Claude Login",
99
109
  },
100
110
  };
101
111
  }
102
112
  return {
103
113
  protocolVersion: 1,
104
114
  agentCapabilities: {
115
+ _meta: {
116
+ claudeCode: {
117
+ promptQueueing: true,
118
+ },
119
+ },
105
120
  promptCapabilities: {
106
121
  image: true,
107
122
  embeddedContext: true,
@@ -115,28 +130,40 @@ export class ClaudeAcpAgent {
115
130
  fork: {},
116
131
  list: {},
117
132
  resume: {},
133
+ close: {},
118
134
  },
119
135
  },
120
136
  agentInfo: {
121
137
  name: packageJson.name,
122
- title: "Claude Code",
138
+ title: "Claude Agent",
123
139
  version: packageJson.version,
124
140
  },
125
- authMethods: [authMethod],
141
+ authMethods: [
142
+ ...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
143
+ ? [terminalAuthMethod]
144
+ : []),
145
+ ...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
146
+ ],
126
147
  };
127
148
  }
128
149
  async newSession(params) {
129
- if (fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
150
+ if (!this.gatewayAuthMeta &&
151
+ fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
130
152
  !fs.existsSync(path.resolve(os.homedir(), ".claude.json"))) {
131
153
  throw RequestError.authRequired();
132
154
  }
133
- return await this.createSession(params, {
155
+ const response = await this.createSession(params, {
134
156
  // Revisit these meta values once we support resume
135
157
  resume: params._meta?.claudeCode?.options?.resume,
136
158
  });
159
+ // Needs to happen after we return the session
160
+ setTimeout(() => {
161
+ this.sendAvailableCommandsUpdate(response.sessionId);
162
+ }, 0);
163
+ return response;
137
164
  }
138
165
  async unstable_forkSession(params) {
139
- return await this.createSession({
166
+ const response = await this.createSession({
140
167
  cwd: params.cwd,
141
168
  mcpServers: params.mcpServers ?? [],
142
169
  _meta: params._meta,
@@ -144,401 +171,534 @@ export class ClaudeAcpAgent {
144
171
  resume: params.sessionId,
145
172
  forkSession: true,
146
173
  });
174
+ // Needs to happen after we return the session
175
+ setTimeout(() => {
176
+ this.sendAvailableCommandsUpdate(response.sessionId);
177
+ }, 0);
178
+ return response;
147
179
  }
148
180
  async unstable_resumeSession(params) {
149
- const response = await this.createSession({
150
- cwd: params.cwd,
151
- mcpServers: params.mcpServers ?? [],
152
- _meta: params._meta,
153
- }, {
154
- resume: params.sessionId,
155
- });
156
- return response;
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;
157
187
  }
158
188
  async loadSession(params) {
159
- try {
160
- await fs.promises.access(sessionFilePath(params.cwd, params.sessionId));
161
- }
162
- catch {
163
- throw new Error("Session not found");
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
+ });
164
209
  }
165
- const response = await this.createSession({
166
- cwd: params.cwd,
167
- mcpServers: params.mcpServers ?? [],
168
- _meta: params._meta,
169
- }, {
170
- resume: params.sessionId,
171
- });
172
- await this.replaySessionHistory(params.sessionId, params.cwd);
173
210
  return {
174
- modes: response.modes,
175
- models: response.models,
211
+ sessions,
176
212
  };
177
213
  }
178
- /**
179
- * List Claude Code sessions by parsing JSONL files
180
- * Sessions are stored in ~/.claude/projects/<path-encoded>/
181
- * Implements the draft session/list RFD spec
182
- */
183
- async unstable_listSessions(params) {
184
- // Note: We load all sessions into memory for sorting, so pagination here is for
185
- // API response size limits rather than memory efficiency. This matches the RFD spec.
186
- const PAGE_SIZE = 50;
187
- const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects");
188
- try {
189
- await fs.promises.access(claudeDir);
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
+ let lastAssistantModel = null;
235
+ let lastContextWindowSize = 200000;
236
+ const userMessage = promptToClaude(params);
237
+ const promptUuid = randomUUID();
238
+ userMessage.uuid = promptUuid;
239
+ // These local-only commands return a result without replaying the user
240
+ // message. Mark promptReplayed=true so their result isn't consumed as a
241
+ // background task result.
242
+ const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
243
+ const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
244
+ if (session.promptRunning) {
245
+ session.input.push(userMessage);
246
+ const order = session.nextPendingOrder++;
247
+ const cancelled = await new Promise((resolve) => {
248
+ session.pendingMessages.set(promptUuid, { resolve, order });
249
+ });
250
+ if (cancelled) {
251
+ return { stopReason: "cancelled" };
252
+ }
190
253
  }
191
- catch {
192
- return { sessions: [] };
254
+ else {
255
+ session.input.push(userMessage);
193
256
  }
194
- // Collect all sessions across all project directories
195
- const allSessions = [];
196
- const encodedCwdFilter = params.cwd ? encodeProjectPath(params.cwd) : null;
257
+ session.promptRunning = true;
258
+ let handedOff = false;
259
+ let stopReason = "end_turn";
197
260
  try {
198
- const projectDirs = await fs.promises.readdir(claudeDir);
199
- for (const encodedPath of projectDirs) {
200
- const projectDir = path.join(claudeDir, encodedPath);
201
- const stat = await fs.promises.stat(projectDir);
202
- if (!stat.isDirectory())
203
- continue;
204
- // Path encoding is not always reversible (hyphens can be separators or literals),
205
- // so only use encoded value as a coarse pre-filter.
206
- if (encodedCwdFilter && encodedPath !== encodedCwdFilter)
207
- continue;
208
- const files = await fs.promises.readdir(projectDir);
209
- // Filter to user session files only. Skip agent-*.jsonl files which contain
210
- // internal agent metadata and system logs, not user-visible conversation sessions.
211
- const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
212
- for (const file of jsonlFiles) {
213
- const filePath = path.join(projectDir, file);
214
- try {
215
- const content = await fs.promises.readFile(filePath, "utf-8");
216
- const lines = content.trim().split("\n").filter(Boolean);
217
- const sessionId = file.replace(".jsonl", "");
218
- let parsedAnyEntry = false;
219
- let sessionCwd;
220
- // Find first user message for title
221
- let title;
222
- for (const line of lines) {
223
- try {
224
- const entry = JSON.parse(line);
225
- parsedAnyEntry = true;
226
- if (entry.isSidechain === true) {
227
- continue;
261
+ while (true) {
262
+ const { value: message, done } = await session.query.next();
263
+ if (done || !message) {
264
+ if (session.cancelled) {
265
+ return { stopReason: "cancelled" };
266
+ }
267
+ break;
268
+ }
269
+ switch (message.type) {
270
+ case "system":
271
+ switch (message.subtype) {
272
+ case "init":
273
+ break;
274
+ case "status": {
275
+ if (message.status === "compacting") {
276
+ await this.client.sessionUpdate({
277
+ sessionId: message.session_id,
278
+ update: {
279
+ sessionUpdate: "agent_message_chunk",
280
+ content: { type: "text", text: "Compacting..." },
281
+ },
282
+ });
228
283
  }
229
- const entrySessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
230
- if (typeof entry.sessionId === "string" && entry.sessionId !== entrySessionId) {
231
- continue;
284
+ break;
285
+ }
286
+ case "compact_boundary": {
287
+ // Send used:0 immediately so the client doesn't keep showing
288
+ // the stale pre-compaction context size until the next turn.
289
+ //
290
+ // This is a deliberate approximation: we don't know the exact
291
+ // post-compaction token count (only the SDK's next API call
292
+ // reveals that). But used:0 is directionally correct — context
293
+ // just dropped dramatically — and the real value replaces it
294
+ // within seconds when the next result message arrives.
295
+ // The alternative (no update) leaves the client showing e.g.
296
+ // "944k/1m" right after the user sees "Compacting completed",
297
+ // which is confusing and wrong.
298
+ lastAssistantTotalUsage = 0;
299
+ await this.client.sessionUpdate({
300
+ sessionId: message.session_id,
301
+ update: {
302
+ sessionUpdate: "usage_update",
303
+ used: 0,
304
+ size: lastContextWindowSize,
305
+ },
306
+ });
307
+ await this.client.sessionUpdate({
308
+ sessionId: message.session_id,
309
+ update: {
310
+ sessionUpdate: "agent_message_chunk",
311
+ content: { type: "text", text: "\n\nCompacting completed." },
312
+ },
313
+ });
314
+ break;
315
+ }
316
+ case "local_command_output": {
317
+ await this.client.sessionUpdate({
318
+ sessionId: message.session_id,
319
+ update: {
320
+ sessionUpdate: "agent_message_chunk",
321
+ content: { type: "text", text: message.content },
322
+ },
323
+ });
324
+ break;
325
+ }
326
+ case "session_state_changed": {
327
+ if (message.state === "idle") {
328
+ return { stopReason, usage: sessionUsage(session) };
329
+ }
330
+ break;
331
+ }
332
+ case "hook_started":
333
+ case "hook_progress":
334
+ case "hook_response":
335
+ case "files_persisted":
336
+ case "task_started":
337
+ case "task_notification":
338
+ case "task_progress":
339
+ case "elicitation_complete":
340
+ case "api_retry":
341
+ // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
342
+ break;
343
+ default:
344
+ unreachable(message, this.logger);
345
+ break;
346
+ }
347
+ break;
348
+ case "result": {
349
+ // Accumulate usage from this result
350
+ session.accumulatedUsage.inputTokens += message.usage.input_tokens;
351
+ session.accumulatedUsage.outputTokens += message.usage.output_tokens;
352
+ session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
353
+ session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens;
354
+ const matchingModelUsage = lastAssistantModel
355
+ ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
356
+ : null;
357
+ const contextWindowSize = matchingModelUsage?.contextWindow ?? 200000;
358
+ lastContextWindowSize = contextWindowSize;
359
+ // Send usage_update notification
360
+ if (lastAssistantTotalUsage !== null) {
361
+ await this.client.sessionUpdate({
362
+ sessionId: params.sessionId,
363
+ update: {
364
+ sessionUpdate: "usage_update",
365
+ used: lastAssistantTotalUsage,
366
+ size: contextWindowSize,
367
+ cost: {
368
+ amount: message.total_cost_usd,
369
+ currency: "USD",
370
+ },
371
+ },
372
+ });
373
+ }
374
+ if (session.cancelled) {
375
+ stopReason = "cancelled";
376
+ break;
377
+ }
378
+ switch (message.subtype) {
379
+ case "success": {
380
+ if (message.result.includes("Please run /login")) {
381
+ throw RequestError.authRequired();
232
382
  }
233
- if (typeof entry.cwd === "string") {
234
- sessionCwd = entry.cwd;
383
+ if (message.stop_reason === "max_tokens") {
384
+ stopReason = "max_tokens";
385
+ break;
235
386
  }
236
- if (!title && entry.type === "user" && entry.message?.content) {
237
- const msgContent = entry.message.content;
238
- if (typeof msgContent === "string") {
239
- title = sanitizeTitle(msgContent);
240
- }
241
- if (Array.isArray(msgContent) && msgContent.length > 0) {
242
- const first = msgContent[0];
243
- const text = typeof first === "string"
244
- ? first
245
- : first && typeof first === "object" && typeof first.text === "string"
246
- ? first.text
247
- : undefined;
248
- if (text) {
249
- title = sanitizeTitle(text);
250
- }
387
+ if (message.is_error) {
388
+ throw RequestError.internalError(undefined, message.result);
389
+ }
390
+ // For local-only commands (no model invocation), the result
391
+ // text is the command output — forward it to the client.
392
+ if (isLocalOnlyCommand) {
393
+ for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, this.toolUseCache, this.client, this.logger)) {
394
+ await this.client.sessionUpdate(notification);
251
395
  }
252
396
  }
253
- // Continue scanning until we have both fields, since cwd can appear
254
- // in later entries even after the first user title-bearing message.
255
- if (title && sessionCwd) {
397
+ break;
398
+ }
399
+ case "error_during_execution": {
400
+ if (message.stop_reason === "max_tokens") {
401
+ stopReason = "max_tokens";
256
402
  break;
257
403
  }
404
+ if (message.is_error) {
405
+ throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
406
+ }
407
+ stopReason = "end_turn";
408
+ break;
258
409
  }
259
- catch {
260
- // Skip malformed lines
261
- }
410
+ case "error_max_budget_usd":
411
+ case "error_max_turns":
412
+ case "error_max_structured_output_retries":
413
+ if (message.is_error) {
414
+ throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
415
+ }
416
+ stopReason = "max_turn_requests";
417
+ break;
418
+ default:
419
+ unreachable(message, this.logger);
420
+ break;
262
421
  }
263
- if (!parsedAnyEntry)
264
- continue;
265
- // SessionInfo.cwd is currently required. For entries that do not
266
- // include an explicit cwd in the session JSONL (typically metadata-only files),
267
- // we skip them instead of decoding folder names because path encoding is lossy.
268
- if (!sessionCwd)
269
- continue;
270
- // Even after encoded-path pre-filtering, verify per-entry cwd to disambiguate
271
- // collisions such as "/a-b" and "/a/b" that map to the same encoded folder name.
272
- if (params.cwd && sessionCwd !== params.cwd)
273
- continue;
274
- // Get file modification time as updatedAt
275
- const fileStat = await fs.promises.stat(filePath);
276
- const updatedAt = fileStat.mtime.toISOString();
277
- allSessions.push({
278
- sessionId,
279
- cwd: sessionCwd,
280
- title: title ?? null,
281
- updatedAt,
282
- });
422
+ break;
283
423
  }
284
- catch (err) {
285
- this.logger.error(`[unstable_listSessions] Failed to parse session file: ${filePath}`, err);
424
+ case "stream_event": {
425
+ for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.client, this.logger, {
426
+ clientCapabilities: this.clientCapabilities,
427
+ cwd: session.cwd,
428
+ })) {
429
+ await this.client.sessionUpdate(notification);
430
+ }
431
+ break;
286
432
  }
287
- }
288
- }
289
- }
290
- catch (err) {
291
- this.logger.error("[unstable_listSessions] Failed to list sessions", err);
292
- return { sessions: [] };
293
- }
294
- // Sort by updatedAt descending (most recent first)
295
- allSessions.sort((a, b) => {
296
- const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
297
- const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
298
- return timeB - timeA;
299
- });
300
- // Handle pagination with cursor
301
- let startIndex = 0;
302
- if (params.cursor) {
303
- try {
304
- const decoded = Buffer.from(params.cursor, "base64").toString("utf-8");
305
- const cursorData = JSON.parse(decoded);
306
- startIndex = cursorData.offset ?? 0;
307
- }
308
- catch {
309
- // Invalid cursor, start from beginning
310
- }
311
- }
312
- const pageOfSessions = allSessions.slice(startIndex, startIndex + PAGE_SIZE);
313
- const hasMore = startIndex + PAGE_SIZE < allSessions.length;
314
- const response = {
315
- sessions: pageOfSessions,
316
- };
317
- if (hasMore) {
318
- const nextCursor = Buffer.from(JSON.stringify({ offset: startIndex + PAGE_SIZE })).toString("base64");
319
- response.nextCursor = nextCursor;
320
- }
321
- return response;
322
- }
323
- async authenticate(_params) {
324
- throw new Error("Method not implemented.");
325
- }
326
- async prompt(params) {
327
- if (!this.sessions[params.sessionId]) {
328
- throw new Error("Session not found");
329
- }
330
- this.sessions[params.sessionId].cancelled = false;
331
- const { query, input } = this.sessions[params.sessionId];
332
- input.push(promptToClaude(params));
333
- while (true) {
334
- const { value: message, done } = await query.next();
335
- if (done || !message) {
336
- if (this.sessions[params.sessionId].cancelled) {
337
- return { stopReason: "cancelled" };
338
- }
339
- break;
340
- }
341
- switch (message.type) {
342
- case "system":
343
- switch (message.subtype) {
344
- case "init":
345
- break;
346
- case "compact_boundary":
347
- case "hook_started":
348
- case "task_notification":
349
- case "hook_progress":
350
- case "hook_response":
351
- case "status":
352
- case "files_persisted":
353
- // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
433
+ case "user":
434
+ case "assistant": {
435
+ if (session.cancelled) {
354
436
  break;
355
- default:
356
- unreachable(message, this.logger);
357
- break;
358
- }
359
- break;
360
- case "result": {
361
- if (this.sessions[params.sessionId].cancelled) {
362
- return { stopReason: "cancelled" };
363
- }
364
- switch (message.subtype) {
365
- case "success": {
366
- if (message.result.includes("Please run /login")) {
367
- throw RequestError.authRequired();
368
- }
369
- if (message.is_error) {
370
- throw RequestError.internalError(undefined, message.result);
371
- }
372
- return { stopReason: "end_turn" };
373
437
  }
374
- case "error_during_execution":
375
- if (message.is_error) {
376
- throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
438
+ // Check for prompt replay
439
+ if (message.type === "user" && "uuid" in message && message.uuid) {
440
+ if (message.uuid === promptUuid) {
441
+ break;
377
442
  }
378
- return { stopReason: "end_turn" };
379
- case "error_max_budget_usd":
380
- case "error_max_turns":
381
- case "error_max_structured_output_retries":
382
- if (message.is_error) {
383
- throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
443
+ const pending = session.pendingMessages.get(message.uuid);
444
+ if (pending) {
445
+ pending.resolve(false);
446
+ session.pendingMessages.delete(message.uuid);
447
+ handedOff = true;
448
+ // the current loop stops with end_turn,
449
+ // the loop of the next prompt continues running
450
+ return { stopReason: "end_turn", usage: sessionUsage(session) };
384
451
  }
385
- return { stopReason: "max_turn_requests" };
386
- default:
387
- unreachable(message, this.logger);
388
- break;
389
- }
390
- break;
391
- }
392
- case "stream_event": {
393
- for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.client, this.logger)) {
394
- await this.client.sessionUpdate(notification);
395
- }
396
- break;
397
- }
398
- case "user":
399
- case "assistant": {
400
- if (this.sessions[params.sessionId].cancelled) {
401
- break;
402
- }
403
- // Slash commands like /compact can generate invalid output... doesn't match
404
- // their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
405
- if (typeof message.message.content === "string" &&
406
- message.message.content.includes("<local-command-stdout>")) {
407
- // Handle /context by sending its reply as regular agent message.
408
- if (message.message.content.includes("Context Usage")) {
409
- for (const notification of toAcpNotifications(message.message.content
410
- .replace("<local-command-stdout>", "")
411
- .replace("</local-command-stdout>", ""), "assistant", params.sessionId, this.toolUseCache, this.client, this.logger)) {
412
- await this.client.sessionUpdate(notification);
452
+ if ("isReplay" in message && message.isReplay) {
453
+ // not pending or unrelated replay message
454
+ break;
413
455
  }
414
456
  }
415
- this.logger.log(message.message.content);
457
+ // Store latest assistant usage (excluding subagents)
458
+ // Sum all token types as a proxy for post-turn context occupancy:
459
+ // current turn's output will become next turn's input.
460
+ // Note: per the Anthropic API, input_tokens excludes cache tokens —
461
+ // cache_read and cache_creation are reported separately, so summing
462
+ // all four fields is not double-counting.
463
+ if (message.message.usage && message.parent_tool_use_id === null) {
464
+ const messageWithUsage = message.message;
465
+ lastAssistantTotalUsage =
466
+ messageWithUsage.usage.input_tokens +
467
+ messageWithUsage.usage.output_tokens +
468
+ messageWithUsage.usage.cache_read_input_tokens +
469
+ messageWithUsage.usage.cache_creation_input_tokens;
470
+ }
471
+ // Track the current top-level model for context window size lookup
472
+ // (exclude subagent messages to stay in sync with lastAssistantTotalUsage)
473
+ if (message.type === "assistant" &&
474
+ message.parent_tool_use_id === null &&
475
+ message.message.model &&
476
+ message.message.model !== "<synthetic>") {
477
+ lastAssistantModel = message.message.model;
478
+ }
479
+ // Slash commands like /compact can generate invalid output... doesn't match
480
+ // their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
481
+ if (typeof message.message.content === "string" &&
482
+ message.message.content.includes("<local-command-stdout>")) {
483
+ this.logger.log(message.message.content);
484
+ break;
485
+ }
486
+ if (typeof message.message.content === "string" &&
487
+ message.message.content.includes("<local-command-stderr>")) {
488
+ this.logger.error(message.message.content);
489
+ break;
490
+ }
491
+ // Skip these user messages for now, since they seem to just be messages we don't want in the feed
492
+ if (message.type === "user" &&
493
+ (typeof message.message.content === "string" ||
494
+ (Array.isArray(message.message.content) &&
495
+ message.message.content.length === 1 &&
496
+ message.message.content[0].type === "text"))) {
497
+ break;
498
+ }
499
+ if (message.type === "assistant" &&
500
+ message.message.model === "<synthetic>" &&
501
+ Array.isArray(message.message.content) &&
502
+ message.message.content.length === 1 &&
503
+ message.message.content[0].type === "text" &&
504
+ message.message.content[0].text.includes("Please run /login")) {
505
+ throw RequestError.authRequired();
506
+ }
507
+ const content = message.type === "assistant"
508
+ ? // Handled by stream events above
509
+ message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
510
+ : message.message.content;
511
+ for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger, {
512
+ clientCapabilities: this.clientCapabilities,
513
+ parentToolUseId: message.parent_tool_use_id,
514
+ cwd: session.cwd,
515
+ })) {
516
+ await this.client.sessionUpdate(notification);
517
+ }
416
518
  break;
417
519
  }
418
- if (typeof message.message.content === "string" &&
419
- message.message.content.includes("<local-command-stderr>")) {
420
- this.logger.error(message.message.content);
520
+ case "tool_progress":
521
+ case "tool_use_summary":
522
+ case "auth_status":
523
+ case "prompt_suggestion":
524
+ case "rate_limit_event":
421
525
  break;
422
- }
423
- // Skip these user messages for now, since they seem to just be messages we don't want in the feed
424
- if (message.type === "user" &&
425
- (typeof message.message.content === "string" ||
426
- (Array.isArray(message.message.content) &&
427
- message.message.content.length === 1 &&
428
- message.message.content[0].type === "text"))) {
526
+ default:
527
+ unreachable(message);
429
528
  break;
529
+ }
530
+ }
531
+ throw new Error("Session did not end in result");
532
+ }
533
+ catch (error) {
534
+ if (error instanceof RequestError || !(error instanceof Error)) {
535
+ throw error;
536
+ }
537
+ const message = error.message;
538
+ if (message.includes("ProcessTransport") ||
539
+ message.includes("terminated process") ||
540
+ message.includes("process exited with") ||
541
+ message.includes("process terminated by signal") ||
542
+ message.includes("Failed to write to process stdin")) {
543
+ this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
544
+ session.settingsManager.dispose();
545
+ session.input.end();
546
+ delete this.sessions[params.sessionId];
547
+ throw RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session.");
548
+ }
549
+ throw error;
550
+ }
551
+ finally {
552
+ if (!handedOff) {
553
+ session.promptRunning = false;
554
+ // This usually should not happen, but in case the loop finishes
555
+ // without claude sending all message replays, we resolve the
556
+ // next pending prompt call to ensure no prompts get stuck.
557
+ if (session.pendingMessages.size > 0) {
558
+ const next = [...session.pendingMessages.entries()].sort((a, b) => a[1].order - b[1].order)[0];
559
+ if (next) {
560
+ next[1].resolve(false);
561
+ session.pendingMessages.delete(next[0]);
430
562
  }
431
- if (message.type === "assistant" &&
432
- message.message.model === "<synthetic>" &&
433
- Array.isArray(message.message.content) &&
434
- message.message.content.length === 1 &&
435
- message.message.content[0].type === "text" &&
436
- message.message.content[0].text.includes("Please run /login")) {
437
- throw RequestError.authRequired();
438
- }
439
- const content = message.type === "assistant"
440
- ? // Handled by stream events above
441
- message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
442
- : message.message.content;
443
- for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger)) {
444
- await this.client.sessionUpdate(notification);
445
- }
446
- break;
447
563
  }
448
- case "tool_progress":
449
- case "tool_use_summary":
450
- break;
451
- case "auth_status":
452
- break;
453
- default:
454
- unreachable(message);
455
- break;
456
564
  }
457
565
  }
458
- throw new Error("Session did not end in result");
459
566
  }
460
567
  async cancel(params) {
461
- if (!this.sessions[params.sessionId]) {
568
+ const session = this.sessions[params.sessionId];
569
+ if (!session) {
462
570
  throw new Error("Session not found");
463
571
  }
464
- this.sessions[params.sessionId].cancelled = true;
465
- await this.sessions[params.sessionId].query.interrupt();
572
+ session.cancelled = true;
573
+ for (const [, pending] of session.pendingMessages) {
574
+ pending.resolve(true);
575
+ }
576
+ session.pendingMessages.clear();
577
+ await session.query.interrupt();
578
+ }
579
+ async unstable_closeSession(params) {
580
+ const session = this.sessions[params.sessionId];
581
+ if (!session) {
582
+ throw new Error("Session not found");
583
+ }
584
+ await this.cancel({ sessionId: params.sessionId });
585
+ session.settingsManager.dispose();
586
+ session.abortController.abort();
587
+ delete this.sessions[params.sessionId];
588
+ return {};
466
589
  }
467
590
  async unstable_setSessionModel(params) {
468
591
  if (!this.sessions[params.sessionId]) {
469
592
  throw new Error("Session not found");
470
593
  }
471
594
  await this.sessions[params.sessionId].query.setModel(params.modelId);
595
+ await this.updateConfigOption(params.sessionId, "model", params.modelId);
472
596
  }
473
597
  async setSessionMode(params) {
474
598
  if (!this.sessions[params.sessionId]) {
475
599
  throw new Error("Session not found");
476
600
  }
477
- switch (params.modeId) {
601
+ await this.applySessionMode(params.sessionId, params.modeId);
602
+ await this.updateConfigOption(params.sessionId, "mode", params.modeId);
603
+ return {};
604
+ }
605
+ async setSessionConfigOption(params) {
606
+ const session = this.sessions[params.sessionId];
607
+ if (!session) {
608
+ throw new Error("Session not found");
609
+ }
610
+ if (typeof params.value !== "string") {
611
+ throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
612
+ }
613
+ const option = session.configOptions.find((o) => o.id === params.configId);
614
+ if (!option) {
615
+ throw new Error(`Unknown config option: ${params.configId}`);
616
+ }
617
+ const allValues = "options" in option && Array.isArray(option.options)
618
+ ? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
619
+ : [];
620
+ let validValue = allValues.find((o) => o.value === params.value);
621
+ // For model options, fall back to resolveModelPreference when the exact
622
+ // value doesn't match. This lets callers use human-friendly aliases like
623
+ // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
624
+ if (!validValue && params.configId === "model") {
625
+ const modelInfos = allValues.map((o) => ({
626
+ value: o.value,
627
+ displayName: o.name,
628
+ description: o.description ?? "",
629
+ }));
630
+ const resolved = resolveModelPreference(modelInfos, params.value);
631
+ if (resolved) {
632
+ validValue = allValues.find((o) => o.value === resolved.value);
633
+ }
634
+ }
635
+ if (!validValue) {
636
+ throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
637
+ }
638
+ // Use the canonical option value so downstream code always receives the
639
+ // model ID rather than the caller-supplied alias.
640
+ const resolvedValue = validValue.value;
641
+ if (params.configId === "mode") {
642
+ await this.applySessionMode(params.sessionId, resolvedValue);
643
+ await this.client.sessionUpdate({
644
+ sessionId: params.sessionId,
645
+ update: {
646
+ sessionUpdate: "current_mode_update",
647
+ currentModeId: resolvedValue,
648
+ },
649
+ });
650
+ }
651
+ else if (params.configId === "model") {
652
+ await this.sessions[params.sessionId].query.setModel(resolvedValue);
653
+ }
654
+ this.syncSessionConfigState(session, params.configId, params.value);
655
+ session.configOptions = session.configOptions.map((o) => o.id === params.configId && typeof o.currentValue === "string"
656
+ ? { ...o, currentValue: resolvedValue }
657
+ : o);
658
+ return { configOptions: session.configOptions };
659
+ }
660
+ async applySessionMode(sessionId, modeId) {
661
+ switch (modeId) {
478
662
  case "default":
479
663
  case "acceptEdits":
480
664
  case "bypassPermissions":
481
665
  case "dontAsk":
482
666
  case "plan":
483
- this.sessions[params.sessionId].permissionMode = params.modeId;
484
- try {
485
- await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
486
- }
487
- catch (error) {
488
- const errorMessage = error instanceof Error && error.message ? error.message : "Invalid Mode";
489
- throw new Error(errorMessage);
490
- }
491
- return {};
667
+ break;
492
668
  default:
493
669
  throw new Error("Invalid Mode");
494
670
  }
495
- }
496
- async replaySessionHistory(sessionId, cwd) {
497
- const filePath = sessionFilePath(cwd, sessionId);
498
- const toolUseCache = {};
499
- const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
500
- const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
501
671
  try {
502
- for await (const line of reader) {
503
- const trimmed = line.trim();
504
- if (!trimmed) {
505
- continue;
506
- }
507
- let entry;
508
- try {
509
- entry = JSON.parse(trimmed);
510
- }
511
- catch {
512
- continue;
513
- }
514
- if (entry.type !== "user" && entry.type !== "assistant") {
515
- continue;
516
- }
517
- if (entry.isSidechain) {
518
- continue;
519
- }
520
- if (entry.sessionId && entry.sessionId !== sessionId) {
521
- continue;
522
- }
523
- const message = entry.message;
524
- if (!message) {
525
- continue;
526
- }
527
- const role = message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null;
528
- if (!role) {
529
- continue;
530
- }
531
- const content = message.content;
532
- if (typeof content !== "string" && !Array.isArray(content)) {
533
- continue;
534
- }
535
- for (const notification of toAcpNotifications(content, role, sessionId, toolUseCache, this.client, this.logger, { registerHooks: false })) {
536
- await this.client.sessionUpdate(notification);
672
+ await this.sessions[sessionId].query.setPermissionMode(modeId);
673
+ }
674
+ catch (error) {
675
+ if (error instanceof Error) {
676
+ if (!error.message) {
677
+ error.message = "Invalid Mode";
537
678
  }
679
+ throw error;
680
+ }
681
+ else {
682
+ // eslint-disable-next-line preserve-caught-error
683
+ throw new Error("Invalid Mode");
538
684
  }
539
685
  }
540
- finally {
541
- reader.close();
686
+ }
687
+ async replaySessionHistory(sessionId) {
688
+ const toolUseCache = {};
689
+ const messages = await getSessionMessages(sessionId);
690
+ for (const message of messages) {
691
+ for (const notification of toAcpNotifications(
692
+ // @ts-expect-error - untyped in SDK but we handle all of these
693
+ message.message.content,
694
+ // @ts-expect-error - untyped in SDK but we handle all of these
695
+ message.message.role, sessionId, toolUseCache, this.client, this.logger, {
696
+ registerHooks: false,
697
+ clientCapabilities: this.clientCapabilities,
698
+ cwd: this.sessions[sessionId]?.cwd,
699
+ })) {
700
+ await this.client.sessionUpdate(notification);
701
+ }
542
702
  }
543
703
  }
544
704
  async readTextFile(params) {
@@ -551,38 +711,47 @@ export class ClaudeAcpAgent {
551
711
  }
552
712
  canUseTool(sessionId) {
553
713
  return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
714
+ const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
554
715
  const session = this.sessions[sessionId];
555
716
  if (!session) {
556
717
  return {
557
718
  behavior: "deny",
558
719
  message: "Session not found",
559
- interrupt: true,
560
720
  };
561
721
  }
562
722
  if (toolName === "ExitPlanMode") {
723
+ const options = [
724
+ {
725
+ kind: "allow_always",
726
+ name: "Yes, and auto-accept edits",
727
+ optionId: "acceptEdits",
728
+ },
729
+ { kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
730
+ { kind: "reject_once", name: "No, keep planning", optionId: "plan" },
731
+ ];
732
+ if (ALLOW_BYPASS) {
733
+ options.unshift({
734
+ kind: "allow_always",
735
+ name: "Yes, and bypass permissions",
736
+ optionId: "bypassPermissions",
737
+ });
738
+ }
563
739
  const response = await this.client.requestPermission({
564
- options: [
565
- {
566
- kind: "allow_always",
567
- name: "Yes, and auto-accept edits",
568
- optionId: "acceptEdits",
569
- },
570
- { kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
571
- { kind: "reject_once", name: "No, keep planning", optionId: "plan" },
572
- ],
740
+ options,
573
741
  sessionId,
574
742
  toolCall: {
575
743
  toolCallId: toolUseID,
576
744
  rawInput: toolInput,
577
- title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
745
+ ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
578
746
  },
579
747
  });
580
748
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
581
749
  throw new Error("Tool use aborted");
582
750
  }
583
751
  if (response.outcome?.outcome === "selected" &&
584
- (response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")) {
585
- session.permissionMode = response.outcome.optionId;
752
+ (response.outcome.optionId === "default" ||
753
+ response.outcome.optionId === "acceptEdits" ||
754
+ response.outcome.optionId === "bypassPermissions")) {
586
755
  await this.client.sessionUpdate({
587
756
  sessionId,
588
757
  update: {
@@ -590,6 +759,7 @@ export class ClaudeAcpAgent {
590
759
  currentModeId: response.outcome.optionId,
591
760
  },
592
761
  });
762
+ await this.updateConfigOption(sessionId, "mode", response.outcome.optionId);
593
763
  return {
594
764
  behavior: "allow",
595
765
  updatedInput: toolInput,
@@ -602,12 +772,10 @@ export class ClaudeAcpAgent {
602
772
  return {
603
773
  behavior: "deny",
604
774
  message: "User rejected request to exit plan mode.",
605
- interrupt: true,
606
775
  };
607
776
  }
608
777
  }
609
- if (session.permissionMode === "bypassPermissions" ||
610
- (session.permissionMode === "acceptEdits" && EDIT_TOOL_NAMES.includes(toolName))) {
778
+ if (session.modes.currentModeId === "bypassPermissions") {
611
779
  return {
612
780
  behavior: "allow",
613
781
  updatedInput: toolInput,
@@ -630,7 +798,7 @@ export class ClaudeAcpAgent {
630
798
  toolCall: {
631
799
  toolCallId: toolUseID,
632
800
  rawInput: toolInput,
633
- title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
801
+ ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
634
802
  },
635
803
  });
636
804
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
@@ -662,11 +830,69 @@ export class ClaudeAcpAgent {
662
830
  return {
663
831
  behavior: "deny",
664
832
  message: "User refused permission to run tool",
665
- interrupt: true,
666
833
  };
667
834
  }
668
835
  };
669
836
  }
837
+ async sendAvailableCommandsUpdate(sessionId) {
838
+ const session = this.sessions[sessionId];
839
+ if (!session)
840
+ return;
841
+ const commands = await session.query.supportedCommands();
842
+ await this.client.sessionUpdate({
843
+ sessionId,
844
+ update: {
845
+ sessionUpdate: "available_commands_update",
846
+ availableCommands: getAvailableSlashCommands(commands),
847
+ },
848
+ });
849
+ }
850
+ async updateConfigOption(sessionId, configId, value) {
851
+ const session = this.sessions[sessionId];
852
+ if (!session)
853
+ return;
854
+ this.syncSessionConfigState(session, configId, value);
855
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
856
+ await this.client.sessionUpdate({
857
+ sessionId,
858
+ update: {
859
+ sessionUpdate: "config_option_update",
860
+ configOptions: session.configOptions,
861
+ },
862
+ });
863
+ }
864
+ syncSessionConfigState(session, configId, value) {
865
+ if (configId === "mode") {
866
+ session.modes = { ...session.modes, currentModeId: value };
867
+ }
868
+ else if (configId === "model") {
869
+ session.models = { ...session.models, currentModelId: value };
870
+ }
871
+ }
872
+ async getOrCreateSession(params) {
873
+ const existingSession = this.sessions[params.sessionId];
874
+ if (existingSession) {
875
+ return {
876
+ sessionId: params.sessionId,
877
+ modes: existingSession.modes,
878
+ models: existingSession.models,
879
+ configOptions: existingSession.configOptions,
880
+ };
881
+ }
882
+ const response = await this.createSession({
883
+ cwd: params.cwd,
884
+ mcpServers: params.mcpServers ?? [],
885
+ _meta: params._meta,
886
+ }, {
887
+ resume: params.sessionId,
888
+ });
889
+ return {
890
+ sessionId: response.sessionId,
891
+ modes: response.modes,
892
+ models: response.models,
893
+ configOptions: response.configOptions,
894
+ };
895
+ }
670
896
  async createSession(params, creationOpts = {}) {
671
897
  // We want to create a new session id unless it is resume,
672
898
  // but not resume + forkSession.
@@ -688,7 +914,8 @@ export class ClaudeAcpAgent {
688
914
  const mcpServers = {};
689
915
  if (Array.isArray(params.mcpServers)) {
690
916
  for (const server of params.mcpServers) {
691
- if ("type" in server) {
917
+ if ("type" in server && (server.type === "http" || server.type === "sse")) {
918
+ // HTTP or SSE type MCP server
692
919
  mcpServers[server.name] = {
693
920
  type: server.type,
694
921
  url: server.url,
@@ -698,6 +925,7 @@ export class ClaudeAcpAgent {
698
925
  };
699
926
  }
700
927
  else {
928
+ // Stdio type MCP server (with or without explicit type field)
701
929
  mcpServers[server.name] = {
702
930
  type: "stdio",
703
931
  command: server.command,
@@ -709,15 +937,6 @@ export class ClaudeAcpAgent {
709
937
  }
710
938
  }
711
939
  }
712
- // Only add the acp MCP server if built-in tools are not disabled
713
- if (!params._meta?.disableBuiltInTools) {
714
- const server = createMcpServer(this, sessionId, this.clientCapabilities);
715
- mcpServers["acp"] = {
716
- type: "sdk",
717
- name: "acp",
718
- instance: server,
719
- };
720
- }
721
940
  let systemPrompt = { type: "preset", preset: "claude_code" };
722
941
  if (params._meta?.systemPrompt) {
723
942
  const customPrompt = params._meta.systemPrompt;
@@ -730,19 +949,35 @@ export class ClaudeAcpAgent {
730
949
  systemPrompt.append = customPrompt.append;
731
950
  }
732
951
  }
733
- const permissionMode = "default";
952
+ const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode);
734
953
  // Extract options from _meta if provided
735
- const userProvidedOptions = params._meta?.claudeCode?.options;
954
+ const sessionMeta = params._meta;
955
+ const userProvidedOptions = sessionMeta?.claudeCode?.options;
736
956
  // Configure thinking tokens from environment variable
737
957
  const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
738
958
  ? parseInt(process.env.MAX_THINKING_TOKENS, 10)
739
959
  : undefined;
960
+ // Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
961
+ const disallowedTools = ["AskUserQuestion"];
962
+ // Resolve which built-in tools to expose.
963
+ // Explicit tools array from _meta.claudeCode.options takes precedence.
964
+ // disableBuiltInTools is a legacy shorthand for tools: [] — kept for
965
+ // backward compatibility but callers should prefer the tools array.
966
+ const tools = userProvidedOptions?.tools ??
967
+ (params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" });
968
+ const abortController = userProvidedOptions?.abortController || new AbortController();
740
969
  const options = {
741
970
  systemPrompt,
742
971
  settingSources: ["user", "project", "local"],
743
- stderr: (err) => this.logger.error(err),
744
972
  ...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
745
973
  ...userProvidedOptions,
974
+ env: {
975
+ ...process.env,
976
+ ...userProvidedOptions?.env,
977
+ ...createEnvForGateway(this.gatewayAuthMeta),
978
+ // Opt-in to session state events like when the agent is idle
979
+ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
980
+ },
746
981
  // Override certain fields that must be controlled by ACP
747
982
  cwd: params.cwd,
748
983
  includePartialMessages: true,
@@ -754,29 +989,26 @@ export class ClaudeAcpAgent {
754
989
  canUseTool: this.canUseTool(sessionId),
755
990
  // note: although not documented by the types, passing an absolute path
756
991
  // here works to find zed's managed node version.
757
- executable: process.execPath,
758
- ...(process.env.CLAUDE_CODE_EXECUTABLE && {
759
- pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
760
- }),
761
- tools: { type: "preset", preset: "claude_code" },
992
+ executable: isStaticBinary() ? undefined : process.execPath,
993
+ ...(process.env.CLAUDE_CODE_EXECUTABLE
994
+ ? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE }
995
+ : isStaticBinary()
996
+ ? { pathToClaudeCodeExecutable: await claudeCliPath() }
997
+ : {}),
998
+ extraArgs: {
999
+ ...userProvidedOptions?.extraArgs,
1000
+ "replay-user-messages": "",
1001
+ },
1002
+ disallowedTools: [...(userProvidedOptions?.disallowedTools || []), ...disallowedTools],
1003
+ tools,
762
1004
  hooks: {
763
1005
  ...userProvidedOptions?.hooks,
764
- PreToolUse: [
765
- ...(userProvidedOptions?.hooks?.PreToolUse || []),
766
- {
767
- hooks: [createPreToolUseHook(settingsManager, this.logger)],
768
- },
769
- ],
770
1006
  PostToolUse: [
771
1007
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
772
1008
  {
773
1009
  hooks: [
774
1010
  createPostToolUseHook(this.logger, {
775
1011
  onEnterPlanMode: async () => {
776
- const session = this.sessions[sessionId];
777
- if (session) {
778
- session.permissionMode = "plan";
779
- }
780
1012
  await this.client.sessionUpdate({
781
1013
  sessionId,
782
1014
  update: {
@@ -784,6 +1016,7 @@ export class ClaudeAcpAgent {
784
1016
  currentModeId: "plan",
785
1017
  },
786
1018
  });
1019
+ await this.updateConfigOption(sessionId, "mode", "plan");
787
1020
  },
788
1021
  }),
789
1022
  ],
@@ -791,41 +1024,17 @@ export class ClaudeAcpAgent {
791
1024
  ],
792
1025
  },
793
1026
  ...creationOpts,
1027
+ abortController,
794
1028
  };
1029
+ options.additionalDirectories = [
1030
+ ...(userProvidedOptions?.additionalDirectories ?? []),
1031
+ ...(sessionMeta?.additionalRoots ?? []),
1032
+ ];
795
1033
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
796
1034
  // Set our own session id if not resuming an existing session.
797
1035
  options.sessionId = sessionId;
798
1036
  }
799
- const allowedTools = [];
800
- // Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
801
- const disallowedTools = ["AskUserQuestion"];
802
- // Check if built-in tools should be disabled
803
- const disableBuiltInTools = params._meta?.disableBuiltInTools === true;
804
- if (!disableBuiltInTools) {
805
- if (this.clientCapabilities?.fs?.readTextFile) {
806
- allowedTools.push(acpToolNames.read);
807
- disallowedTools.push("Read");
808
- }
809
- if (this.clientCapabilities?.fs?.writeTextFile) {
810
- disallowedTools.push("Write", "Edit");
811
- }
812
- if (this.clientCapabilities?.terminal) {
813
- allowedTools.push(acpToolNames.bashOutput, acpToolNames.killShell);
814
- disallowedTools.push("Bash", "BashOutput", "KillShell");
815
- }
816
- }
817
- else {
818
- // When built-in tools are disabled, explicitly disallow all of them
819
- 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");
820
- }
821
- if (allowedTools.length > 0) {
822
- options.allowedTools = allowedTools;
823
- }
824
- if (disallowedTools.length > 0) {
825
- options.disallowedTools = [...(options.disallowedTools || []), ...disallowedTools];
826
- }
827
1037
  // Handle abort controller from meta options
828
- const abortController = userProvidedOptions?.abortController;
829
1038
  if (abortController?.signal.aborted) {
830
1039
  throw new Error("Cancelled");
831
1040
  }
@@ -842,29 +1051,26 @@ export class ClaudeAcpAgent {
842
1051
  prompt: input,
843
1052
  options,
844
1053
  });
845
- this.sessions[sessionId] = {
846
- query: q,
847
- input: input,
848
- cancelled: false,
849
- permissionMode,
850
- settingsManager,
851
- };
852
- this.logger.log(`📋 [ACP] 等待初始化结果 (模型+命令)...`);
853
- const initializationResult = await withTimeout(q.initializationResult(), MODEL_FETCH_TIMEOUT_MS, "initializationResult", this.logger);
854
- this.logger.log(`📋 [ACP] 初始化完成, 获取到 ${initializationResult.models.length} 个模型, ${initializationResult.commands.length} 个命令`);
1054
+ let initializationResult;
1055
+ try {
1056
+ initializationResult = await q.initializationResult();
1057
+ }
1058
+ catch (error) {
1059
+ if (creationOpts.resume &&
1060
+ error instanceof Error &&
1061
+ error.message === "Query closed before response received") {
1062
+ throw RequestError.resourceNotFound(sessionId);
1063
+ }
1064
+ throw error;
1065
+ }
1066
+ if (shouldHideClaudeAuth() &&
1067
+ initializationResult.account.subscriptionType &&
1068
+ !this.gatewayAuthMeta) {
1069
+ throw RequestError.authRequired(undefined, "This integration does not support using claude.ai subscriptions.");
1070
+ }
855
1071
  const models = await getAvailableModels(q, initializationResult.models, settingsManager);
856
1072
  const sessionCreateElapsed = Date.now() - sessionCreateStartTime;
857
1073
  this.logger.log(`✅ [ACP] 会话创建完成, 总耗时: ${sessionCreateElapsed}ms`);
858
- // Needs to happen after we return the session
859
- setTimeout(() => {
860
- this.client.sessionUpdate({
861
- sessionId,
862
- update: {
863
- sessionUpdate: "available_commands_update",
864
- availableCommands: getAvailableSlashCommands(initializationResult.commands),
865
- },
866
- });
867
- }, 0);
868
1074
  const availableModes = [
869
1075
  {
870
1076
  id: "default",
@@ -895,28 +1101,162 @@ export class ClaudeAcpAgent {
895
1101
  description: "Bypass all permission checks",
896
1102
  });
897
1103
  }
1104
+ const modes = {
1105
+ currentModeId: permissionMode,
1106
+ availableModes,
1107
+ };
1108
+ const configOptions = buildConfigOptions(modes, models);
1109
+ this.sessions[sessionId] = {
1110
+ query: q,
1111
+ input: input,
1112
+ cancelled: false,
1113
+ cwd: params.cwd,
1114
+ settingsManager,
1115
+ accumulatedUsage: {
1116
+ inputTokens: 0,
1117
+ outputTokens: 0,
1118
+ cachedReadTokens: 0,
1119
+ cachedWriteTokens: 0,
1120
+ },
1121
+ modes,
1122
+ models,
1123
+ configOptions,
1124
+ promptRunning: false,
1125
+ pendingMessages: new Map(),
1126
+ nextPendingOrder: 0,
1127
+ abortController,
1128
+ };
898
1129
  return {
899
1130
  sessionId,
900
1131
  models,
901
- modes: {
902
- currentModeId: permissionMode,
903
- availableModes,
904
- },
1132
+ modes,
1133
+ configOptions,
905
1134
  };
906
1135
  }
907
1136
  }
908
- async function getAvailableModels(query, models, settingsManager) {
909
- if (models.length === 0) {
910
- return { availableModels: [], currentModelId: "" };
1137
+ function sessionUsage(session) {
1138
+ return {
1139
+ inputTokens: session.accumulatedUsage.inputTokens,
1140
+ outputTokens: session.accumulatedUsage.outputTokens,
1141
+ cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
1142
+ cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
1143
+ totalTokens: session.accumulatedUsage.inputTokens +
1144
+ session.accumulatedUsage.outputTokens +
1145
+ session.accumulatedUsage.cachedReadTokens +
1146
+ session.accumulatedUsage.cachedWriteTokens,
1147
+ };
1148
+ }
1149
+ function createEnvForGateway(gatewayMeta) {
1150
+ if (!gatewayMeta) {
1151
+ return {};
1152
+ }
1153
+ return {
1154
+ ANTHROPIC_BASE_URL: gatewayMeta.gateway.baseUrl,
1155
+ ANTHROPIC_CUSTOM_HEADERS: Object.entries(gatewayMeta.gateway.headers)
1156
+ .map(([key, value]) => `${key}: ${value}`)
1157
+ .join("\n"),
1158
+ ANTHROPIC_AUTH_TOKEN: "", // Must be specified to bypass claude login requirement
1159
+ };
1160
+ }
1161
+ function buildConfigOptions(modes, models) {
1162
+ return [
1163
+ {
1164
+ id: "mode",
1165
+ name: "Mode",
1166
+ description: "Session permission mode",
1167
+ category: "mode",
1168
+ type: "select",
1169
+ currentValue: modes.currentModeId,
1170
+ options: modes.availableModes.map((m) => ({
1171
+ value: m.id,
1172
+ name: m.name,
1173
+ description: m.description,
1174
+ })),
1175
+ },
1176
+ {
1177
+ id: "model",
1178
+ name: "Model",
1179
+ description: "AI model to use",
1180
+ category: "model",
1181
+ type: "select",
1182
+ currentValue: models.currentModelId,
1183
+ options: models.availableModels.map((m) => ({
1184
+ value: m.modelId,
1185
+ name: m.name,
1186
+ description: m.description ?? undefined,
1187
+ })),
1188
+ },
1189
+ ];
1190
+ }
1191
+ // Claude Code CLI persists display strings like "opus[1m]" in settings,
1192
+ // but the SDK model list uses IDs like "claude-opus-4-6-1m".
1193
+ const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
1194
+ function tokenizeModelPreference(model) {
1195
+ const lower = model.trim().toLowerCase();
1196
+ const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
1197
+ const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ");
1198
+ const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
1199
+ const tokens = rawTokens
1200
+ .map((token) => {
1201
+ if (token === "opusplan")
1202
+ return "opus";
1203
+ if (token === "best" || token === "default")
1204
+ return "";
1205
+ return token;
1206
+ })
1207
+ .filter((token) => token && token !== "claude")
1208
+ .filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
1209
+ return { tokens, contextHint };
1210
+ }
1211
+ function scoreModelMatch(model, tokens, contextHint) {
1212
+ const haystack = `${model.value} ${model.displayName}`.toLowerCase();
1213
+ let score = 0;
1214
+ for (const token of tokens) {
1215
+ if (haystack.includes(token)) {
1216
+ score += token === contextHint ? 3 : 1;
1217
+ }
1218
+ }
1219
+ return score;
1220
+ }
1221
+ function resolveModelPreference(models, preference) {
1222
+ const trimmed = preference.trim();
1223
+ if (!trimmed)
1224
+ return null;
1225
+ const lower = trimmed.toLowerCase();
1226
+ // Exact match on value or display name
1227
+ const directMatch = models.find((model) => model.value === trimmed ||
1228
+ model.value.toLowerCase() === lower ||
1229
+ model.displayName.toLowerCase() === lower);
1230
+ if (directMatch)
1231
+ return directMatch;
1232
+ // Substring match
1233
+ const includesMatch = models.find((model) => {
1234
+ const value = model.value.toLowerCase();
1235
+ const display = model.displayName.toLowerCase();
1236
+ return value.includes(lower) || display.includes(lower) || lower.includes(value);
1237
+ });
1238
+ if (includesMatch)
1239
+ return includesMatch;
1240
+ // Tokenized matching for aliases like "opus[1m]"
1241
+ const { tokens, contextHint } = tokenizeModelPreference(trimmed);
1242
+ if (tokens.length === 0)
1243
+ return null;
1244
+ let bestMatch = null;
1245
+ let bestScore = 0;
1246
+ for (const model of models) {
1247
+ const score = scoreModelMatch(model, tokens, contextHint);
1248
+ if (0 < score && (!bestMatch || bestScore < score)) {
1249
+ bestMatch = model;
1250
+ bestScore = score;
1251
+ }
911
1252
  }
1253
+ return bestMatch;
1254
+ }
1255
+ async function getAvailableModels(query, models, settingsManager) {
912
1256
  const settings = settingsManager.getSettings();
913
1257
  let currentModel = models[0];
914
1258
  if (settings.model) {
915
- const match = models.find((m) => m.value === settings.model ||
916
- m.value.includes(settings.model) ||
917
- settings.model.includes(m.value) ||
918
- m.displayName.toLowerCase() === settings.model.toLowerCase() ||
919
- m.displayName.toLowerCase().includes(settings.model.toLowerCase()));
1259
+ const match = resolveModelPreference(models, settings.model);
920
1260
  if (match) {
921
1261
  currentModel = match;
922
1262
  }
@@ -988,10 +1328,10 @@ export function promptToClaude(prompt) {
988
1328
  case "text": {
989
1329
  let text = chunk.text;
990
1330
  // change /mcp:server:command args -> /server:command (MCP) args
991
- const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
1331
+ const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/);
992
1332
  if (mcpMatch) {
993
1333
  const [, server, command, args] = mcpMatch;
994
- text = `/${server}:${command} (MCP)${args || ""}`;
1334
+ text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
995
1335
  }
996
1336
  content.push({ type: "text", text });
997
1337
  break;
@@ -1062,19 +1402,25 @@ export function promptToClaude(prompt) {
1062
1402
  */
1063
1403
  export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
1064
1404
  const registerHooks = options?.registerHooks !== false;
1405
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
1065
1406
  if (typeof content === "string") {
1066
- return [
1067
- {
1068
- sessionId,
1069
- update: {
1070
- sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
1071
- content: {
1072
- type: "text",
1073
- text: content,
1074
- },
1075
- },
1407
+ const update = {
1408
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
1409
+ content: {
1410
+ type: "text",
1411
+ text: content,
1076
1412
  },
1077
- ];
1413
+ };
1414
+ if (options?.parentToolUseId) {
1415
+ update._meta = {
1416
+ ...update._meta,
1417
+ claudeCode: {
1418
+ ...(update._meta?.claudeCode || {}),
1419
+ parentToolUseId: options.parentToolUseId,
1420
+ },
1421
+ };
1422
+ }
1423
+ return [{ sessionId, update }];
1078
1424
  }
1079
1425
  const output = [];
1080
1426
  // Only handle the first chunk for streaming; extend as needed for batching
@@ -1115,6 +1461,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1115
1461
  case "tool_use":
1116
1462
  case "server_tool_use":
1117
1463
  case "mcp_tool_use": {
1464
+ const alreadyCached = chunk.id in toolUseCache;
1118
1465
  toolUseCache[chunk.id] = chunk;
1119
1466
  if (chunk.name === "TodoWrite") {
1120
1467
  // @ts-expect-error - sometimes input is empty object
@@ -1126,11 +1473,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1126
1473
  }
1127
1474
  }
1128
1475
  else {
1129
- if (registerHooks) {
1476
+ // Only register hooks on first encounter to avoid double-firing
1477
+ if (registerHooks && !alreadyCached) {
1130
1478
  registerHookCallback(chunk.id, {
1131
1479
  onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
1132
1480
  const toolUse = toolUseCache[toolUseId];
1133
1481
  if (toolUse) {
1482
+ const editDiff = toolUse.name === "Edit" ? toolUpdateFromEditToolResponse(toolResponse) : {};
1134
1483
  const update = {
1135
1484
  _meta: {
1136
1485
  claudeCode: {
@@ -1140,6 +1489,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1140
1489
  },
1141
1490
  toolCallId: toolUseId,
1142
1491
  sessionUpdate: "tool_call_update",
1492
+ ...editDiff,
1143
1493
  };
1144
1494
  await client.sessionUpdate({
1145
1495
  sessionId,
@@ -1147,7 +1497,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1147
1497
  });
1148
1498
  }
1149
1499
  else {
1150
- logger.error(`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`);
1500
+ logger.error(`[claude-agent-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`);
1151
1501
  }
1152
1502
  },
1153
1503
  });
@@ -1159,18 +1509,41 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1159
1509
  catch {
1160
1510
  // ignore if we can't turn it to JSON
1161
1511
  }
1162
- update = {
1163
- _meta: {
1164
- claudeCode: {
1165
- toolName: chunk.name,
1512
+ if (alreadyCached) {
1513
+ // Second encounter (full assistant message after streaming) —
1514
+ // send as tool_call_update to refine the existing tool_call
1515
+ // rather than emitting a duplicate tool_call.
1516
+ update = {
1517
+ _meta: {
1518
+ claudeCode: {
1519
+ toolName: chunk.name,
1520
+ },
1166
1521
  },
1167
- },
1168
- toolCallId: chunk.id,
1169
- sessionUpdate: "tool_call",
1170
- rawInput,
1171
- status: "pending",
1172
- ...toolInfoFromToolUse(chunk),
1173
- };
1522
+ toolCallId: chunk.id,
1523
+ sessionUpdate: "tool_call_update",
1524
+ rawInput,
1525
+ ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
1526
+ };
1527
+ }
1528
+ else {
1529
+ // First encounter (streaming content_block_start or replay) —
1530
+ // send as tool_call with terminal_info for Bash tools.
1531
+ update = {
1532
+ _meta: {
1533
+ claudeCode: {
1534
+ toolName: chunk.name,
1535
+ },
1536
+ ...(chunk.name === "Bash" && supportsTerminalOutput
1537
+ ? { terminal_info: { terminal_id: chunk.id } }
1538
+ : {}),
1539
+ },
1540
+ toolCallId: chunk.id,
1541
+ sessionUpdate: "tool_call",
1542
+ rawInput,
1543
+ status: "pending",
1544
+ ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
1545
+ };
1546
+ }
1174
1547
  }
1175
1548
  break;
1176
1549
  }
@@ -1184,21 +1557,43 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1184
1557
  case "mcp_tool_result": {
1185
1558
  const toolUse = toolUseCache[chunk.tool_use_id];
1186
1559
  if (!toolUse) {
1187
- logger.error(`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
1560
+ logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
1188
1561
  break;
1189
1562
  }
1190
1563
  if (toolUse.name !== "TodoWrite") {
1564
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
1565
+ // When terminal output is supported, send terminal_output as a
1566
+ // separate notification to match codex-acp's streaming lifecycle:
1567
+ // 1. tool_call → _meta.terminal_info (already sent above)
1568
+ // 2. tool_call_update → _meta.terminal_output (sent here)
1569
+ // 3. tool_call_update → _meta.terminal_exit (sent below with status)
1570
+ if (toolMeta?.terminal_output) {
1571
+ output.push({
1572
+ sessionId,
1573
+ update: {
1574
+ _meta: {
1575
+ terminal_output: toolMeta.terminal_output,
1576
+ ...(options?.parentToolUseId
1577
+ ? { claudeCode: { parentToolUseId: options.parentToolUseId } }
1578
+ : {}),
1579
+ },
1580
+ toolCallId: chunk.tool_use_id,
1581
+ sessionUpdate: "tool_call_update",
1582
+ },
1583
+ });
1584
+ }
1191
1585
  update = {
1192
1586
  _meta: {
1193
1587
  claudeCode: {
1194
1588
  toolName: toolUse.name,
1195
1589
  },
1590
+ ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
1196
1591
  },
1197
1592
  toolCallId: chunk.tool_use_id,
1198
1593
  sessionUpdate: "tool_call_update",
1199
1594
  status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
1200
1595
  rawOutput: chunk.content,
1201
- ...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
1596
+ ...toolUpdate,
1202
1597
  };
1203
1598
  }
1204
1599
  break;
@@ -1218,18 +1613,35 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1218
1613
  break;
1219
1614
  }
1220
1615
  if (update) {
1616
+ if (options?.parentToolUseId) {
1617
+ update._meta = {
1618
+ ...update._meta,
1619
+ claudeCode: {
1620
+ ...(update._meta?.claudeCode || {}),
1621
+ parentToolUseId: options.parentToolUseId,
1622
+ },
1623
+ };
1624
+ }
1221
1625
  output.push({ sessionId, update });
1222
1626
  }
1223
1627
  }
1224
1628
  return output;
1225
1629
  }
1226
- export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger) {
1630
+ export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
1227
1631
  const event = message.event;
1228
1632
  switch (event.type) {
1229
1633
  case "content_block_start":
1230
- return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger);
1634
+ return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
1635
+ clientCapabilities: options?.clientCapabilities,
1636
+ parentToolUseId: message.parent_tool_use_id,
1637
+ cwd: options?.cwd,
1638
+ });
1231
1639
  case "content_block_delta":
1232
- return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger);
1640
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
1641
+ clientCapabilities: options?.clientCapabilities,
1642
+ parentToolUseId: message.parent_tool_use_id,
1643
+ cwd: options?.cwd,
1644
+ });
1233
1645
  // No content
1234
1646
  case "message_start":
1235
1647
  case "message_delta":
@@ -1247,3 +1659,24 @@ export function runAcp() {
1247
1659
  const stream = ndJsonStream(input, output);
1248
1660
  new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
1249
1661
  }
1662
+ function commonPrefixLength(a, b) {
1663
+ let i = 0;
1664
+ while (i < a.length && i < b.length && a[i] === b[i]) {
1665
+ i++;
1666
+ }
1667
+ return i;
1668
+ }
1669
+ function getMatchingModelUsage(modelUsage, currentModel) {
1670
+ let bestKey = null;
1671
+ let bestLen = 0;
1672
+ for (const key of Object.keys(modelUsage)) {
1673
+ const len = commonPrefixLength(key, currentModel);
1674
+ if (len > bestLen) {
1675
+ bestLen = len;
1676
+ bestKey = key;
1677
+ }
1678
+ }
1679
+ if (bestKey) {
1680
+ return modelUsage[bestKey];
1681
+ }
1682
+ }