claude-code-acp-ts 0.16.1 → 0.24.3

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,9 +21,51 @@ function sanitizeTitle(text) {
28
21
  }
29
22
  return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
30
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
+ }
31
35
  // Bypass Permissions doesn't work if we are a root/sudo user
32
36
  const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
33
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
+ }
34
69
  // Implement the ACP Agent interface
35
70
  export class ClaudeAcpAgent {
36
71
  constructor(client, logger) {
@@ -42,26 +77,46 @@ export class ClaudeAcpAgent {
42
77
  }
43
78
  async initialize(request) {
44
79
  this.clientCapabilities = request.clientCapabilities;
45
- // Default authMethod
46
- 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 = {
47
94
  description: "Run `claude /login` in the terminal",
48
- name: "Log in with Claude Code",
95
+ name: "Log in with Claude",
49
96
  id: "claude-login",
97
+ type: "terminal",
98
+ args: ["--cli"],
50
99
  };
100
+ const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
51
101
  // If client supports terminal-auth capability, use that instead.
52
- if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
53
- const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
54
- authMethod._meta = {
102
+ const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
103
+ if (supportsMetaTerminalAuth) {
104
+ terminalAuthMethod._meta = {
55
105
  "terminal-auth": {
56
- command: "node",
57
- args: [cliPath, "/login"],
58
- label: "Claude Code Login",
106
+ command: process.execPath,
107
+ args: [...process.argv.slice(1), "--cli"],
108
+ label: "Claude Login",
59
109
  },
60
110
  };
61
111
  }
62
112
  return {
63
113
  protocolVersion: 1,
64
114
  agentCapabilities: {
115
+ _meta: {
116
+ claudeCode: {
117
+ promptQueueing: true,
118
+ },
119
+ },
65
120
  promptCapabilities: {
66
121
  image: true,
67
122
  embeddedContext: true,
@@ -75,28 +130,40 @@ export class ClaudeAcpAgent {
75
130
  fork: {},
76
131
  list: {},
77
132
  resume: {},
133
+ close: {},
78
134
  },
79
135
  },
80
136
  agentInfo: {
81
137
  name: packageJson.name,
82
- title: "Claude Code",
138
+ title: "Claude Agent",
83
139
  version: packageJson.version,
84
140
  },
85
- authMethods: [authMethod],
141
+ authMethods: [
142
+ ...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
143
+ ? [terminalAuthMethod]
144
+ : []),
145
+ ...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
146
+ ],
86
147
  };
87
148
  }
88
149
  async newSession(params) {
89
- if (fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
150
+ if (!this.gatewayAuthMeta &&
151
+ fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
90
152
  !fs.existsSync(path.resolve(os.homedir(), ".claude.json"))) {
91
153
  throw RequestError.authRequired();
92
154
  }
93
- return await this.createSession(params, {
155
+ const response = await this.createSession(params, {
94
156
  // Revisit these meta values once we support resume
95
157
  resume: params._meta?.claudeCode?.options?.resume,
96
158
  });
159
+ // Needs to happen after we return the session
160
+ setTimeout(() => {
161
+ this.sendAvailableCommandsUpdate(response.sessionId);
162
+ }, 0);
163
+ return response;
97
164
  }
98
165
  async unstable_forkSession(params) {
99
- return await this.createSession({
166
+ const response = await this.createSession({
100
167
  cwd: params.cwd,
101
168
  mcpServers: params.mcpServers ?? [],
102
169
  _meta: params._meta,
@@ -104,401 +171,534 @@ export class ClaudeAcpAgent {
104
171
  resume: params.sessionId,
105
172
  forkSession: true,
106
173
  });
174
+ // Needs to happen after we return the session
175
+ setTimeout(() => {
176
+ this.sendAvailableCommandsUpdate(response.sessionId);
177
+ }, 0);
178
+ return response;
107
179
  }
108
180
  async unstable_resumeSession(params) {
109
- const response = await this.createSession({
110
- cwd: params.cwd,
111
- mcpServers: params.mcpServers ?? [],
112
- _meta: params._meta,
113
- }, {
114
- resume: params.sessionId,
115
- });
116
- 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;
117
187
  }
118
188
  async loadSession(params) {
119
- try {
120
- await fs.promises.access(sessionFilePath(params.cwd, params.sessionId));
121
- }
122
- catch {
123
- 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
+ });
124
209
  }
125
- const response = await this.createSession({
126
- cwd: params.cwd,
127
- mcpServers: params.mcpServers ?? [],
128
- _meta: params._meta,
129
- }, {
130
- resume: params.sessionId,
131
- });
132
- await this.replaySessionHistory(params.sessionId, params.cwd);
133
210
  return {
134
- modes: response.modes,
135
- models: response.models,
211
+ sessions,
136
212
  };
137
213
  }
138
- /**
139
- * List Claude Code sessions by parsing JSONL files
140
- * Sessions are stored in ~/.claude/projects/<path-encoded>/
141
- * Implements the draft session/list RFD spec
142
- */
143
- async unstable_listSessions(params) {
144
- // Note: We load all sessions into memory for sorting, so pagination here is for
145
- // API response size limits rather than memory efficiency. This matches the RFD spec.
146
- const PAGE_SIZE = 50;
147
- const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects");
148
- try {
149
- 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
+ }
150
253
  }
151
- catch {
152
- return { sessions: [] };
254
+ else {
255
+ session.input.push(userMessage);
153
256
  }
154
- // Collect all sessions across all project directories
155
- const allSessions = [];
156
- const encodedCwdFilter = params.cwd ? encodeProjectPath(params.cwd) : null;
257
+ session.promptRunning = true;
258
+ let handedOff = false;
259
+ let stopReason = "end_turn";
157
260
  try {
158
- const projectDirs = await fs.promises.readdir(claudeDir);
159
- for (const encodedPath of projectDirs) {
160
- const projectDir = path.join(claudeDir, encodedPath);
161
- const stat = await fs.promises.stat(projectDir);
162
- if (!stat.isDirectory())
163
- continue;
164
- // Path encoding is not always reversible (hyphens can be separators or literals),
165
- // so only use encoded value as a coarse pre-filter.
166
- if (encodedCwdFilter && encodedPath !== encodedCwdFilter)
167
- continue;
168
- const files = await fs.promises.readdir(projectDir);
169
- // Filter to user session files only. Skip agent-*.jsonl files which contain
170
- // internal agent metadata and system logs, not user-visible conversation sessions.
171
- const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
172
- for (const file of jsonlFiles) {
173
- const filePath = path.join(projectDir, file);
174
- try {
175
- const content = await fs.promises.readFile(filePath, "utf-8");
176
- const lines = content.trim().split("\n").filter(Boolean);
177
- const sessionId = file.replace(".jsonl", "");
178
- let parsedAnyEntry = false;
179
- let sessionCwd;
180
- // Find first user message for title
181
- let title;
182
- for (const line of lines) {
183
- try {
184
- const entry = JSON.parse(line);
185
- parsedAnyEntry = true;
186
- if (entry.isSidechain === true) {
187
- 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
+ });
188
283
  }
189
- const entrySessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
190
- if (typeof entry.sessionId === "string" && entry.sessionId !== entrySessionId) {
191
- 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) };
192
329
  }
193
- if (typeof entry.cwd === "string") {
194
- sessionCwd = entry.cwd;
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();
195
382
  }
196
- if (!title && entry.type === "user" && entry.message?.content) {
197
- const msgContent = entry.message.content;
198
- if (typeof msgContent === "string") {
199
- title = sanitizeTitle(msgContent);
200
- }
201
- if (Array.isArray(msgContent) && msgContent.length > 0) {
202
- const first = msgContent[0];
203
- const text = typeof first === "string"
204
- ? first
205
- : first && typeof first === "object" && typeof first.text === "string"
206
- ? first.text
207
- : undefined;
208
- if (text) {
209
- title = sanitizeTitle(text);
210
- }
383
+ if (message.stop_reason === "max_tokens") {
384
+ stopReason = "max_tokens";
385
+ break;
386
+ }
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);
211
395
  }
212
396
  }
213
- // Continue scanning until we have both fields, since cwd can appear
214
- // in later entries even after the first user title-bearing message.
215
- if (title && sessionCwd) {
397
+ break;
398
+ }
399
+ case "error_during_execution": {
400
+ if (message.stop_reason === "max_tokens") {
401
+ stopReason = "max_tokens";
216
402
  break;
217
403
  }
404
+ if (message.is_error) {
405
+ throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
406
+ }
407
+ stopReason = "end_turn";
408
+ break;
218
409
  }
219
- catch {
220
- // Skip malformed lines
221
- }
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;
222
421
  }
223
- if (!parsedAnyEntry)
224
- continue;
225
- // SessionInfo.cwd is currently required. For entries that do not
226
- // include an explicit cwd in the session JSONL (typically metadata-only files),
227
- // we skip them instead of decoding folder names because path encoding is lossy.
228
- if (!sessionCwd)
229
- continue;
230
- // Even after encoded-path pre-filtering, verify per-entry cwd to disambiguate
231
- // collisions such as "/a-b" and "/a/b" that map to the same encoded folder name.
232
- if (params.cwd && sessionCwd !== params.cwd)
233
- continue;
234
- // Get file modification time as updatedAt
235
- const fileStat = await fs.promises.stat(filePath);
236
- const updatedAt = fileStat.mtime.toISOString();
237
- allSessions.push({
238
- sessionId,
239
- cwd: sessionCwd,
240
- title: title ?? null,
241
- updatedAt,
242
- });
422
+ break;
243
423
  }
244
- catch (err) {
245
- 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;
246
432
  }
247
- }
248
- }
249
- }
250
- catch (err) {
251
- this.logger.error("[unstable_listSessions] Failed to list sessions", err);
252
- return { sessions: [] };
253
- }
254
- // Sort by updatedAt descending (most recent first)
255
- allSessions.sort((a, b) => {
256
- const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
257
- const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
258
- return timeB - timeA;
259
- });
260
- // Handle pagination with cursor
261
- let startIndex = 0;
262
- if (params.cursor) {
263
- try {
264
- const decoded = Buffer.from(params.cursor, "base64").toString("utf-8");
265
- const cursorData = JSON.parse(decoded);
266
- startIndex = cursorData.offset ?? 0;
267
- }
268
- catch {
269
- // Invalid cursor, start from beginning
270
- }
271
- }
272
- const pageOfSessions = allSessions.slice(startIndex, startIndex + PAGE_SIZE);
273
- const hasMore = startIndex + PAGE_SIZE < allSessions.length;
274
- const response = {
275
- sessions: pageOfSessions,
276
- };
277
- if (hasMore) {
278
- const nextCursor = Buffer.from(JSON.stringify({ offset: startIndex + PAGE_SIZE })).toString("base64");
279
- response.nextCursor = nextCursor;
280
- }
281
- return response;
282
- }
283
- async authenticate(_params) {
284
- throw new Error("Method not implemented.");
285
- }
286
- async prompt(params) {
287
- if (!this.sessions[params.sessionId]) {
288
- throw new Error("Session not found");
289
- }
290
- this.sessions[params.sessionId].cancelled = false;
291
- const { query, input } = this.sessions[params.sessionId];
292
- input.push(promptToClaude(params));
293
- while (true) {
294
- const { value: message, done } = await query.next();
295
- if (done || !message) {
296
- if (this.sessions[params.sessionId].cancelled) {
297
- return { stopReason: "cancelled" };
298
- }
299
- break;
300
- }
301
- switch (message.type) {
302
- case "system":
303
- switch (message.subtype) {
304
- case "init":
305
- break;
306
- case "compact_boundary":
307
- case "hook_started":
308
- case "task_notification":
309
- case "hook_progress":
310
- case "hook_response":
311
- case "status":
312
- case "files_persisted":
313
- // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
314
- break;
315
- default:
316
- unreachable(message, this.logger);
433
+ case "user":
434
+ case "assistant": {
435
+ if (session.cancelled) {
317
436
  break;
318
- }
319
- break;
320
- case "result": {
321
- if (this.sessions[params.sessionId].cancelled) {
322
- return { stopReason: "cancelled" };
323
- }
324
- switch (message.subtype) {
325
- case "success": {
326
- if (message.result.includes("Please run /login")) {
327
- throw RequestError.authRequired();
328
- }
329
- if (message.is_error) {
330
- throw RequestError.internalError(undefined, message.result);
331
- }
332
- return { stopReason: "end_turn" };
333
437
  }
334
- case "error_during_execution":
335
- if (message.is_error) {
336
- 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;
337
442
  }
338
- return { stopReason: "end_turn" };
339
- case "error_max_budget_usd":
340
- case "error_max_turns":
341
- case "error_max_structured_output_retries":
342
- if (message.is_error) {
343
- 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) };
344
451
  }
345
- return { stopReason: "max_turn_requests" };
346
- default:
347
- unreachable(message, this.logger);
348
- break;
349
- }
350
- break;
351
- }
352
- case "stream_event": {
353
- for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.client, this.logger)) {
354
- await this.client.sessionUpdate(notification);
355
- }
356
- break;
357
- }
358
- case "user":
359
- case "assistant": {
360
- if (this.sessions[params.sessionId].cancelled) {
361
- break;
362
- }
363
- // Slash commands like /compact can generate invalid output... doesn't match
364
- // their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
365
- if (typeof message.message.content === "string" &&
366
- message.message.content.includes("<local-command-stdout>")) {
367
- // Handle /context by sending its reply as regular agent message.
368
- if (message.message.content.includes("Context Usage")) {
369
- for (const notification of toAcpNotifications(message.message.content
370
- .replace("<local-command-stdout>", "")
371
- .replace("</local-command-stdout>", ""), "assistant", params.sessionId, this.toolUseCache, this.client, this.logger)) {
372
- await this.client.sessionUpdate(notification);
452
+ if ("isReplay" in message && message.isReplay) {
453
+ // not pending or unrelated replay message
454
+ break;
373
455
  }
374
456
  }
375
- 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
+ }
376
518
  break;
377
519
  }
378
- if (typeof message.message.content === "string" &&
379
- message.message.content.includes("<local-command-stderr>")) {
380
- 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":
381
525
  break;
382
- }
383
- // Skip these user messages for now, since they seem to just be messages we don't want in the feed
384
- if (message.type === "user" &&
385
- (typeof message.message.content === "string" ||
386
- (Array.isArray(message.message.content) &&
387
- message.message.content.length === 1 &&
388
- message.message.content[0].type === "text"))) {
526
+ default:
527
+ unreachable(message);
389
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]);
390
562
  }
391
- if (message.type === "assistant" &&
392
- message.message.model === "<synthetic>" &&
393
- Array.isArray(message.message.content) &&
394
- message.message.content.length === 1 &&
395
- message.message.content[0].type === "text" &&
396
- message.message.content[0].text.includes("Please run /login")) {
397
- throw RequestError.authRequired();
398
- }
399
- const content = message.type === "assistant"
400
- ? // Handled by stream events above
401
- message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
402
- : message.message.content;
403
- for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger)) {
404
- await this.client.sessionUpdate(notification);
405
- }
406
- break;
407
563
  }
