claude-code-acp-ts 0.24.3 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acp-agent.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
- import { getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
2
+ import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
3
3
  import { randomUUID } from "node:crypto";
4
- import * as fs from "node:fs";
5
4
  import * as os from "node:os";
6
5
  import * as path from "node:path";
7
6
  import packageJson from "../package.json" with { type: "json" };
8
7
  import { SettingsManager } from "./settings.js";
9
- import { createPostToolUseHook, planEntries, registerHookCallback, toolInfoFromToolUse, toolUpdateFromEditToolResponse, toolUpdateFromToolResult, } from "./tools.js";
8
+ import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
10
9
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
11
10
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
12
11
  const MAX_TITLE_LENGTH = 256;
@@ -21,13 +20,63 @@ function sanitizeTitle(text) {
21
20
  }
22
21
  return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
23
22
  }
24
- function isStaticBinary() {
25
- return process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN !== undefined;
23
+ const ZERO_USAGE = Object.freeze({
24
+ input_tokens: 0,
25
+ output_tokens: 0,
26
+ cache_read_input_tokens: 0,
27
+ cache_creation_input_tokens: 0,
28
+ });
29
+ const DEFAULT_CONTEXT_WINDOW = 200000;
30
+ /** Compute a stable fingerprint of the session-defining params so we can
31
+ * detect when a loadSession/resumeSession call requires tearing down and
32
+ * recreating the underlying Query process. MCP servers are sorted by name
33
+ * so that ordering differences don't trigger unnecessary recreations. */
34
+ function computeSessionFingerprint(params) {
35
+ const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
36
+ return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
26
37
  }
27
38
  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");
39
+ if (process.env.CLAUDE_CODE_EXECUTABLE) {
40
+ return process.env.CLAUDE_CODE_EXECUTABLE;
41
+ }
42
+ // The SDK's CLI is a native binary shipped as a platform-specific optional
43
+ // dependency of @anthropic-ai/claude-agent-sdk. Resolve via a require bound
44
+ // to the SDK so nested installs are found even when npm doesn't hoist.
45
+ const { createRequire } = await import("node:module");
46
+ const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk"));
47
+ const ext = process.platform === "win32" ? ".exe" : "";
48
+ // On linux, both glibc and musl variants may be installed side-by-side
49
+ // (e.g. bunx hydrates every optional dep), so picking one by trial is
50
+ // unreliable: the wrong binary segfaults at runtime instead of failing to
51
+ // spawn. Detect the runtime libc and prefer the matching variant, falling
52
+ // back to the other only if the preferred one isn't installed.
53
+ const candidates = process.platform === "linux"
54
+ ? isMuslLibc()
55
+ ? [
56
+ `@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
57
+ `@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
58
+ ]
59
+ : [
60
+ `@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
61
+ `@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
62
+ ]
63
+ : [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`];
64
+ for (const candidate of candidates) {
65
+ try {
66
+ return req.resolve(candidate);
67
+ }
68
+ catch {
69
+ // try next candidate
70
+ }
71
+ }
72
+ throw new Error(`Claude native binary not found for ${process.platform}-${process.arch}. ` +
73
+ `Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE.`);
74
+ }
75
+ function isMuslLibc() {
76
+ // process.report.getReport().header.glibcVersionRuntime is populated when
77
+ // Node is dynamically linked against glibc, and absent on musl.
78
+ const report = process.report?.getReport();
79
+ return !report?.header?.glibcVersionRuntime;
31
80
  }
32
81
  function shouldHideClaudeAuth() {
33
82
  return process.argv.includes("--hide-claude-auth");
@@ -38,7 +87,54 @@ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
38
87
  // Slash commands that the SDK handles locally without replaying the user
39
88
  // message and without invoking the model.
40
89
  const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
90
+ // The Claude SDK persists local slash command invocations (e.g. `/model`) and
91
+ // their output as user messages in the session transcript, wrapping the
92
+ // payload in these XML-like markers that the CLI uses for its own display.
93
+ // The live prompt loop drops them; replay must strip them too or they leak
94
+ // into the UI on session/load.
95
+ const LOCAL_COMMAND_TAG_PATTERN = /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
96
+ function stripMarkerTags(text) {
97
+ return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
98
+ }
99
+ /**
100
+ * Return user-message content with local-command marker tags removed, or
101
+ * `null` if nothing meaningful remains (caller should skip the message).
102
+ * Preserves real prose that's mixed in alongside the markers — e.g. a
103
+ * message like `<command-name>…</command-name>hi` becomes `hi`.
104
+ */
105
+ export function stripLocalCommandMetadata(content) {
106
+ if (typeof content === "string") {
107
+ const stripped = stripMarkerTags(content);
108
+ return stripped.trim() === "" ? null : stripped;
109
+ }
110
+ if (!Array.isArray(content))
111
+ return content;
112
+ const kept = [];
113
+ for (const block of content) {
114
+ if (block &&
115
+ typeof block === "object" &&
116
+ "type" in block &&
117
+ block.type === "text" &&
118
+ "text" in block &&
119
+ typeof block.text === "string") {
120
+ const stripped = stripMarkerTags(block.text);
121
+ if (stripped.trim() === "")
122
+ continue;
123
+ kept.push({ ...block, text: stripped });
124
+ }
125
+ else {
126
+ kept.push(block);
127
+ }
128
+ }
129
+ if (kept.length === 0)
130
+ return null;
131
+ return kept;
132
+ }
133
+ export function isLocalCommandMetadata(content) {
134
+ return stripLocalCommandMetadata(content) === null;
135
+ }
41
136
  const PERMISSION_MODE_ALIASES = {
137
+ auto: "auto",
42
138
  default: "default",
43
139
  acceptedits: "acceptEdits",
44
140
  dontask: "dontAsk",
@@ -46,26 +142,64 @@ const PERMISSION_MODE_ALIASES = {
46
142
  bypasspermissions: "bypassPermissions",
47
143
  bypass: "bypassPermissions",
48
144
  };
49
- export function resolvePermissionMode(defaultMode) {
145
+ export function resolvePermissionMode(defaultMode, logger = console) {
50
146
  if (defaultMode === undefined) {
51
147
  return "default";
52
148
  }
53
149
  if (typeof defaultMode !== "string") {
54
- throw new Error("Invalid permissions.defaultMode: expected a string.");
150
+ logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
151
+ return "default";
55
152
  }
56
153
  const normalized = defaultMode.trim().toLowerCase();
57
154
  if (normalized === "") {
58
- throw new Error("Invalid permissions.defaultMode: expected a non-empty string.");
155
+ logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
156
+ return "default";
59
157
  }
60
158
  const mapped = PERMISSION_MODE_ALIASES[normalized];
61
159
  if (!mapped) {
62
- throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`);
160
+ logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
161
+ return "default";
63
162
  }
64
163
  if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
65
- throw new Error("Invalid permissions.defaultMode: bypassPermissions is not available when running as root.");
164
+ logger.error("Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.");
165
+ return "default";
66
166
  }
67
167
  return mapped;
68
168
  }