408
- case "tool_progress":
409
- case "tool_use_summary":
410
- break;
411
- case "auth_status":
412
- break;
413
- default:
414
- unreachable(message);
415
- break;
416
564
  }
417
565
  }
418
- throw new Error("Session did not end in result");
419
566
  }
420
567
  async cancel(params) {
421
- if (!this.sessions[params.sessionId]) {
568
+ const session = this.sessions[params.sessionId];
569
+ if (!session) {
422
570
  throw new Error("Session not found");
423
571
  }
424
- this.sessions[params.sessionId].cancelled = true;
425
- 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 {};
426
589
  }
427
590
  async unstable_setSessionModel(params) {
428
591
  if (!this.sessions[params.sessionId]) {
429
592
  throw new Error("Session not found");
430
593
  }
431
594
  await this.sessions[params.sessionId].query.setModel(params.modelId);
595
+ await this.updateConfigOption(params.sessionId, "model", params.modelId);
432
596
  }
433
597
  async setSessionMode(params) {
434
598
  if (!this.sessions[params.sessionId]) {
435
599
  throw new Error("Session not found");
436
600
  }
437
- 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) {
438
662
  case "default":
439
663
  case "acceptEdits":
440
664
  case "bypassPermissions":
441
665
  case "dontAsk":
442
666
  case "plan":
443
- this.sessions[params.sessionId].permissionMode = params.modeId;
444
- try {
445
- await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
446
- }
447
- catch (error) {
448
- const errorMessage = error instanceof Error && error.message ? error.message : "Invalid Mode";
449
- throw new Error(errorMessage);
450
- }
451
- return {};
667
+ break;
452
668
  default:
453
669
  throw new Error("Invalid Mode");
454
670
  }
455
- }
456
- async replaySessionHistory(sessionId, cwd) {
457
- const filePath = sessionFilePath(cwd, sessionId);
458
- const toolUseCache = {};
459
- const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
460
- const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
461
671
  try {
462
- for await (const line of reader) {
463
- const trimmed = line.trim();
464
- if (!trimmed) {
465
- continue;
466
- }
467
- let entry;
468
- try {
469
- entry = JSON.parse(trimmed);
470
- }
471
- catch {
472
- continue;
473
- }
474
- if (entry.type !== "user" && entry.type !== "assistant") {
475
- continue;
476
- }
477
- if (entry.isSidechain) {
478
- continue;
479
- }
480
- if (entry.sessionId && entry.sessionId !== sessionId) {
481
- continue;
482
- }
483
- const message = entry.message;
484
- if (!message) {
485
- continue;
486
- }
487
- const role = message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null;
488
- if (!role) {
489
- continue;
490
- }
491
- const content = message.content;
492
- if (typeof content !== "string" && !Array.isArray(content)) {
493
- continue;
494
- }
495
- for (const notification of toAcpNotifications(content, role, sessionId, toolUseCache, this.client, this.logger, { registerHooks: false })) {
496
- 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";
497
678
  }
679
+ throw error;
680
+ }
681
+ else {
682
+ // eslint-disable-next-line preserve-caught-error
683
+ throw new Error("Invalid Mode");
498
684
  }
499
685
  }
500
- finally {
501
- 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
+ }
502
702
  }