169
+ /**
170
+ * Builds the label for the "Always Allow" permission option so the user can see
171
+ * the exact scope they are committing to. Uses the SDK-provided suggestions
172
+ * when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
173
+ * tool so "Always Allow" is never a blank check without disclosure.
174
+ */
175
+ export function describeAlwaysAllow(suggestions, toolName) {
176
+ if (!suggestions || suggestions.length === 0) {
177
+ return `Always Allow all ${toolName}`;
178
+ }
179
+ const ruleLabels = [];
180
+ const directories = [];
181
+ for (const update of suggestions) {
182
+ if (update.type === "addRules" && update.behavior === "allow") {
183
+ for (const rule of update.rules) {
184
+ ruleLabels.push(rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`);
185
+ }
186
+ }
187
+ else if (update.type === "addDirectories") {
188
+ directories.push(...update.directories);
189
+ }
190
+ }
191
+ const parts = [];
192
+ if (ruleLabels.length > 0) {
193
+ parts.push(ruleLabels.join(", "));
194
+ }
195
+ if (directories.length > 0) {
196
+ parts.push(`access to ${directories.join(", ")}`);
197
+ }
198
+ if (parts.length === 0) {
199
+ return `Always Allow all ${toolName}`;
200
+ }
201
+ return `Always Allow ${parts.join(" and ")}`;
202
+ }
69
203
  // Implement the ACP Agent interface
70
204
  export class ClaudeAcpAgent {
71
205
  constructor(client, logger) {
@@ -90,24 +224,87 @@ export class ClaudeAcpAgent {
90
224
  },
91
225
  },
92
226
  };
93
- const terminalAuthMethod = {
94
- description: "Run `claude /login` in the terminal",
95
- name: "Log in with Claude",
96
- id: "claude-login",
97
- type: "terminal",
98
- args: ["--cli"],
227
+ const gatewayBedrockAuthMethod = {
228
+ id: "gateway-bedrock",
229
+ name: "Custom model gateway",
230
+ description: "Use a custom gateway to authenticate and access models",
231
+ _meta: {
232
+ gateway: {
233
+ protocol: "bedrock",
234
+ },
235
+ },
99
236
  };
100
237
  const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
101
- // If client supports terminal-auth capability, use that instead.
102
238
  const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
103
- if (supportsMetaTerminalAuth) {
104
- terminalAuthMethod._meta = {
105
- "terminal-auth": {
106
- command: process.execPath,
107
- args: [...process.argv.slice(1), "--cli"],
108
- label: "Claude Login",
109
- },
239
+ // Detect remote environments where the OAuth browser redirect to localhost
240
+ // won't work. This matches the SDK's internal isRemote check. In these cases,
241
+ // the `auth login` subcommand would fall back to a device-code-like manual
242
+ // flow, which doesn't work well over ACP, so we offer the TUI login instead.
243
+ const isRemote = !!(process.env.NO_BROWSER ||
244
+ process.env.SSH_CONNECTION ||
245
+ process.env.SSH_CLIENT ||
246
+ process.env.SSH_TTY ||
247
+ process.env.CLAUDE_CODE_REMOTE);
248
+ const terminalAuthMethods = [];
249
+ if (isRemote) {
250
+ const remoteLoginMethod = {
251
+ description: "Run `claude /login` in the terminal",
252
+ name: "Log in with Claude",
253
+ id: "claude-login",
254
+ type: "terminal",
255
+ args: ["--cli"],
110
256
  };
257
+ if (supportsMetaTerminalAuth) {
258
+ remoteLoginMethod._meta = {
259
+ "terminal-auth": {
260
+ command: process.execPath,
261
+ args: [...process.argv.slice(1), "--cli"],
262
+ label: "Claude Login",
263
+ },
264
+ };
265
+ }
266
+ if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
267
+ terminalAuthMethods.push(remoteLoginMethod);
268
+ }
269
+ }
270
+ else {
271
+ const claudeLoginMethod = {
272
+ description: "Use Claude subscription ",
273
+ name: "Claude Subscription",
274
+ id: "claude-ai-login",
275
+ type: "terminal",
276
+ args: ["--cli", "auth", "login", "--claudeai"],
277
+ };
278
+ const consoleLoginMethod = {
279
+ description: "Use Anthropic Console (API usage billing)",
280
+ name: "Anthropic Console",
281
+ id: "console-login",
282
+ type: "terminal",
283
+ args: ["--cli", "auth", "login", "--console"],
284
+ };
285
+ if (supportsMetaTerminalAuth) {
286
+ const baseArgs = process.argv.slice(1);
287
+ claudeLoginMethod._meta = {
288
+ "terminal-auth": {
289
+ command: process.execPath,
290
+ args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
291
+ label: "Claude Login",
292
+ },
293
+ };
294
+ consoleLoginMethod._meta = {
295
+ "terminal-auth": {
296
+ command: process.execPath,
297
+ args: [...baseArgs, "--cli", "auth", "login", "--console"],
298
+ label: "Anthropic Console Login",
299
+ },
300
+ };
301
+ }
302
+ if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
303
+ terminalAuthMethods.push(claudeLoginMethod);
304
+ }
305
+ if (supportsTerminalAuth || supportsMetaTerminalAuth) {
306
+ terminalAuthMethods.push(consoleLoginMethod);
307
+ }
111
308
  }
112
309
  return {
113
310
  protocolVersion: 1,
@@ -127,10 +324,12 @@ export class ClaudeAcpAgent {
127
324
  },
128
325
  loadSession: true,
129
326
  sessionCapabilities: {
327
+ additionalDirectories: {},
328
+ close: {},
329
+ delete: {},
130
330
  fork: {},
131
331
  list: {},
132
332
  resume: {},
133
- close: {},
134
333
  },
135
334
  },
136
335
  agentInfo: {
@@ -139,19 +338,12 @@ export class ClaudeAcpAgent {
139
338
  version: packageJson.version,
140
339
  },
141
340
  authMethods: [
142
- ...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
143
- ? [terminalAuthMethod]
144
- : []),
145
- ...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
341
+ ...terminalAuthMethods,
342
+ ...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
146
343
  ],
147
344
  };
148
345
  }
149
346
  async newSession(params) {
150
- if (!this.gatewayAuthMeta &&
151
- fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
152
- !fs.existsSync(path.resolve(os.homedir(), ".claude.json"))) {
153
- throw RequestError.authRequired();
154
- }
155
347
  const response = await this.createSession(params, {
156
348
  // Revisit these meta values once we support resume
157
349
  resume: params._meta?.claudeCode?.options?.resume,
@@ -166,6 +358,7 @@ export class ClaudeAcpAgent {
166
358
  const response = await this.createSession({
167
359
  cwd: params.cwd,
168
360
  mcpServers: params.mcpServers ?? [],
361
+ additionalDirectories: params.additionalDirectories,
169
362
  _meta: params._meta,
170
363
  }, {
171
364
  resume: params.sessionId,
@@ -177,7 +370,7 @@ export class ClaudeAcpAgent {
177
370
  }, 0);
178
371
  return response;
179
372
  }
180
- async unstable_resumeSession(params) {
373
+ async resumeSession(params) {
181
374
  const result = await this.getOrCreateSession(params);
182
375
  // Needs to happen after we return the session
183
376
  setTimeout(() => {
@@ -212,8 +405,8 @@ export class ClaudeAcpAgent {
212
405
  };
213
406
  }
214
407
  async authenticate(_params) {
215
- if (_params.methodId === "gateway") {
216
- this.gatewayAuthMeta = _params._meta;
408
+ if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
409
+ this.gatewayAuthRequest = _params;
217
410
  return;
218
411
  }
219
412
  throw new Error("Method not implemented.");
@@ -231,8 +424,15 @@ export class ClaudeAcpAgent {
231
424
  cachedWriteTokens: 0,
232
425
  };
233
426
  let lastAssistantTotalUsage = null;
427
+ let lastAssistantUsage = null;
234
428
  let lastAssistantModel = null;
235
- let lastContextWindowSize = 200000;
429
+ // When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
430
+ // problem, billing), it sets a categorical `error` field on the
431
+ // `SDKAssistantMessage` that precedes the final `result` message. We
432
+ // capture it here so the subsequent `RequestError.internalError` can
433
+ // forward it to clients as structured `data`, sparing them from
434
+ // pattern-matching on the human-readable message text.
435
+ let lastAssistantError;
236
436
  const userMessage = promptToClaude(params);
237
437
  const promptUuid = randomUUID();
238
438
  userMessage.uuid = promptUuid;
@@ -256,6 +456,7 @@ export class ClaudeAcpAgent {
256
456
  }
257
457
  session.promptRunning = true;
258
458
  let handedOff = false;
459
+ let errored = false;
259
460
  let stopReason = "end_turn";
260
461
  try {
261
462
  while (true) {
@@ -266,6 +467,13 @@ export class ClaudeAcpAgent {
266
467
  }
267
468
  break;
268
469
  }
470
+ if (session.emitRawSDKMessages &&
471
+ shouldEmitRawMessage(session.emitRawSDKMessages, message)) {
472
+ await this.client.extNotification("_claude/sdkMessage", {
473
+ sessionId: params.sessionId,
474
+ message: message,
475
+ });
476
+ }
269
477
  switch (message.type) {
270
478
  case "system":
271
479
  switch (message.subtype) {
@@ -296,12 +504,13 @@ export class ClaudeAcpAgent {
296
504
  // "944k/1m" right after the user sees "Compacting completed",
297
505
  // which is confusing and wrong.
298
506
  lastAssistantTotalUsage = 0;
507
+ lastAssistantUsage = null;
299
508
  await this.client.sessionUpdate({
300
509
  sessionId: message.session_id,
301
510
  update: {
302
511
  sessionUpdate: "usage_update",
303
512
  used: 0,
304
- size: lastContextWindowSize,
513
+ size: session.contextWindowSize,
305
514
  },
306
515
  });
307
516
  await this.client.sessionUpdate({
@@ -325,10 +534,50 @@ export class ClaudeAcpAgent {
325
534
  }
326
535
  case "session_state_changed": {
327
536
  if (message.state === "idle") {
537
+ if (session.cancelled) {
538
+ stopReason = "cancelled";
539
+ }
328
540
  return { stopReason, usage: sessionUsage(session) };
329
541
  }
330
542
  break;
331
543
  }
544
+ case "memory_recall": {
545
+ const isSynthesis = message.mode === "synthesize";
546
+ const locations = isSynthesis
547
+ ? []
548
+ : message.memories.map((m) => ({ path: m.path }));
549
+ const content = isSynthesis
550
+ ? message.memories
551
+ .filter((m) => typeof m.content === "string")
552
+ .map((m) => ({
553
+ type: "content",
554
+ content: { type: "text", text: m.content },
555
+ }))
556
+ : [];
557
+ const count = message.memories.length;
558
+ const title = isSynthesis
559
+ ? "Recalled synthesized memory"
560
+ : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
561
+ await this.client.sessionUpdate({
562
+ sessionId: message.session_id,
563
+ update: {
564
+ sessionUpdate: "tool_call",
565
+ toolCallId: message.uuid,
566
+ title,
567
+ kind: "read",
568
+ status: "completed",
569
+ ...(locations.length > 0 && { locations }),
570
+ ...(content.length > 0 && { content }),
571
+ _meta: {
572
+ claudeCode: {
573
+ toolName: "memory_recall",
574
+ toolResponse: { mode: message.mode },
575
+ },
576
+ },
577
+ },
578
+ });
579
+ break;
580
+ }
332
581
  case "hook_started":
333
582
  case "hook_progress":
334
583
  case "hook_response":
@@ -336,8 +585,13 @@ export class ClaudeAcpAgent {
336
585
  case "task_started":
337
586
  case "task_notification":
338
587
  case "task_progress":
588
+ case "task_updated":
339
589
  case "elicitation_complete":
590
+ case "plugin_install":
591
+ case "notification":
340
592
  case "api_retry":
593
+ case "mirror_error":
594
+ case "permission_denied":
341
595
  // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
342
596
  break;
343
597
  default:
@@ -354,8 +608,18 @@ export class ClaudeAcpAgent {
354
608
  const matchingModelUsage = lastAssistantModel
355
609
  ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
356
610
  : null;
357
- const contextWindowSize = matchingModelUsage?.contextWindow ?? 200000;
358
- lastContextWindowSize = contextWindowSize;
611
+ // Only overwrite when we have an authoritative value — a miss
612
+ // (e.g. a turn with no top-level assistant message) would
613
+ // otherwise discard the window learned on a prior turn and
614
+ // leave the next prompt's mid-stream updates reporting 200k.
615
+ if (matchingModelUsage) {
616
+ session.contextWindowSize = matchingModelUsage.contextWindow;
617
+ }
618
+ // Task-notification followups are autonomous work triggered by a
619
+ // task-notification system message, not by the user's prompt.
620
+ // They should not influence the user-turn lifecycle (stop reason,
621
+ // slash-command output forwarding) but their cost is real.
622
+ const isTaskNotification = message.origin?.kind === "task-notification";
359
623
  // Send usage_update notification
360
624
  if (lastAssistantTotalUsage !== null) {
361
625
  await this.client.sessionUpdate({
@@ -363,16 +627,21 @@ export class ClaudeAcpAgent {
363
627
  update: {
364
628
  sessionUpdate: "usage_update",
365
629
  used: lastAssistantTotalUsage,
366
- size: contextWindowSize,
630
+ size: session.contextWindowSize,
367
631
  cost: {
368
632
  amount: message.total_cost_usd,
369
633
  currency: "USD",
370
634
  },
635
+ ...(message.origin && {
636
+ _meta: { "_claude/origin": message.origin },
637
+ }),
371
638
  },
372
639
  });
373
640
  }
374
641
  if (session.cancelled) {
375
- stopReason = "cancelled";
642
+ if (!isTaskNotification) {
643
+ stopReason = "cancelled";
644
+ }
376
645
  break;
377
646
  }
378
647
  switch (message.subtype) {
@@ -381,15 +650,19 @@ export class ClaudeAcpAgent {
381
650
  throw RequestError.authRequired();
382
651
  }
383
652
  if (message.stop_reason === "max_tokens") {
384
- stopReason = "max_tokens";
653
+ if (!isTaskNotification) {
654
+ stopReason = "max_tokens";
655
+ }
385
656
  break;
386
657
  }
387
658
  if (message.is_error) {
388
- throw RequestError.internalError(undefined, message.result);
659
+ throw RequestError.internalError(errorKindData(lastAssistantError), message.result);
389
660
  }
390
661
  // For local-only commands (no model invocation), the result
391
662
  // text is the command output — forward it to the client.
392
- if (isLocalOnlyCommand) {
663
+ // Task-notification followups never originate from a user
664
+ // slash command, so skip the forwarding for them.
665
+ if (isLocalOnlyCommand && !isTaskNotification) {
393
666
  for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, this.toolUseCache, this.client, this.logger)) {
394
667
  await this.client.sessionUpdate(notification);
395
668
  }
@@ -398,22 +671,28 @@ export class ClaudeAcpAgent {
398
671
  }
399
672
  case "error_during_execution": {
400
673
  if (message.stop_reason === "max_tokens") {
401
- stopReason = "max_tokens";
674
+ if (!isTaskNotification) {
675
+ stopReason = "max_tokens";
676
+ }
402
677
  break;
403
678
  }
404
679
  if (message.is_error) {
405
- throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
680
+ throw RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype);
681
+ }
682
+ if (!isTaskNotification) {
683
+ stopReason = "end_turn";
406
684
  }
407
- stopReason = "end_turn";
408
685
  break;
409
686
  }
410
687
  case "error_max_budget_usd":
411
688
  case "error_max_turns":
412
689
  case "error_max_structured_output_retries":
413
690
  if (message.is_error) {
414
- throw RequestError.internalError(undefined, message.errors.join(", ") || message.subtype);
691
+ throw RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype);
692
+ }
693
+ if (!isTaskNotification) {
694
+ stopReason = "max_turn_requests";
415
695
  }
416
- stopReason = "max_turn_requests";
417
696
  break;
418
697
  default:
419
698
  unreachable(message, this.logger);
@@ -422,9 +701,57 @@ export class ClaudeAcpAgent {
422
701
  break;
423
702
  }
424
703
  case "stream_event": {
704
+ if (message.parent_tool_use_id === null &&
705
+ (message.event.type === "message_start" || message.event.type === "message_delta")) {
706
+ if (message.event.type === "message_start") {
707
+ lastAssistantUsage = snapshotFromUsage(message.event.message.usage);
708
+ const model = message.event.message.model;
709
+ if (model && model !== "<synthetic>") {
710
+ lastAssistantModel = model;
711
+ // Only upgrade from the default — once a `result` has given
712
+ // us an authoritative window, trust it over the heuristic.
713
+ // Model switches invalidate the cached window via
714
+ // `syncSessionConfigState`, which resets us back to the
715
+ // default so this branch runs again for the new model.
716
+ if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
717
+ const inferred = inferContextWindowFromModel(model);
718
+ if (inferred !== null) {
719
+ session.contextWindowSize = inferred;
720
+ }
721
+ }
722
+ }
723
+ }
724
+ else {
725
+ const usage = message.event.usage;
726
+ const prev = lastAssistantUsage ?? ZERO_USAGE;
727
+ // Per Anthropic API, message_delta usage fields are *cumulative*;
728
+ // nullable fields (input_tokens and the cache fields) fall back
729
+ // to the prior snapshot when the server omits them from this
730
+ // delta. Only output_tokens is guaranteed non-null.
731
+ lastAssistantUsage = {
732
+ input_tokens: usage.input_tokens ?? prev.input_tokens,
733
+ output_tokens: usage.output_tokens,
734
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? prev.cache_read_input_tokens,
735
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? prev.cache_creation_input_tokens,
736
+ };
737
+ }
738
+ const nextUsage = totalTokens(lastAssistantUsage);
739
+ if (nextUsage !== lastAssistantTotalUsage) {
740
+ lastAssistantTotalUsage = nextUsage;
741
+ await this.client.sessionUpdate({
742
+ sessionId: params.sessionId,
743
+ update: {
744
+ sessionUpdate: "usage_update",
745
+ used: nextUsage,
746
+ size: session.contextWindowSize,
747
+ },
748
+ });
749
+ }
750
+ }
425
751
  for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.client, this.logger, {
426
752
  clientCapabilities: this.clientCapabilities,
427
753
  cwd: session.cwd,
754
+ taskState: session.taskState,
428
755
  })) {
429
756
  await this.client.sessionUpdate(notification);
430
757
  }
@@ -454,33 +781,41 @@ export class ClaudeAcpAgent {
454
781
  break;
455
782
  }
456
783
  }
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;
784
+ // Snapshot the latest top-level assistant usage and model so the
785
+ // next `result` can emit a usage_update tied to the right context
786
+ // window. Subagent messages are excluded to keep the snapshot
787
+ // aligned with what the user's current selection is producing.
788
+ if (message.type === "assistant" && message.parent_tool_use_id === null) {
789
+ lastAssistantUsage = snapshotFromUsage(message.message.usage);
790
+ lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
791
+ if (message.message.model && message.message.model !== "<synthetic>") {
792
+ lastAssistantModel = message.message.model;
793
+ }
794
+ if (message.error) {
795
+ lastAssistantError = message.error;
796
+ }
478
797
  }
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
798
+ // Strip <command-*>/<local-command-stdout> markers and render any
799
+ // remaining prose. Skill bodies and built-in slash commands (e.g.
800
+ // /usage, /status, /model) arrive wrapped in these tags; pure-marker
801
+ // payloads (e.g. /compact's malformed output) strip to null and are
802
+ // skipped. Mirrors the replay path at replaySessionHistory.
481
803
  if (typeof message.message.content === "string" &&
482
804
  message.message.content.includes("<local-command-stdout>")) {
483
- this.logger.log(message.message.content);
805
+ const stripped = stripLocalCommandMetadata(message.message.content);
806
+ if (typeof stripped === "string") {
807
+ for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger, {
808
+ clientCapabilities: this.clientCapabilities,
809
+ parentToolUseId: message.parent_tool_use_id,
810
+ cwd: session.cwd,
811
+ taskState: session.taskState,
812
+ })) {
813
+ await this.client.sessionUpdate(notification);
814
+ }
815
+ }
816
+ else {
817
+ this.logger.log(message.message.content);
818
+ }
484
819
  break;
485
820
  }
486
821
  if (typeof message.message.content === "string" &&
@@ -512,6 +847,7 @@ export class ClaudeAcpAgent {
512
847
  clientCapabilities: this.clientCapabilities,
513
848
  parentToolUseId: message.parent_tool_use_id,
514
849
  cwd: session.cwd,
850
+ taskState: session.taskState,
515
851
  })) {
516
852
  await this.client.sessionUpdate(notification);
517
853
  }
@@ -531,6 +867,30 @@ export class ClaudeAcpAgent {
531
867
  throw new Error("Session did not end in result");
532
868
  }
533
869
  catch (error) {
870
+ errored = true;
871
+ // A failed turn typically leaves a trailing `session_state_changed: idle`
872
+ // (and possibly more) in the query iterator. If we don't drain it here,
873
+ // the next prompt's first `query.next()` consumes that stale idle and
874
+ // short-circuits to end_turn with zero usage
875
+ // Bounded so a misbehaving SDK can't hang the next prompt indefinitely.
876
+ try {
877
+ await session.query.interrupt();
878
+ const MAX_DRAIN = 100;
879
+ for (let i = 0; i < MAX_DRAIN; i++) {
880
+ const { value: m, done } = await session.query.next();
881
+ if (done || !m)
882
+ break;
883
+ if (m.type === "system" && m.subtype === "session_state_changed" && m.state === "idle") {
884
+ break;
885
+ }
886
+ if (i === MAX_DRAIN - 1) {
887
+ this.logger.error(`Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`);
888
+ }
889
+ }
890
+ }
891
+ catch (drainErr) {
892
+ this.logger.error(`Session ${params.sessionId}: failed to drain query after prompt error:`, drainErr);
893
+ }
534
894
  if (error instanceof RequestError || !(error instanceof Error)) {
535
895
  throw error;
536
896
  }
@@ -551,10 +911,20 @@ export class ClaudeAcpAgent {
551
911
  finally {
552
912
  if (!handedOff) {
553
913
  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) {
914
+ if (errored) {
915
+ // The query stream was just drained — handing pending prompts off
916
+ // onto it would let them race with the recovery. Cancel them so
917
+ // each waiting prompt() returns stopReason: "cancelled" and the
918
+ // client can decide whether to retry.
919
+ for (const pending of session.pendingMessages.values()) {
920
+ pending.resolve(true);
921
+ }
922
+ session.pendingMessages.clear();
923
+ }
924
+ else if (session.pendingMessages.size > 0) {
925
+ // This usually should not happen, but in case the loop finishes
926
+ // without claude sending all message replays, we resolve the
927
+ // next pending prompt call to ensure no prompts get stuck.
558
928
  const next = [...session.pendingMessages.entries()].sort((a, b) => a[1].order - b[1].order)[0];
559
929
  if (next) {
560
930
  next[1].resolve(false);
@@ -567,7 +937,7 @@ export class ClaudeAcpAgent {
567
937
  async cancel(params) {
568
938
  const session = this.sessions[params.sessionId];
569
939
  if (!session) {
570
- throw new Error("Session not found");
940
+ return;
571
941
  }
572
942
  session.cancelled = true;
573
943
  for (const [, pending] of session.pendingMessages) {
@@ -576,23 +946,51 @@ export class ClaudeAcpAgent {
576
946
  session.pendingMessages.clear();
577
947
  await session.query.interrupt();
578
948
  }
579
- async unstable_closeSession(params) {
580
- const session = this.sessions[params.sessionId];
949
+ /** Cleanly tear down a session: cancel in-flight work, dispose resources,
950
+ * and remove it from the session map. */
951
+ async teardownSession(sessionId) {
952
+ const session = this.sessions[sessionId];
581
953
  if (!session) {
582
- throw new Error("Session not found");
954
+ return;
583
955
  }
584
- await this.cancel({ sessionId: params.sessionId });
956
+ await this.cancel({ sessionId });
585
957
  session.settingsManager.dispose();
586
958
  session.abortController.abort();
587
- delete this.sessions[params.sessionId];
959
+ session.query.close();
960
+ delete this.sessions[sessionId];
961
+ }
962
+ /** Tear down all active sessions. Called when the ACP connection closes. */
963
+ async dispose() {
964
+ await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
965
+ }
966
+ async closeSession(params) {
967
+ if (!this.sessions[params.sessionId]) {
968
+ throw new Error("Session not found");
969
+ }
970
+ await this.teardownSession(params.sessionId);
971
+ return {};
972
+ }
973
+ async unstable_deleteSession(params) {
974
+ // Tear down any active in-memory state first so the on-disk file isn't
975
+ // recreated by an outstanding query writing to it.
976
+ if (this.sessions[params.sessionId]) {
977
+ await this.teardownSession(params.sessionId);
978
+ }
979
+ await deleteSession(params.sessionId);
588
980
  return {};
589
981
  }
590
982
  async unstable_setSessionModel(params) {
591
- if (!this.sessions[params.sessionId]) {
983
+ const session = this.sessions[params.sessionId];
984
+ if (!session) {
592
985
  throw new Error("Session not found");
593
986
  }
594
- await this.sessions[params.sessionId].query.setModel(params.modelId);
595
- await this.updateConfigOption(params.sessionId, "model", params.modelId);
987
+ // Resolve aliases (e.g. "opus", "opus[1m]") to canonical model IDs so
988
+ // downstream lookups in modelInfos succeed and the effort option isn't
989
+ // silently dropped.
990
+ const resolved = resolveModelPreference(session.modelInfos, params.modelId);
991
+ const modelId = resolved?.value ?? params.modelId;
992
+ await session.query.setModel(modelId);
993
+ await this.updateConfigOption(params.sessionId, "model", modelId);
596
994
  }
597
995
  async setSessionMode(params) {
598
996
  if (!this.sessions[params.sessionId]) {
@@ -651,14 +1049,15 @@ export class ClaudeAcpAgent {
651
1049
  else if (params.configId === "model") {
652
1050
  await this.sessions[params.sessionId].query.setModel(resolvedValue);
653
1051
  }
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);
1052
+ // Effort SDK sync is handled inside applyConfigOptionValue so that direct
1053
+ // effort changes and effort changes induced by a model switch go through
1054
+ // the same path.
1055
+ await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
658
1056
  return { configOptions: session.configOptions };
659
1057
  }
660
1058
  async applySessionMode(sessionId, modeId) {
661
1059
  switch (modeId) {
1060
+ case "auto":
662
1061
  case "default":
663
1062
  case "acceptEdits":
664
1063
  case "bypassPermissions":
@@ -668,8 +1067,15 @@ export class ClaudeAcpAgent {
668
1067
  default:
669
1068
  throw new Error("Invalid Mode");
670
1069
  }
1070
+ const session = this.sessions[sessionId];
1071
+ if (!session) {
1072
+ throw new Error("Session not found");
1073
+ }
1074
+ if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
1075
+ throw new Error(`Mode ${modeId} is not available in this session`);
1076
+ }
671
1077
  try {
672
- await this.sessions[sessionId].query.setPermissionMode(modeId);
1078
+ await session.query.setPermissionMode(modeId);
673
1079
  }
674
1080
  catch (error) {
675
1081
  if (error instanceof Error) {
@@ -688,14 +1094,23 @@ export class ClaudeAcpAgent {
688
1094
  const toolUseCache = {};
689
1095
  const messages = await getSessionMessages(sessionId);
690
1096
  for (const message of messages) {
1097
+ // @ts-expect-error - untyped in SDK but we handle all of these
1098
+ let content = message.message.content;
1099
+ // @ts-expect-error - untyped in SDK but we handle all of these
1100
+ if (message.message.role === "user") {
1101
+ content = stripLocalCommandMetadata(content);
1102
+ if (content === null)
1103
+ continue;
1104
+ }
691
1105
  for (const notification of toAcpNotifications(
692
1106
  // @ts-expect-error - untyped in SDK but we handle all of these
693
- message.message.content,
1107
+ content,
694
1108
  // @ts-expect-error - untyped in SDK but we handle all of these
695
1109
  message.message.role, sessionId, toolUseCache, this.client, this.logger, {
696
1110
  registerHooks: false,
697
1111
  clientCapabilities: this.clientCapabilities,
698
1112
  cwd: this.sessions[sessionId]?.cwd,
1113
+ taskState: this.sessions[sessionId]?.taskState,
699
1114
  })) {
700
1115
  await this.client.sessionUpdate(notification);
701
1116
  }
@@ -711,6 +1126,7 @@ export class ClaudeAcpAgent {
711
1126
  }
712
1127
  canUseTool(sessionId) {
713
1128
  return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
1129
+ const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
714
1130
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
715
1131
  const session = this.sessions[sessionId];
716
1132
  if (!session) {
@@ -720,7 +1136,8 @@ export class ClaudeAcpAgent {
720
1136
  };
721
1137
  }
722
1138
  if (toolName === "ExitPlanMode") {
723
- const options = [
1139
+ const optionsAll = [
1140
+ { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
724
1141
  {
725
1142
  kind: "allow_always",
726
1143
  name: "Yes, and auto-accept edits",
@@ -730,12 +1147,18 @@ export class ClaudeAcpAgent {
730
1147
  { kind: "reject_once", name: "No, keep planning", optionId: "plan" },
731
1148
  ];
732
1149
  if (ALLOW_BYPASS) {
733
- options.unshift({
1150
+ optionsAll.unshift({
734
1151
  kind: "allow_always",
735
1152
  name: "Yes, and bypass permissions",
736
1153
  optionId: "bypassPermissions",
737
1154
  });
738
1155
  }
1156
+ // Filter against the session's currently-advertised modes so we never
1157
+ // present options the active model can't honor (e.g. `auto` on Haiku).
1158
+ // `bypassPermissions` is already covered by `availableModes` via
1159
+ // `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
1160
+ // "keep planning" reject path; it's always present in `availableModes`.
1161
+ const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
739
1162
  const response = await this.client.requestPermission({
740
1163
  options,
741
1164
  sessionId,
@@ -748,23 +1171,26 @@ export class ClaudeAcpAgent {
748
1171
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
749
1172
  throw new Error("Tool use aborted");
750
1173
  }
751
- if (response.outcome?.outcome === "selected" &&
752
- (response.outcome.optionId === "default" ||
753
- response.outcome.optionId === "acceptEdits" ||
754
- response.outcome.optionId === "bypassPermissions")) {
1174
+ const selectedMode = response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
1175
+ const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
1176
+ if (selectedModeWasOffered &&
1177
+ (selectedMode === "default" ||
1178
+ selectedMode === "acceptEdits" ||
1179
+ selectedMode === "auto" ||
1180
+ selectedMode === "bypassPermissions")) {
755
1181
  await this.client.sessionUpdate({
756
1182
  sessionId,
757
1183
  update: {
758
1184
  sessionUpdate: "current_mode_update",
759
- currentModeId: response.outcome.optionId,
1185
+ currentModeId: selectedMode,
760
1186
  },
761
1187
  });
762
- await this.updateConfigOption(sessionId, "mode", response.outcome.optionId);
1188
+ await this.updateConfigOption(sessionId, "mode", selectedMode);
763
1189
  return {
764
1190
  behavior: "allow",
765
1191
  updatedInput: toolInput,
766
1192
  updatedPermissions: suggestions ?? [
767
- { type: "setMode", mode: response.outcome.optionId, destination: "session" },
1193
+ { type: "setMode", mode: selectedMode, destination: "session" },
768
1194
  ],
769
1195
  };
770
1196
  }
@@ -788,7 +1214,7 @@ export class ClaudeAcpAgent {
788
1214
  options: [
789
1215
  {
790
1216
  kind: "allow_always",
791
- name: "Always Allow",
1217
+ name: alwaysAllowLabel,
792
1218
  optionId: "allow_always",
793
1219
  },
794
1220
  { kind: "allow_once", name: "Allow", optionId: "allow" },
@@ -851,8 +1277,7 @@ export class ClaudeAcpAgent {
851
1277
  const session = this.sessions[sessionId];
852
1278
  if (!session)
853
1279
  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);
1280
+ await this.applyConfigOptionValue(sessionId, session, configId, value);
856
1281
  await this.client.sessionUpdate({
857
1282
  sessionId,
858
1283
  update: {
@@ -861,27 +1286,107 @@ export class ClaudeAcpAgent {
861
1286
  },
862
1287
  });
863
1288
  }
864
- syncSessionConfigState(session, configId, value) {
1289
+ async applyConfigOptionValue(sessionId, session, configId, value) {
865
1290
  if (configId === "mode") {
866
1291
  session.modes = { ...session.modes, currentModeId: value };
1292
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
867
1293
  }
868
1294
  else if (configId === "model") {
1295
+ if (session.models.currentModelId !== value) {
1296
+ // The cached context window was learned for the previous model; reset
1297
+ // to the new model's heuristic so mid-stream updates between now and
1298
+ // the next `result` reflect the user's selection instead of the old
1299
+ // model's window.
1300
+ session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
1301
+ }
869
1302
  session.models = { ...session.models, currentModelId: value };
1303
+ // Recompute availableModes for the new model and clamp the current
1304
+ // mode if the SDK no longer offers it (today: "auto" on Haiku).
1305
+ // `ModelInfo.supportsAutoMode` is the canonical SDK signal.
1306
+ const newModelInfo = session.modelInfos.find((m) => m.value === value);
1307
+ const newAvailableModes = buildAvailableModes(newModelInfo);
1308
+ // Capture BEFORE mutating session.modes so the log message reflects
1309
+ // the invalidated mode rather than "default".
1310
+ const previousModeId = session.modes.currentModeId;
1311
+ let modeDowngraded = false;
1312
+ if (!newAvailableModes.some((m) => m.id === previousModeId)) {
1313
+ session.modes = {
1314
+ availableModes: newAvailableModes,
1315
+ currentModeId: "default",
1316
+ };
1317
+ try {
1318
+ await session.query.setPermissionMode("default");
1319
+ }
1320
+ catch (err) {
1321
+ // Failing the entire model switch over a bookkeeping sync error is
1322
+ // worse UX than logging and continuing; the user explicitly asked
1323
+ // to change models. The next setPermissionMode from the user will
1324
+ // either succeed or surface a fresh error.
1325
+ this.logger.error(`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, err);
1326
+ }
1327
+ modeDowngraded = true;
1328
+ }
1329
+ else {
1330
+ session.modes = { ...session.modes, availableModes: newAvailableModes };
1331
+ }
1332
+ // Rebuild config options since effort levels depend on the selected model
1333
+ const effortOpt = session.configOptions.find((o) => o.id === "effort");
1334
+ const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
1335
+ session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort);
1336
+ // Sync effort with the SDK if it changed after the model switch
1337
+ const newEffortOpt = session.configOptions.find((o) => o.id === "effort");
1338
+ const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
1339
+ if (newEffort !== currentEffort) {
1340
+ await session.query.applyFlagSettings({
1341
+ effortLevel: toSdkEffortLevel(newEffort),
1342
+ });
1343
+ }
1344
+ // Emit current_mode_update only after session.modes AND
1345
+ // session.configOptions have been fully reconciled. This way, a failure
1346
+ // in the configOptions/effort rebuild above can't leave the client with
1347
+ // a clamped currentModeId but stale configOptions, and the notification
1348
+ // still precedes the caller's config_option_update so order-sensitive
1349
+ // clients update currentModeId before re-rendering the option list.
1350
+ if (modeDowngraded) {
1351
+ await this.client.sessionUpdate({
1352
+ sessionId,
1353
+ update: {
1354
+ sessionUpdate: "current_mode_update",
1355
+ currentModeId: "default",
1356
+ },
1357
+ });
1358
+ }
1359
+ }
1360
+ else {
1361
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
1362
+ if (configId === "effort") {
1363
+ await session.query.applyFlagSettings({
1364
+ effortLevel: toSdkEffortLevel(value),
1365
+ });
1366
+ }
870
1367
  }
871
1368
  }
872
1369
  async getOrCreateSession(params) {
873
1370
  const existingSession = this.sessions[params.sessionId];
874
1371
  if (existingSession) {
875
- return {
876
- sessionId: params.sessionId,
877
- modes: existingSession.modes,
878
- models: existingSession.models,
879
- configOptions: existingSession.configOptions,
880
- };
1372
+ const fingerprint = computeSessionFingerprint(params);
1373
+ if (fingerprint === existingSession.sessionFingerprint) {
1374
+ return {
1375
+ sessionId: params.sessionId,
1376
+ modes: existingSession.modes,
1377
+ models: existingSession.models,
1378
+ configOptions: existingSession.configOptions,
1379
+ };
1380
+ }
1381
+ // Session-defining params changed (e.g. cwd pointed at a git worktree,
1382
+ // or MCP servers reconfigured). Tear down the existing session and
1383
+ // recreate it so the underlying Query process picks up the new values.
1384
+ await this.teardownSession(params.sessionId);
881
1385
  }
882
1386
  const response = await this.createSession({
883
1387
  cwd: params.cwd,
884
1388
  mcpServers: params.mcpServers ?? [],
1389
+ additionalDirectories: params.additionalDirectories,
885
1390
  _meta: params._meta,
886
1391
  }, {
887
1392
  resume: params.sessionId,
@@ -924,7 +1429,7 @@ export class ClaudeAcpAgent {
924
1429
  : undefined,
925
1430
  };
926
1431
  }
927
- else {
1432
+ else if (!("type" in server)) {
928
1433
  // Stdio type MCP server (with or without explicit type field)
929
1434
  mcpServers[server.name] = {
930
1435
  type: "stdio",
@@ -944,12 +1449,18 @@ export class ClaudeAcpAgent {
944
1449
  systemPrompt = customPrompt;
945
1450
  }
946
1451
  else if (typeof customPrompt === "object" &&
947
- "append" in customPrompt &&
948
- typeof customPrompt.append === "string") {
949
- systemPrompt.append = customPrompt.append;
1452
+ customPrompt !== null &&
1453
+ !Array.isArray(customPrompt)) {
1454
+ // Forward all preset options (append, excludeDynamicSections, and
1455
+ // anything the SDK adds later) while locking type/preset.
1456
+ systemPrompt = {
1457
+ ...customPrompt,
1458
+ type: "preset",
1459
+ preset: "claude_code",
1460
+ };
950
1461
  }
951
1462
  }
952
- const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode);
1463
+ const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
953
1464
  // Extract options from _meta if provided
954
1465
  const sessionMeta = params._meta;
955
1466
  const userProvidedOptions = sessionMeta?.claudeCode?.options;
@@ -957,8 +1468,10 @@ export class ClaudeAcpAgent {
957
1468
  const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
958
1469
  ? parseInt(process.env.MAX_THINKING_TOKENS, 10)
959
1470
  : undefined;
1471
+ // Parse model configuration from environment (e.g. Bedrock model overrides)
1472
+ const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
960
1473
  // 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"];
1474
+ const disallowedTools = ["AskUserQuestion", "WebFetch", "WebSearch"];
962
1475
  // Resolve which built-in tools to expose.
963
1476
  // Explicit tools array from _meta.claudeCode.options takes precedence.
964
1477
  // disableBuiltInTools is a legacy shorthand for tools: [] — kept for
@@ -966,15 +1479,30 @@ export class ClaudeAcpAgent {
966
1479
  const tools = userProvidedOptions?.tools ??
967
1480
  (params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" });
968
1481
  const abortController = userProvidedOptions?.abortController || new AbortController();
1482
+ // Per-session task state. Created here (rather than in the session record
1483
+ // below) so the TaskCreated/TaskCompleted hook callbacks can close over
1484
+ // the same Map that the streaming message handler will read from.
1485
+ const taskState = new Map();
969
1486
  const options = {
970
1487
  systemPrompt,
971
1488
  settingSources: ["user", "project", "local"],
972
1489
  ...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
973
1490
  ...userProvidedOptions,
1491
+ // CLAUDE_MODEL_CONFIG env var is a fallback for model
1492
+ // configuration (e.g. Bedrock model ID overrides). When the caller
1493
+ // provides settings via _meta, we intentionally ignore the env var —
1494
+ // the caller is assumed to have full control over model configuration.
1495
+ ...(!userProvidedOptions?.settings &&
1496
+ modelConfig && {
1497
+ settings: {
1498
+ ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
1499
+ ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
1500
+ },
1501
+ }),
974
1502
  env: {
975
1503
  ...process.env,
976
1504
  ...userProvidedOptions?.env,
977
- ...createEnvForGateway(this.gatewayAuthMeta),
1505
+ ...createEnvForGateway(this.gatewayAuthRequest),
978
1506
  // Opt-in to session state events like when the agent is idle
979
1507
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
980
1508
  },
@@ -987,14 +1515,7 @@ export class ClaudeAcpAgent {
987
1515
  allowDangerouslySkipPermissions: ALLOW_BYPASS,
988
1516
  permissionMode,
989
1517
  canUseTool: this.canUseTool(sessionId),
990
- // note: although not documented by the types, passing an absolute path
991
- // here works to find zed's managed node version.
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
- : {}),
1518
+ pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
998
1519
  extraArgs: {
999
1520
  ...userProvidedOptions?.extraArgs,
1000
1521
  "replay-user-messages": "",
@@ -1022,13 +1543,56 @@ export class ClaudeAcpAgent {
1022
1543
  ],
1023
1544
  },
1024
1545
  ],
1546
+ TaskCreated: [
1547
+ ...(userProvidedOptions?.hooks?.TaskCreated || []),
1548
+ {
1549
+ hooks: [
1550
+ createTaskHook({
1551
+ taskState,
1552
+ onChange: async () => {
1553
+ await this.client.sessionUpdate({
1554
+ sessionId,
1555
+ update: {
1556
+ sessionUpdate: "plan",
1557
+ entries: taskStateToPlanEntries(taskState),
1558
+ },
1559
+ });
1560
+ },
1561
+ }),
1562
+ ],
1563
+ },
1564
+ ],
1565
+ TaskCompleted: [
1566
+ ...(userProvidedOptions?.hooks?.TaskCompleted || []),
1567
+ {
1568
+ hooks: [
1569
+ createTaskHook({
1570
+ taskState,
1571
+ onChange: async () => {
1572
+ await this.client.sessionUpdate({
1573
+ sessionId,
1574
+ update: {
1575
+ sessionUpdate: "plan",
1576
+ entries: taskStateToPlanEntries(taskState),
1577
+ },
1578
+ });
1579
+ },
1580
+ }),
1581
+ ],
1582
+ },
1583
+ ],
1025
1584
  },
1026
1585
  ...creationOpts,
1027
1586
  abortController,
1028
1587
  };
1588
+ // Prefer the official ACP `additionalDirectories` field. Fall back to the
1589
+ // legacy `_meta.additionalRoots` extension for clients that haven't been
1590
+ // updated yet. Either source is merged with directories supplied via
1591
+ // `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
1592
+ const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
1029
1593
  options.additionalDirectories = [
1030
1594
  ...(userProvidedOptions?.additionalDirectories ?? []),
1031
- ...(sessionMeta?.additionalRoots ?? []),
1595
+ ...acpAdditionalDirectories,
1032
1596
  ];
1033
1597
  if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
1034
1598
  // Set our own session id if not resuming an existing session.
@@ -1038,15 +1602,6 @@ export class ClaudeAcpAgent {
1038
1602
  if (abortController?.signal.aborted) {
1039
1603
  throw new Error("Cancelled");
1040
1604
  }
1041
- // ==================== 会话创建开始 ====================
1042
- const sessionCreateStartTime = Date.now();
1043
- this.logger.log(`🔵 [ACP] 开始创建会话`);
1044
- this.logger.log(` ├─ sessionId: ${sessionId}`);
1045
- this.logger.log(` ├─ cwd: ${params.cwd}`);
1046
- this.logger.log(` └─ MCP 服务器数量: ${Object.keys(mcpServers).length}`);
1047
- if (Object.keys(mcpServers).length > 0) {
1048
- this.logger.log(` └─ MCP 服务器列表: ${Object.keys(mcpServers).join(", ")}`);
1049
- }
1050
1605
  const q = query({
1051
1606
  prompt: input,
1052
1607
  options,
@@ -1058,59 +1613,78 @@ export class ClaudeAcpAgent {
1058
1613
  catch (error) {
1059
1614
  if (creationOpts.resume &&
1060
1615
  error instanceof Error &&
1061
- error.message === "Query closed before response received") {
1616
+ (error.message === "Query closed before response received" ||
1617
+ error.message.includes("No conversation found with session ID"))) {
1062
1618
  throw RequestError.resourceNotFound(sessionId);
1063
1619
  }
1064
1620
  throw error;
1065
1621
  }
1066
1622
  if (shouldHideClaudeAuth() &&
1067
1623
  initializationResult.account.subscriptionType &&
1068
- !this.gatewayAuthMeta) {
1624
+ !this.gatewayAuthRequest) {
1069
1625
  throw RequestError.authRequired(undefined, "This integration does not support using claude.ai subscriptions.");
1070
1626
  }
1071
- const models = await getAvailableModels(q, initializationResult.models, settingsManager);
1072
- const sessionCreateElapsed = Date.now() - sessionCreateStartTime;
1073
- this.logger.log(`✅ [ACP] 会话创建完成, 总耗时: ${sessionCreateElapsed}ms`);
1074
- const availableModes = [
1075
- {
1076
- id: "default",
1077
- name: "Default",
1078
- description: "Standard behavior, prompts for dangerous operations",
1079
- },
1080
- {
1081
- id: "acceptEdits",
1082
- name: "Accept Edits",
1083
- description: "Auto-accept file edit operations",
1084
- },
1085
- {
1086
- id: "plan",
1087
- name: "Plan Mode",
1088
- description: "Planning mode, no actual tool execution",
1089
- },
1090
- {
1091
- id: "dontAsk",
1092
- name: "Don't Ask",
1093
- description: "Don't prompt for permissions, deny if not pre-approved",
1094
- },
1095
- ];
1096
- // Only works in non-root mode
1097
- if (ALLOW_BYPASS) {
1098
- availableModes.push({
1099
- id: "bypassPermissions",
1100
- name: "Bypass Permissions",
1101
- description: "Bypass all permission checks",
1102
- });
1627
+ // Apply user's `availableModels` allowlist from settings.json before any
1628
+ // downstream model handling. The SDK only enforces this allowlist in its
1629
+ // own UI, not in `initializationResult.models`, so we filter here to keep
1630
+ // configOptions, the current-model resolver, and the stored modelInfos
1631
+ // consistent with what the user configured.
1632
+ const settingsAvailableModels = settingsManager.getSettings().availableModels;
1633
+ const allowedModels = Array.isArray(settingsAvailableModels)
1634
+ ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels)
1635
+ : initializationResult.models;
1636
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger);
1637
+ // Gate `auto` (and future model-specific modes) on the resolved model's
1638
+ // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
1639
+ const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
1640
+ const availableModes = buildAvailableModes(currentModelInfo);
1641
+ // Clamp `permissionMode` if the resolved session does not offer it. The
1642
+ // common case is `permissions.defaultMode: "auto"` resolving to a model
1643
+ // that does not support auto mode (e.g. Haiku); without this clamp the
1644
+ // SDK would later throw `"auto mode unavailable for this model"` from
1645
+ // `setPermissionMode`. Keep `permissionMode` as the resolved user intent
1646
+ // (matches what was passed into `options.permissionMode` above) and use
1647
+ // `effectiveMode` for the post-clamp value the session actually runs in.
1648
+ let effectiveMode = permissionMode;
1649
+ if (!availableModes.some((m) => m.id === effectiveMode)) {
1650
+ if (effectiveMode === "auto") {
1651
+ this.logger.error(`permissions.defaultMode "auto" is not available for model ` +
1652
+ `"${models.currentModelId}"; falling back to "default".`);
1653
+ }
1654
+ else {
1655
+ this.logger.error(`permissions.defaultMode "${effectiveMode}" is not available in ` +
1656
+ `this session; falling back to "default".`);
1657
+ }
1658
+ effectiveMode = "default";
1659
+ // Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
1660
+ // try/catch since failing here would abort session creation entirely.
1661
+ try {
1662
+ await q.setPermissionMode("default");
1663
+ }
1664
+ catch (err) {
1665
+ this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
1666
+ }
1103
1667
  }
1104
1668
  const modes = {
1105
- currentModeId: permissionMode,
1669
+ currentModeId: effectiveMode,
1106
1670
  availableModes,
1107
1671
  };
1108
- const configOptions = buildConfigOptions(modes, models);
1672
+ const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel);
1673
+ // Apply the initial effort level to the SDK so it matches the UI default
1674
+ const initialEffort = configOptions.find((o) => o.id === "effort");
1675
+ if (initialEffort &&
1676
+ typeof initialEffort.currentValue === "string" &&
1677
+ initialEffort.currentValue !== "default") {
1678
+ await q.applyFlagSettings({
1679
+ effortLevel: initialEffort.currentValue,
1680
+ });
1681
+ }
1109
1682
  this.sessions[sessionId] = {
1110
1683
  query: q,
1111
1684
  input: input,
1112
1685
  cancelled: false,
1113
1686
  cwd: params.cwd,
1687
+ sessionFingerprint: computeSessionFingerprint(params),
1114
1688
  settingsManager,
1115
1689
  accumulatedUsage: {
1116
1690
  inputTokens: 0,
@@ -1120,11 +1694,15 @@ export class ClaudeAcpAgent {
1120
1694
  },
1121
1695
  modes,
1122
1696
  models,
1697
+ modelInfos: allowedModels,
1123
1698
  configOptions,
1124
1699
  promptRunning: false,
1125
1700
  pendingMessages: new Map(),
1126
1701
  nextPendingOrder: 0,
1127
1702
  abortController,
1703
+ emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
1704
+ contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
1705
+ taskState,
1128
1706
  };
1129
1707
  return {
1130
1708
  sessionId,
@@ -1134,6 +1712,15 @@ export class ClaudeAcpAgent {
1134
1712
  };
1135
1713
  }
1136
1714
  }
1715
+ function shouldEmitRawMessage(config, message) {
1716
+ if (config === true)
1717
+ return true;
1718
+ if (config === false)
1719
+ return false;
1720
+ return config.some((f) => f.type === message.type &&
1721
+ (f.subtype === undefined || f.subtype === message.subtype) &&
1722
+ (f.origin === undefined || f.origin === message.origin?.kind));
1723
+ }
1137
1724
  function sessionUsage(session) {
1138
1725
  return {
1139
1726
  inputTokens: session.accumulatedUsage.inputTokens,
@@ -1146,20 +1733,119 @@ function sessionUsage(session) {
1146
1733
  session.accumulatedUsage.cachedWriteTokens,
1147
1734
  };
1148
1735
  }
1149
- function createEnvForGateway(gatewayMeta) {
1150
- if (!gatewayMeta) {
1736
+ /** Sum all four fields as a proxy for post-turn context occupancy: the current
1737
+ * turn's output becomes next turn's input. Per the Anthropic API, input_tokens
1738
+ * excludes cache tokens — cache_read and cache_creation are reported
1739
+ * separately — so summing all four is not double-counting. */
1740
+ function totalTokens(usage) {
1741
+ return (usage.input_tokens +
1742
+ usage.output_tokens +
1743
+ usage.cache_read_input_tokens +
1744
+ usage.cache_creation_input_tokens);
1745
+ }
1746
+ /**
1747
+ * Build the `data` payload attached to a `RequestError.internalError` when we
1748
+ * have a categorical error from the Claude SDK. Returns `undefined` when no
1749
+ * categorical error is available, matching the previous behavior of passing
1750
+ * `undefined` to `RequestError.internalError`.
1751
+ *
1752
+ * The `errorKind` field is a convention for ACP clients to dispatch on
1753
+ * without having to pattern-match the human-readable message text. Clients
1754
+ * that don't understand it fall back to the existing message-based rendering.
1755
+ */
1756
+ function errorKindData(errorKind) {
1757
+ return errorKind ? { errorKind } : undefined;
1758
+ }
1759
+ /** Project a nullable API usage object into our non-null snapshot shape.
1760
+ * Both SDK message_start and assistant message `usage` have `number | null`
1761
+ * cache fields; we coerce absent values to 0 so `totalTokens` never hits
1762
+ * NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but
1763
+ * synthetic or third-party-backend stream events have been observed emitting
1764
+ * them as null/undefined — coerce those too so a malformed upstream event
1765
+ * can't leak NaN into the wire `used` field. Delta events have different
1766
+ * semantics (cumulative + prev fallback) and are handled inline. */
1767
+ function snapshotFromUsage(usage) {
1768
+ return {
1769
+ input_tokens: usage.input_tokens ?? 0,
1770
+ output_tokens: usage.output_tokens ?? 0,
1771
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
1772
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
1773
+ };
1774
+ }
1775
+ function createEnvForGateway(request) {
1776
+ if (!request?._meta) {
1151
1777
  return {};
1152
1778
  }
1779
+ const customHeaders = Object.entries(request._meta.gateway.headers)
1780
+ .map(([key, value]) => `${key}: ${value}`)
1781
+ .join("\n");
1782
+ if (request.methodId === "gateway-bedrock") {
1783
+ return {
1784
+ CLAUDE_CODE_USE_BEDROCK: "1",
1785
+ AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
1786
+ ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
1787
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
1788
+ };
1789
+ }
1153
1790
  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
1791
+ ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
1792
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
1793
+ ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
1159
1794
  };
1160
1795
  }
1161
- function buildConfigOptions(modes, models) {
1162
- return [
1796
+ /**
1797
+ * Build the list of permission modes the agent will advertise for the given
1798
+ * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
1799
+ * the SDK's model-level availability signal. `undefined`/`false` both exclude
1800
+ * `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
1801
+ */
1802
+ function buildAvailableModes(modelInfo) {
1803
+ const modes = [];
1804
+ // Only advertise "auto" when the SDK reports the model supports it.
1805
+ if (modelInfo?.supportsAutoMode === true) {
1806
+ modes.push({
1807
+ id: "auto",
1808
+ name: "Auto",
1809
+ description: "Use a model classifier to approve/deny permission prompts",
1810
+ });
1811
+ }
1812
+ modes.push({
1813
+ id: "default",
1814
+ name: "Default",
1815
+ description: "Standard behavior, prompts for dangerous operations",
1816
+ }, {
1817
+ id: "acceptEdits",
1818
+ name: "Accept Edits",
1819
+ description: "Auto-accept file edit operations",
1820
+ }, {
1821
+ id: "plan",
1822
+ name: "Plan Mode",
1823
+ description: "Planning mode, no actual tool execution",
1824
+ }, {
1825
+ id: "dontAsk",
1826
+ name: "Don't Ask",
1827
+ description: "Don't prompt for permissions, deny if not pre-approved",
1828
+ });
1829
+ if (ALLOW_BYPASS) {
1830
+ modes.push({
1831
+ id: "bypassPermissions",
1832
+ name: "Bypass Permissions",
1833
+ description: "Bypass all permission checks",
1834
+ });
1835
+ }
1836
+ return modes;
1837
+ }
1838
+ // Translate a UI effort value into the flag-layer payload. The SDK
1839
+ // shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
1840
+ // and only clears a key when an explicit `null` is sent — see
1841
+ // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
1842
+ // `"default"` sentinel and `undefined` (effort option absent for the model) to
1843
+ // `null` ensures any previously-applied flag is actually cleared.
1844
+ function toSdkEffortLevel(value) {
1845
+ return value === undefined || value === "default" ? null : value;
1846
+ }
1847
+ function buildConfigOptions(modes, models, modelInfos, currentEffortLevel) {
1848
+ const options = [
1163
1849
  {
1164
1850
  id: "mode",
1165
1851
  name: "Mode",
@@ -1187,10 +1873,58 @@ function buildConfigOptions(modes, models) {
1187
1873
  })),
1188
1874
  },
1189
1875
  ];
1876
+ // Add effort level option based on the currently selected model
1877
+ const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId);
1878
+ const supportedLevels = currentModelInfo?.supportsEffort
1879
+ ? (currentModelInfo.supportedEffortLevels ?? [])
1880
+ : [];
1881
+ if (supportedLevels.length > 0) {
1882
+ const effortOptions = [
1883
+ { value: "default", name: "Default" },
1884
+ ...supportedLevels.map((level) => ({
1885
+ value: level,
1886
+ name: level
1887
+ .split(/[_-]/)
1888
+ .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
1889
+ .join(" "),
1890
+ })),
1891
+ ];
1892
+ const includes = (l) => l === "default" || supportedLevels.includes(l);
1893
+ const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
1894
+ options.push({
1895
+ id: "effort",
1896
+ name: "Effort",
1897
+ description: "Available effort levels for this model",
1898
+ category: "thought_level",
1899
+ type: "select",
1900
+ currentValue: validEffort,
1901
+ options: effortOptions,
1902
+ });
1903
+ }
1904
+ return options;
1190
1905
  }
1191
1906
  // Claude Code CLI persists display strings like "opus[1m]" in settings,
1192
1907
  // but the SDK model list uses IDs like "claude-opus-4-6-1m".
1193
1908
  const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
1909
+ // Captures a model family version such as `4-6` or `4.7` so we can keep
1910
+ // `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that
1911
+ // alias currently resolves to a different family version (e.g. Opus 4.7).
1912
+ const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/;
1913
+ function extractModelFamilyVersion(s) {
1914
+ const match = s.match(MODEL_FAMILY_VERSION_PATTERN);
1915
+ return match ? `${match[1]}.${match[2]}` : null;
1916
+ }
1917
+ function modelVersionsCompatible(preference, candidate) {
1918
+ const preferred = extractModelFamilyVersion(preference);
1919
+ if (!preferred)
1920
+ return true;
1921
+ const candidateVersion = extractModelFamilyVersion(candidate.value) ??
1922
+ extractModelFamilyVersion(candidate.displayName) ??
1923
+ extractModelFamilyVersion(candidate.description);
1924
+ if (!candidateVersion)
1925
+ return true;
1926
+ return preferred === candidateVersion;
1927
+ }
1194
1928
  function tokenizeModelPreference(model) {
1195
1929
  const lower = model.trim().toLowerCase();
1196
1930
  const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
@@ -1231,6 +1965,8 @@ function resolveModelPreference(models, preference) {
1231
1965
  return directMatch;
1232
1966
  // Substring match
1233
1967
  const includesMatch = models.find((model) => {
1968
+ if (!modelVersionsCompatible(trimmed, model))
1969
+ return false;
1234
1970
  const value = model.value.toLowerCase();
1235
1971
  const display = model.displayName.toLowerCase();
1236
1972
  return value.includes(lower) || display.includes(lower) || lower.includes(value);
@@ -1244,6 +1980,8 @@ function resolveModelPreference(models, preference) {
1244
1980
  let bestMatch = null;
1245
1981
  let bestScore = 0;
1246
1982
  for (const model of models) {
1983
+ if (!modelVersionsCompatible(trimmed, model))
1984
+ continue;
1247
1985
  const score = scoreModelMatch(model, tokens, contextHint);
1248
1986
  if (0 < score && (!bestMatch || bestScore < score)) {
1249
1987
  bestMatch = model;
@@ -1252,26 +1990,98 @@ function resolveModelPreference(models, preference) {
1252
1990
  }
1253
1991
  return bestMatch;
1254
1992
  }
1255
- async function getAvailableModels(query, models, settingsManager) {
1993
+ function resolveSettingsModel(models, settingsModel, logger) {
1994
+ if (settingsModel === undefined) {
1995
+ return null;
1996
+ }
1997
+ if (typeof settingsModel !== "string") {
1998
+ const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
1999
+ logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
2000
+ return null;
2001
+ }
2002
+ return resolveModelPreference(models, settingsModel);
2003
+ }
2004
+ /**
2005
+ * Restrict the SDK's model list to the user's `availableModels` allowlist
2006
+ * (already merged-and-deduped across settings sources by `SettingsManager`).
2007
+ * The user's exact entries become the model IDs surfaced via configOptions
2008
+ * and passed to `setModel`, which prevents Claude Code from silently
2009
+ * substituting a date-pinned variant (e.g. `haiku` →
2010
+ * `claude-haiku-4-5-20251001`) that the user may not have access to.
2011
+ *
2012
+ * Display info and capability flags are copied from the closest SDK match so
2013
+ * the UI still renders sensible names and effort levels.
2014
+ *
2015
+ * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
2016
+ * - `undefined` is handled by the caller (no allowlist applied).
2017
+ * - The Default option is unaffected by `availableModels` — it always remains
2018
+ * available, even when the allowlist is `[]`.
2019
+ */
2020
+ function applyAvailableModelsAllowlist(sdkModels, allowlist) {
2021
+ // Default is always preserved per the docs. Synthesize one if the SDK
2022
+ // didn't surface it so downstream code (e.g. `getAvailableModels` picking
2023
+ // `models[0]` as a fallback) still has something to work with.
2024
+ const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
2025
+ value: "default",
2026
+ displayName: "Default",
2027
+ description: "",
2028
+ };
2029
+ const result = [defaultModel];
2030
+ const seen = new Set([defaultModel.value]);
2031
+ const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
2032
+ for (const entry of allowlist) {
2033
+ const trimmed = entry.trim();
2034
+ if (!trimmed || seen.has(trimmed))
2035
+ continue;
2036
+ const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
2037
+ if (sdkMatch) {
2038
+ result.push({ ...sdkMatch, value: trimmed });
2039
+ }
2040
+ else {
2041
+ result.push({ value: trimmed, displayName: trimmed, description: "" });
2042
+ }
2043
+ seen.add(trimmed);
2044
+ }
2045
+ return result;
2046
+ }
2047
+ async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
1256
2048
  const settings = settingsManager.getSettings();
1257
2049
  let currentModel = models[0];
1258
- // 模型优先级(从高到低):
1259
- // 1. ANTHROPIC_MODEL 环境变量(用户期望的最高优先级)
1260
- // 2. settings.model(用户配置文件)
1261
- // 3. models[0](默认使用第一个模型)
2050
+ let resolvedFromInput;
2051
+ // Model priority (highest to lowest):
2052
+ // 1. ANTHROPIC_MODEL environment variable
2053
+ // 2. settings.model (user configuration)
2054
+ // 3. models[0] (default first model)
1262
2055
  if (process.env.ANTHROPIC_MODEL) {
1263
2056
  const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
1264
2057
  if (match) {
1265
2058
  currentModel = match;
2059
+ resolvedFromInput = process.env.ANTHROPIC_MODEL;
1266
2060
  }
1267
2061
  }
1268
- else if (settings.model) {
1269
- const match = resolveModelPreference(models, settings.model);
2062
+ else if (typeof settings.model === "string") {
2063
+ const match = resolveSettingsModel(models, settings.model, logger);
1270
2064
  if (match) {
1271
2065
  currentModel = match;
2066
+ resolvedFromInput = settings.model;
1272
2067
  }
1273
2068
  }
1274
- await query.setModel(currentModel.value);
2069
+ // Skip the setModel round-trip when we can prove the SDK has already landed
2070
+ // on the same model. Two cases qualify:
2071
+ // (a) No override applied — currentModel stayed at models[0]; the SDK is on
2072
+ // its own default and we have nothing to sync.
2073
+ // (b) The resolver returned the user's input verbatim AND that value exists
2074
+ // in the SDK's original model list — meaning no fuzzy match or
2075
+ // allowlist rewrite was involved, and the SDK (which reads the same
2076
+ // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
2077
+ // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
2078
+ // setModel call so we don't drift from the user's intended pin.
2079
+ const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
2080
+ const skipSetModel = resolvedFromInput === undefined ||
2081
+ (currentModel.value === resolvedFromInput && sdkSawSameValue);
2082
+ if (!skipSetModel) {
2083
+ await query.setModel(currentModel.value);
2084
+ }
1275
2085
  return {
1276
2086
  availableModels: models.map((model) => ({
1277
2087
  modelId: model.value,
@@ -1283,6 +2093,7 @@ async function getAvailableModels(query, models, settingsManager) {
1283
2093
  }
1284
2094
  function getAvailableSlashCommands(commands) {
1285
2095
  const UNSUPPORTED_COMMANDS = [
2096
+ "clear",
1286
2097
  "cost",
1287
2098
  "keybindings-help",
1288
2099
  "login",
@@ -1411,6 +2222,7 @@ export function promptToClaude(prompt) {
1411
2222
  * Only handles text, image, and thinking chunks for now.
1412
2223
  */
1413
2224
  export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
2225
+ const taskState = options?.taskState ?? new Map();
1414
2226
  const registerHooks = options?.registerHooks !== false;
1415
2227
  const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
1416
2228
  if (typeof content === "string") {
@@ -1474,14 +2286,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1474
2286
  const alreadyCached = chunk.id in toolUseCache;
1475
2287
  toolUseCache[chunk.id] = chunk;
1476
2288
  if (chunk.name === "TodoWrite") {
1477
- // @ts-expect-error - sometimes input is empty object
1478
- if (Array.isArray(chunk.input.todos)) {
2289
+ // @ts-expect-error - sometimes input is empty object or undefined
2290
+ if (Array.isArray(chunk.input?.todos)) {
1479
2291
  update = {
1480
2292
  sessionUpdate: "plan",
1481
2293
  entries: planEntries(chunk.input),
1482
2294
  };
1483
2295
  }
1484
2296
  }
2297
+ else if (chunk.name === "TaskCreate" ||
2298
+ chunk.name === "TaskUpdate" ||
2299
+ chunk.name === "TaskList" ||
2300
+ chunk.name === "TaskGet") {
2301
+ // Task* tool_use is suppressed; the plan update is emitted at
2302
+ // tool_result time once we have the task ID (for TaskCreate) and
2303
+ // confirmation that the change took effect.
2304
+ }
1485
2305
  else {
1486
2306
  // Only register hooks on first encounter to avoid double-firing
1487
2307
  if (registerHooks && !alreadyCached) {
@@ -1489,7 +2309,17 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1489
2309
  onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
1490
2310
  const toolUse = toolUseCache[toolUseId];
1491
2311
  if (toolUse) {
1492
- const editDiff = toolUse.name === "Edit" ? toolUpdateFromEditToolResponse(toolResponse) : {};
2312
+ // Both `Edit` and `Write` produce a structuredPatch in their
2313
+ // PostToolUse tool_response. For Edit the diff replaces the
2314
+ // optimistic content built at tool_use time. For Write the
2315
+ // optimistic content (built from `input.content` alone with
2316
+ // `oldText: null`) shows "creation" semantics regardless of
2317
+ // whether the file existed; the structuredPatch from the
2318
+ // hook lets us emit the real diff for `type: "update"`. The
2319
+ // helper returns `{}` if the response shape isn't usable.
2320
+ const editDiff = toolUse.name === "Edit" || toolUse.name === "Write"
2321
+ ? toolUpdateFromDiffToolResponse(toolResponse)
2322
+ : {};
1493
2323
  const update = {
1494
2324
  _meta: {
1495
2325
  claudeCode: {
@@ -1570,7 +2400,32 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1570
2400
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
1571
2401
  break;
1572
2402
  }
1573
- if (toolUse.name !== "TodoWrite") {
2403
+ if (toolUse.name === "TaskCreate" ||
2404
+ toolUse.name === "TaskUpdate" ||
2405
+ toolUse.name === "TaskList" ||
2406
+ toolUse.name === "TaskGet") {
2407
+ // Headless/SDK sessions emit Task* tools instead of TodoWrite.
2408
+ // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
2409
+ // and TaskGet are read-only so we just suppress their tool_call /
2410
+ // tool_result events. The plan update is emitted as a snapshot of
2411
+ // the accumulated state, mirroring the legacy TodoWrite behavior.
2412
+ const isError = "is_error" in chunk && chunk.is_error;
2413
+ if (!isError) {
2414
+ if (toolUse.name === "TaskCreate") {
2415
+ applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
2416
+ }
2417
+ else if (toolUse.name === "TaskUpdate") {
2418
+ applyTaskUpdate(taskState, toolUse.input);
2419
+ }
2420
+ }
2421
+ if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
2422
+ update = {
2423
+ sessionUpdate: "plan",
2424
+ entries: taskStateToPlanEntries(taskState),
2425
+ };
2426
+ }
2427
+ }
2428
+ else if (toolUse.name !== "TodoWrite") {
1574
2429
  const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
1575
2430
  // When terminal output is supported, send terminal_output as a
1576
2431
  // separate notification to match codex-acp's streaming lifecycle:
@@ -1617,6 +2472,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
1617
2472
  case "container_upload":
1618
2473
  case "compaction":
1619
2474
  case "compaction_delta":
2475
+ case "advisor_tool_result":
1620
2476
  break;
1621
2477
  default:
1622
2478
  unreachable(chunk, logger);
@@ -1645,14 +2501,20 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
1645
2501
  clientCapabilities: options?.clientCapabilities,
1646
2502
  parentToolUseId: message.parent_tool_use_id,
1647
2503
  cwd: options?.cwd,
2504
+ taskState: options?.taskState,
1648
2505
  });
1649
2506
  case "content_block_delta":
1650
2507
  return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
1651
2508
  clientCapabilities: options?.clientCapabilities,
1652
2509
  parentToolUseId: message.parent_tool_use_id,
1653
2510
  cwd: options?.cwd,
2511
+ taskState: options?.taskState,
1654
2512
  });
1655
- // No content
2513
+ // No content. `ping` is a Messages-API keep-alive event that the SDK's
2514
+ // `BetaRawMessageStreamEvent` union doesn't include even though the
2515
+ // wire format emits it; the `as never` cast lets us no-op it here
2516
+ // instead of letting it fall through to `unreachable`.
2517
+ case "ping":
1656
2518
  case "message_start":
1657
2519
  case "message_delta":
1658
2520
  case "message_stop":
@@ -1667,7 +2529,12 @@ export function runAcp() {
1667
2529
  const input = nodeToWebWritable(process.stdout);
1668
2530
  const output = nodeToWebReadable(process.stdin);
1669
2531
  const stream = ndJsonStream(input, output);
1670
- new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
2532
+ let agent;
2533
+ const connection = new AgentSideConnection((client) => {
2534
+ agent = new ClaudeAcpAgent(client);
2535
+ return agent;
2536
+ }, stream);
2537
+ return { connection, agent };
1671
2538
  }
1672
2539
  function commonPrefixLength(a, b) {
1673
2540
  let i = 0;
@@ -1676,6 +2543,30 @@ function commonPrefixLength(a, b) {
1676
2543
  }
1677
2544
  return i;
1678
2545
  }
2546
+ /** Best-effort first guess of a model's context window from its ID, used only
2547
+ * until a `result` message arrives with the authoritative `modelUsage` value.
2548
+ * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
2549
+ * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
2550
+ * matching things like "10m" or embedded substrings. */
2551
+ function inferContextWindowFromModel(model) {
2552
+ if (/\b1m\b/i.test(model))
2553
+ return 1_000_000;
2554
+ return null;
2555
+ }
2556
+ function parseModelConfig(raw) {
2557
+ if (!raw)
2558
+ return undefined;
2559
+ const parsed = JSON.parse(raw);
2560
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2561
+ throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object");
2562
+ }
2563
+ const result = {};
2564
+ if (parsed.modelOverrides !== undefined)
2565
+ result.modelOverrides = parsed.modelOverrides;
2566
+ if (parsed.availableModels !== undefined)
2567
+ result.availableModels = parsed.availableModels;
2568
+ return Object.keys(result).length > 0 ? result : undefined;
2569
+ }
1679
2570
  function getMatchingModelUsage(modelUsage, currentModel) {
1680
2571
  let bestKey = null;
1681
2572
  let bestLen = 0;