503
703
  }
504
704
  async readTextFile(params) {
@@ -511,38 +711,47 @@ export class ClaudeAcpAgent {
511
711
  }
512
712
  canUseTool(sessionId) {
513
713
  return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
714
+ const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
514
715
  const session = this.sessions[sessionId];
515
716
  if (!session) {
516
717
  return {
517
718
  behavior: "deny",
518
719
  message: "Session not found",
519
- interrupt: true,
520
720
  };
521
721
  }
522
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
+ }
523
739
  const response = await this.client.requestPermission({
524
- options: [
525
- {
526
- kind: "allow_always",
527
- name: "Yes, and auto-accept edits",
528
- optionId: "acceptEdits",
529
- },
530
- { kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
531
- { kind: "reject_once", name: "No, keep planning", optionId: "plan" },
532
- ],
740
+ options,
533
741
  sessionId,
534
742
  toolCall: {
535
743
  toolCallId: toolUseID,
536
744
  rawInput: toolInput,
537
- title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
745
+ ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
538
746
  },
539
747
  });
540
748
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
541
749
  throw new Error("Tool use aborted");
542
750
  }
543
751
  if (response.outcome?.outcome === "selected" &&
544
- (response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")) {
545
- session.permissionMode = response.outcome.optionId;
752
+ (response.outcome.optionId === "default" ||
753
+ response.outcome.optionId === "acceptEdits" ||
754
+ response.outcome.optionId === "bypassPermissions")) {
546
755
  await this.client.sessionUpdate({
547
756
  sessionId,
548
757
  update: {
@@ -550,6 +759,7 @@ export class ClaudeAcpAgent {
550
759
  currentModeId: response.outcome.optionId,
551
760
  },
552
761
  });
762
+ await this.updateConfigOption(sessionId, "mode", response.outcome.optionId);
553
763
  return {
554
764
  behavior: "allow",
555
765
  updatedInput: toolInput,
@@ -562,12 +772,10 @@ export class ClaudeAcpAgent {
562
772
  return {
563
773
  behavior: "deny",
564
774
  message: "User rejected request to exit plan mode.",
565
- interrupt: true,
566
775
  };
567
776
  }
568
777
  }
569
- if (session.permissionMode === "bypassPermissions" ||
570
- (session.permissionMode === "acceptEdits" && EDIT_TOOL_NAMES.includes(toolName))) {
778
+ if (session.modes.currentModeId === "bypassPermissions") {
571
779
  return {
572
780
  behavior: "allow",
573
781
  updatedInput: toolInput,
@@ -590,7 +798,7 @@ export class ClaudeAcpAgent {
590
798
  toolCall: {
591
799
  toolCallId: toolUseID,
592
800
  rawInput: toolInput,
593
- title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
801
+ ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
594
802
  },
595
803
  });
596
804
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
@@ -622,11 +830,69 @@ export class ClaudeAcpAgent {
622
830
  return {
623
831
  behavior: "deny",
624
832
  message: "User refused permission to run tool",
625
- interrupt: true,
626
833
  };
627
834
  }
628
835
  };
629
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
+ }
630
896
  async createSession(params, creationOpts = {}) {
631
897
  // We want to create a new session id unless it is resume,
632
898
  // but not resume + forkSession.
@@ -648,7 +914,8 @@ export class ClaudeAcpAgent {
648
914
  const mcpServers = {};
649
915
  if (Array.isArray(params.mcpServers)) {
650
916
  for (const server of params.mcpServers) {
651
- if ("type" in server) {
917
+ if ("type" in server && (server.type === "http" || server.type === "sse")) {
918
+ // HTTP or SSE type MCP server
652
919
  mcpServers[server.name] = {
653
920
  type: server.type,
654
921
  url: server.url,
@@ -658,6 +925,7 @@ export class ClaudeAcpAgent {
658
925
  };
659
926
  }
660
927
  else {
928
+ // Stdio type MCP server (with or without explicit type field)
661
929
  mcpServers[server.name] = {
662
930
  type: "stdio",
663
931
  command: server.command,
@@ -669,15 +937,6 @@ export class ClaudeAcpAgent {
669
937
  }
670
938
  }
671
939
  }
672
- // Only add the acp MCP server if built-in tools are not disabled
673
- if (!params._meta?.disableBuiltInTools) {
674
- const server = createMcpServer(this, sessionId, this.clientCapabilities);
675
- mcpServers["acp"] = {
676
- type: "sdk",
677
- name: "acp",
678
- instance: server,
679
- };
680
- }
681
940
  let systemPrompt = { type: "preset", preset: "claude_code" };
682
941
  if (params._meta?.systemPrompt) {
683
942
  const customPrompt = params._meta.systemPrompt;
@@ -690,19 +949,35 @@ export class ClaudeAcpAgent {
690
949
  systemPrompt.append = customPrompt.append;
691
950
  }
692
951
  }
693
- const permissionMode = "default";
952
+ const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode);
694
953
  // Extract options from _meta if provided
695
- const userProvidedOptions = params._meta?.claudeCode?.options;
954
+ const sessionMeta = params._meta;
955
+ const userProvidedOptions = sessionMeta?.claudeCode?.options;
696
956
  // Configure thinking tokens from environment variable
697
957
  const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
698
958
  ? parseInt(process.env.MAX_THINKING_TOKENS, 10)
699
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();
700
969
  const options = {
701
970
  systemPrompt,
702
971
  settingSources: ["user", "project", "local"],
703
- stderr: (err) => this.logger.error(err),
704
972
  ...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
705
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
+ },
706
981
  // Override certain fields that must be controlled by ACP
707
982
  cwd: params.cwd,
708
983
  includePartialMessages: true,
@@ -714,29 +989,26 @@ export class ClaudeAcpAgent {
714
989
  canUseTool: this.canUseTool(sessionId),
715
990
  // note: although not documented by the types, passing an absolute path
716
991
  // here works to find zed's managed node version.
717
- executable: process.execPath,
718
- ...(process.env.CLAUDE_CODE_EXECUTABLE && {
719
- pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
720
- }),
721
- 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,
722
1004
  hooks: {
723
1005
  ...userProvidedOptions?.hooks,
724
- PreToolUse: [
725
- ...(userProvidedOptions?.hooks?.PreToolUse || []),
726
- {
727
- hooks: [createPreToolUseHook(settingsManager, this.logger)],
728
- },
729
- ],
730
1006
  PostToolUse: [
731
1007
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
732
1008
  {
733
1009
  hooks: [
734
1010
  createPostToolUseHook(this.logger, {
735
1011
  onEnterPlanMode: async () => {
736
- const session = this.sessions[sessionId];
737
- if (session) {
738
- session.permissionMode = "plan";
739
- }
740
1012
  await this.client.sessionUpdate({
741
1013
  sessionId,
742
1014
  update: {
@@ -744,6 +1016,7 @@ export class ClaudeAcpAgent {
744
1016
  currentModeId: "plan",
745
1017
  },
746
1018
  });
1019
+ await this.updateConfigOption(sessionId, "mode", "plan");
747
1020
  },
748
1021
  }),
749
1022
  ],
@@ -751,41 +1024,17 @@ export class ClaudeAcpAgent {
751
1024
  ],
752
1025
  },
753
1026
  ...creationOpts,
1027
+ abortController,
754
1028
  };
1029
+ options.additionalDirectories = [
1030
+ ...(userProvidedOptions?.additionalDirectories ?? []),
1031
+ ...(sessionMeta?.additionalRoots ?? []),
1032
+ ];
755
1033
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
756
1034
  // Set our own session id if not resuming an existing session.
757
1035
  options.sessionId = sessionId;
758
1036
  }
759
- const allowedTools = [];
760
- // Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
761
- const disallowedTools = ["AskUserQuestion"];
762
- // Check if built-in tools should be disabled
763
- const disableBuiltInTools = params._meta?.disableBuiltInTools === true;
764
- if (!disableBuiltInTools) {
765
- if (this.clientCapabilities?.fs?.readTextFile) {
766
- allowedTools.push(acpToolNames.read);
767
- disallowedTools.push("Read");
768
- }
769
- if (this.clientCapabilities?.fs?.writeTextFile) {
770
- disallowedTools.push("Write", "Edit");
771
- }
772
- if (this.clientCapabilities?.terminal) {
773
- allowedTools.push(acpToolNames.bashOutput, acpToolNames.killShell);
774
- disallowedTools.push("Bash", "BashOutput", "KillShell");
775
- }
776
- }
777
- else {
778
- // When built-in tools are disabled, explicitly disallow all of them
779
- 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");
780
- }
781
- if (allowedTools.length > 0) {
782
- options.allowedTools = allowedTools;
783
- }
784
- if (disallowedTools.length > 0) {
785
- options.disallowedTools = [...(options.disallowedTools || []), ...disallowedTools];
786
- }
787
1037
  // Handle abort controller from meta options
788
- const abortController = userProvidedOptions?.abortController;
789
1038
  if (abortController?.signal.aborted) {
790
1039
  throw new Error("Cancelled");
791
1040
  }
@@ -802,19 +1051,24 @@ export class ClaudeAcpAgent {
802
1051
  prompt: input,
803
1052
  options,
804
1053
  });
805
- this.sessions[sessionId] = {
806
- query: q,
807
- input: input,
808
- cancelled: false,
809
- permissionMode,
810
- settingsManager,
811
- };
812
- // 跳过远程拉取模型列表和斜杠命令,直接使用空默认值
813
- this.logger.log(`📋 [ACP] 跳过 initializationResult (不需要远程拉取模型/命令)`);
814
- const models = {
815
- availableModels: [],
816
- currentModelId: "",
817
- };
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
+ }
1071
+ const models = await getAvailableModels(q, initializationResult.models, settingsManager);
818
1072
  const sessionCreateElapsed = Date.now() - sessionCreateStartTime;
819
1073
  this.logger.log(`✅ [ACP] 会话创建完成, 总耗时: ${sessionCreateElapsed}ms`);
820
1074
  const availableModes = [
@@ -847,16 +1101,217 @@ export class ClaudeAcpAgent {
847
1101
  description: "Bypass all permission checks",
848
1102
  });
849
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
+ };
850
1129
  return {
851
1130
  sessionId,
852
1131
  models,
853
- modes: {
854
- currentModeId: permissionMode,
855
- availableModes,
856
- },
1132
+ modes,
1133
+ configOptions,
857
1134
  };
858
1135
  }
859
1136
  }
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
+ }
1252
+ }
1253
+ return bestMatch;
1254
+ }
1255
+ async function getAvailableModels(query, models, settingsManager) {
1256
+ const settings = settingsManager.getSettings();
1257
+ let currentModel = models[0];
1258
+ // 模型优先级(从高到低):
1259
+ // 1. ANTHROPIC_MODEL 环境变量(用户期望的最高优先级)
1260
+ // 2. settings.model(用户配置文件)
1261
+ // 3. models[0](默认使用第一个模型)
1262
+ if (process.env.ANTHROPIC_MODEL) {
1263
+ const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
1264
+ if (match) {
1265
+ currentModel = match;
1266
+ }
1267
+ }
1268
+ else if (settings.model) {
1269
+ const match = resolveModelPreference(models, settings.model);
1270
+ if (match) {
1271
+ currentModel = match;
1272
+ }
1273
+ }
1274
+ await query.setModel(currentModel.value);
1275
+ return {
1276
+ availableModels: models.map((model) => ({
1277
+ modelId: model.value,
1278
+ name: model.displayName,
1279
+ description: model.description,
1280
+ })),
1281
+ currentModelId: currentModel.value,
1282
+ };
1283
+ }
1284
+ function getAvailableSlashCommands(commands) {
1285
+ const UNSUPPORTED_COMMANDS = [
1286
+ "cost",
1287
+ "keybindings-help",
1288
+ "login",
1289
+ "logout",
1290
+ "output-style:new",
1291
+ "release-notes",
1292
+ "todos",
1293
+ ];
1294
+ return commands
1295
+ .map((command) => {
1296
+ const input = command.argumentHint
1297
+ ? {
1298
+ hint: Array.isArray(command.argumentHint)
1299
+ ? command.argumentHint.join(" ")
1300
+ : command.argumentHint,
1301
+ }
1302
+ : null;
1303
+ let name = command.name;
1304
+ if (command.name.endsWith(" (MCP)")) {
1305
+ name = `mcp:${name.replace(" (MCP)", "")}`;
1306
+ }
1307
+ return {
1308
+ name,
1309
+ description: command.description || "",
1310
+ input,
1311
+ };
1312
+ })
1313
+ .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
1314
+ }
860
1315
  function formatUriAsLink(uri) {
861
1316
  try {
862
1317
  if (uri.startsWith("file://")) {
@@ -883,10 +1338,10 @@ export function promptToClaude(prompt) {
883
1338
  case "text": {
884
1339
  let text = chunk.text;
885
1340
  // change /mcp:server:command args -> /server:command (MCP) args
886
- const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
1341
+ const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/);
887
1342
  if (mcpMatch) {
888
1343
  const [, server, command, args] = mcpMatch;
889
- text = `/${server}:${command} (MCP)${args || ""}`;
1344
+ text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
890
1345
  }
891
1346
  content.push({ type: "text", text });
892
1347
  break;
@@ -957,19 +1412,25 @@ export function promptToClaude(prompt) {
957
1412
  */
958
1413
  export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
959
1414
  const registerHooks = options?.registerHooks !== false;
1415
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
960
1416
  if (typeof content === "string") {
961
- return [
962
- {
963
- sessionId,
964
- update: {
965
- sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
966
- content: {
967
- type: "text",
968
- text: content,
969
- },
970
- },
1417
+ const update = {
1418
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
1419
+ content: {
1420
+ type: "text",
1421
+ text: content,
971
1422
  },
972
- ];
1423
+ };
1424
+ if (options?.parentToolUseId) {
1425
+ update._meta = {
1426
+ ...update._meta,
1427
+ claudeCode: {
1428
+ ...(update._meta?.claudeCode || {}),
1429
+ parentToolUseId: options.parentToolUseId,
1430
+ },
1431
+ };
1432
+ }
1433
+ return [{ sessionId, update }];
973
1434
  }
974
1435
  const output = [];
975
1436
  // Only handle the first chunk for streaming; extend as needed for batching
@@ -1010,6 +1471,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1010
1471
  case "tool_use":
1011
1472
  case "server_tool_use":
1012
1473
  case "mcp_tool_use": {
1474
+ const alreadyCached = chunk.id in toolUseCache;
1013
1475
  toolUseCache[chunk.id] = chunk;
1014
1476
  if (chunk.name === "TodoWrite") {
1015
1477
  // @ts-expect-error - sometimes input is empty object
@@ -1021,11 +1483,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1021
1483
  }
1022
1484
  }
1023
1485
  else {
1024
- if (registerHooks) {
1486
+ // Only register hooks on first encounter to avoid double-firing
1487
+ if (registerHooks && !alreadyCached) {
1025
1488
  registerHookCallback(chunk.id, {
1026
1489
  onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
1027
1490
  const toolUse = toolUseCache[toolUseId];
1028
1491
  if (toolUse) {
1492
+ const editDiff = toolUse.name === "Edit" ? toolUpdateFromEditToolResponse(toolResponse) : {};
1029
1493
  const update = {
1030
1494
  _meta: {
1031
1495
  claudeCode: {
@@ -1035,6 +1499,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1035
1499
  },
1036
1500
  toolCallId: toolUseId,
1037
1501
  sessionUpdate: "tool_call_update",
1502
+ ...editDiff,
1038
1503
  };
1039
1504
  await client.sessionUpdate({
1040
1505
  sessionId,
@@ -1042,7 +1507,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1042
1507
  });
1043
1508
  }
1044
1509
  else {
1045
- logger.error(`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`);
1510
+ logger.error(`[claude-agent-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`);
1046
1511
  }
1047
1512
  },
1048
1513
  });
@@ -1054,18 +1519,41 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1054
1519
  catch {
1055
1520
  // ignore if we can't turn it to JSON
1056
1521
  }
1057
- update = {
1058
- _meta: {
1059
- claudeCode: {
1060
- toolName: chunk.name,
1522
+ if (alreadyCached) {
1523
+ // Second encounter (full assistant message after streaming) —
1524
+ // send as tool_call_update to refine the existing tool_call
1525
+ // rather than emitting a duplicate tool_call.
1526
+ update = {
1527
+ _meta: {
1528
+ claudeCode: {
1529
+ toolName: chunk.name,
1530
+ },
1061
1531
  },
1062
- },
1063
- toolCallId: chunk.id,
1064
- sessionUpdate: "tool_call",
1065
- rawInput,
1066
- status: "pending",
1067
- ...toolInfoFromToolUse(chunk),
1068
- };
1532
+ toolCallId: chunk.id,
1533
+ sessionUpdate: "tool_call_update",
1534
+ rawInput,
1535
+ ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
1536
+ };
1537
+ }
1538
+ else {
1539
+ // First encounter (streaming content_block_start or replay) —
1540
+ // send as tool_call with terminal_info for Bash tools.
1541
+ update = {
1542
+ _meta: {
1543
+ claudeCode: {
1544
+ toolName: chunk.name,
1545
+ },
1546
+ ...(chunk.name === "Bash" && supportsTerminalOutput
1547
+ ? { terminal_info: { terminal_id: chunk.id } }
1548
+ : {}),
1549
+ },
1550
+ toolCallId: chunk.id,
1551
+ sessionUpdate: "tool_call",
1552
+ rawInput,
1553
+ status: "pending",
1554
+ ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
1555
+ };
1556
+ }
1069
1557
  }
1070
1558
  break;
1071
1559
  }
@@ -1079,21 +1567,43 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1079
1567
  case "mcp_tool_result": {
1080
1568
  const toolUse = toolUseCache[chunk.tool_use_id];
1081
1569
  if (!toolUse) {
1082
- logger.error(`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
1570
+ logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
1083
1571
  break;
1084
1572
  }
1085
1573
  if (toolUse.name !== "TodoWrite") {
1574
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
1575
+ // When terminal output is supported, send terminal_output as a
1576
+ // separate notification to match codex-acp's streaming lifecycle:
1577
+ // 1. tool_call → _meta.terminal_info (already sent above)
1578
+ // 2. tool_call_update → _meta.terminal_output (sent here)
1579
+ // 3. tool_call_update → _meta.terminal_exit (sent below with status)
1580
+ if (toolMeta?.terminal_output) {
1581
+ output.push({
1582
+ sessionId,
1583
+ update: {
1584
+ _meta: {
1585
+ terminal_output: toolMeta.terminal_output,
1586
+ ...(options?.parentToolUseId
1587
+ ? { claudeCode: { parentToolUseId: options.parentToolUseId } }
1588
+ : {}),
1589
+ },
1590
+ toolCallId: chunk.tool_use_id,
1591
+ sessionUpdate: "tool_call_update",
1592
+ },
1593
+ });
1594
+ }
1086
1595
  update = {
1087
1596
  _meta: {
1088
1597
  claudeCode: {
1089
1598
  toolName: toolUse.name,
1090
1599
  },
1600
+ ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
1091
1601
  },
1092
1602
  toolCallId: chunk.tool_use_id,
1093
1603
  sessionUpdate: "tool_call_update",
1094
1604
  status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
1095
1605
  rawOutput: chunk.content,
1096
- ...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
1606
+ ...toolUpdate,
1097
1607
  };
1098
1608
  }
1099
1609
  break;
@@ -1113,18 +1623,35 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1113
1623
  break;
1114
1624
  }
1115
1625
  if (update) {
1626
+ if (options?.parentToolUseId) {
1627
+ update._meta = {
1628
+ ...update._meta,
1629
+ claudeCode: {
1630
+ ...(update._meta?.claudeCode || {}),
1631
+ parentToolUseId: options.parentToolUseId,
1632
+ },
1633
+ };
1634
+ }
1116
1635
  output.push({ sessionId, update });
1117
1636
  }
1118
1637
  }
1119
1638
  return output;
1120
1639
  }
1121
- export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger) {
1640
+ export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
1122
1641
  const event = message.event;
1123
1642
  switch (event.type) {
1124
1643
  case "content_block_start":
1125
- return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger);
1644
+ return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
1645
+ clientCapabilities: options?.clientCapabilities,
1646
+ parentToolUseId: message.parent_tool_use_id,
1647
+ cwd: options?.cwd,
1648
+ });
1126
1649
  case "content_block_delta":
1127
- return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger);
1650
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
1651
+ clientCapabilities: options?.clientCapabilities,
1652
+ parentToolUseId: message.parent_tool_use_id,
1653
+ cwd: options?.cwd,
1654
+ });
1128
1655
  // No content
1129
1656
  case "message_start":
1130
1657
  case "message_delta":
@@ -1142,3 +1669,24 @@ export function runAcp() {
1142
1669
  const stream = ndJsonStream(input, output);
1143
1670
  new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
1144
1671
  }
1672
+ function commonPrefixLength(a, b) {
1673
+ let i = 0;
1674
+ while (i < a.length && i < b.length && a[i] === b[i]) {
1675
+ i++;
1676
+ }
1677
+ return i;
1678
+ }
1679
+ function getMatchingModelUsage(modelUsage, currentModel) {
1680
+ let bestKey = null;
1681
+ let bestLen = 0;
1682
+ for (const key of Object.keys(modelUsage)) {
1683
+ const len = commonPrefixLength(key, currentModel);
1684
+ if (len > bestLen) {
1685
+ bestLen = len;
1686
+ bestKey = key;
1687
+ }
1688
+ }
1689
+ if (bestKey) {
1690
+ return modelUsage[bestKey];
1691
+ }
1692
+ }