@lucascouts/claude-agent-acp-plus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4332 @@
1
+ import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
2
+ import { deleteSession, getSessionInfo, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
3
+ import { execFile } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import * as fs from "node:fs/promises";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import { promisify } from "node:util";
9
+ import packageJson from "../package.json" with { type: "json" };
10
+ import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
11
+ import { askUserQuestionFallbackEnabled, handleAskUserQuestionViaPermission, } from "./ask-user-question-fallback.js";
12
+ import { agentName } from "./agent-name.js";
13
+ import { SettingsManager } from "./settings.js";
14
+ import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
15
+ import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
16
+ export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
17
+ const execFileAsync = promisify(execFile);
18
+ const MAX_TITLE_LENGTH = 256;
19
+ function sanitizeTitle(text) {
20
+ // Replace newlines and collapse whitespace
21
+ const sanitized = text
22
+ .replace(/[\r\n]+/g, " ")
23
+ .replace(/\s+/g, " ")
24
+ .trim();
25
+ if (sanitized.length <= MAX_TITLE_LENGTH) {
26
+ return sanitized;
27
+ }
28
+ return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
29
+ }
30
+ const ZERO_USAGE = Object.freeze({
31
+ input_tokens: 0,
32
+ output_tokens: 0,
33
+ cache_read_input_tokens: 0,
34
+ cache_creation_input_tokens: 0,
35
+ });
36
+ const DEFAULT_CONTEXT_WINDOW = 200000;
37
+ /** Floor after `session/cancel` before the adapter forces the active prompt
38
+ * loop to return "cancelled". `query.interrupt()` normally makes the SDK
39
+ * yield a trailing idle within milliseconds, and the loop returns through its
40
+ * usual path — so this timer is armed and cleared, never fired, on healthy
41
+ * cancels. It only trips when the SDK is genuinely wedged (e.g. a
42
+ * `TaskOutput { block: true }` poll against a hung background task — issue
43
+ * #680) and never yields. The value is deliberately loose: it's an
44
+ * "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
45
+ * pre-empt a slow-but-healthy interrupt. */
46
+ const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
47
+ /** Error surfaced when the SDK declares a turn over (`session_state_changed:
48
+ * idle`, its authoritative turn-over signal) without ever emitting the turn's
49
+ * `result` — a model stream that dropped mid-turn, or an async agent that
50
+ * completed/stalled without the host turn resolving (issue #825). */
51
+ const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
52
+ "(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
53
+ /** Compute a stable fingerprint of the session-defining params so we can
54
+ * detect when a loadSession/resumeSession call requires tearing down and
55
+ * recreating the underlying Query process. MCP servers are sorted by name
56
+ * so that ordering differences don't trigger unnecessary recreations. */
57
+ function computeSessionFingerprint(params) {
58
+ const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
59
+ return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
60
+ }
61
+ export async function claudeCliPath() {
62
+ if (process.env.CLAUDE_CODE_EXECUTABLE) {
63
+ return process.env.CLAUDE_CODE_EXECUTABLE;
64
+ }
65
+ // The SDK's CLI is a native binary shipped as a platform-specific optional
66
+ // dependency of @anthropic-ai/claude-agent-sdk. Resolve via a require bound
67
+ // to the SDK so nested installs are found even when npm doesn't hoist.
68
+ const { createRequire } = await import("node:module");
69
+ const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk"));
70
+ const ext = process.platform === "win32" ? ".exe" : "";
71
+ // On linux, both glibc and musl variants may be installed side-by-side
72
+ // (e.g. bunx hydrates every optional dep), so picking one by trial is
73
+ // unreliable: the wrong binary segfaults at runtime instead of failing to
74
+ // spawn. Detect the runtime libc and prefer the matching variant, falling
75
+ // back to the other only if the preferred one isn't installed.
76
+ const candidates = process.platform === "linux"
77
+ ? isMuslLibc()
78
+ ? [
79
+ `@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
80
+ `@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
81
+ ]
82
+ : [
83
+ `@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
84
+ `@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
85
+ ]
86
+ : [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`];
87
+ for (const candidate of candidates) {
88
+ try {
89
+ return req.resolve(candidate);
90
+ }
91
+ catch {
92
+ // try next candidate
93
+ }
94
+ }
95
+ throw new Error(`Claude native binary not found for ${process.platform}-${process.arch}. ` +
96
+ `Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE.`);
97
+ }
98
+ function isMuslLibc() {
99
+ // process.report.getReport().header.glibcVersionRuntime is populated when
100
+ // Node is dynamically linked against glibc, and absent on musl.
101
+ const report = process.report?.getReport();
102
+ return !report?.header?.glibcVersionRuntime;
103
+ }
104
+ function shouldHideClaudeAuth() {
105
+ return process.argv.includes("--hide-claude-auth");
106
+ }
107
+ /** Returned to clients when a prompt or cancel targets a session whose SDK
108
+ * query stream has already ended (ran to `done` or died). The stream is not
109
+ * revivable, so the only recovery is a fresh session. */
110
+ const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session.";
111
+ // Bypass Permissions doesn't work if we are a root/sudo user
112
+ const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
113
+ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
114
+ // Slash commands that the SDK handles locally without replaying the user
115
+ // message and without invoking the model.
116
+ const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
117
+ // The Claude SDK persists local slash command invocations (e.g. `/model`) and
118
+ // their output as user messages in the session transcript, wrapping the
119
+ // payload in these XML-like markers that the CLI uses for its own display.
120
+ // The live prompt loop drops them; replay must strip them too or they leak
121
+ // into the UI on session/load.
122
+ const LOCAL_COMMAND_MARKERS = [
123
+ "command-name",
124
+ "command-message",
125
+ "command-args",
126
+ "local-command-stdout",
127
+ "local-command-stderr",
128
+ ].map((tag) => ({ open: `<${tag}>`, close: `</${tag}>` }));
129
+ // Single-pass scanner that removes each `<tag>…</tag>` marker (matching the
130
+ // nearest closing tag of the same name, like a lazy regex would).
131
+ function stripMarkerTags(text) {
132
+ const dead = new Set();
133
+ let result = "";
134
+ let copiedUpTo = 0;
135
+ let i = 0;
136
+ while (i < text.length) {
137
+ if (text[i] === "<") {
138
+ const marker = LOCAL_COMMAND_MARKERS.find((m) => !dead.has(m.open) && text.startsWith(m.open, i));
139
+ if (marker) {
140
+ const end = text.indexOf(marker.close, i + marker.open.length);
141
+ if (end !== -1) {
142
+ result += text.slice(copiedUpTo, i);
143
+ i = copiedUpTo = end + marker.close.length;
144
+ continue;
145
+ }
146
+ // No closing marker remains anywhere ahead, and `indexOf` only ever
147
+ // searches forward from here on, so stop treating this tag as an
148
+ // opener — that avoids rescanning the tail for it on every match.
149
+ dead.add(marker.open);
150
+ }
151
+ }
152
+ i++;
153
+ }
154
+ return result + text.slice(copiedUpTo);
155
+ }
156
+ /**
157
+ * Return user-message content with local-command marker tags removed, or
158
+ * `null` if nothing meaningful remains (caller should skip the message).
159
+ * Preserves real prose that's mixed in alongside the markers — e.g. a
160
+ * message like `<command-name>…</command-name>hi` becomes `hi`.
161
+ */
162
+ export function stripLocalCommandMetadata(content) {
163
+ if (typeof content === "string") {
164
+ const stripped = stripMarkerTags(content);
165
+ return stripped.trim() === "" ? null : stripped;
166
+ }
167
+ if (!Array.isArray(content))
168
+ return content;
169
+ const kept = [];
170
+ for (const block of content) {
171
+ if (block &&
172
+ typeof block === "object" &&
173
+ "type" in block &&
174
+ block.type === "text" &&
175
+ "text" in block &&
176
+ typeof block.text === "string") {
177
+ const stripped = stripMarkerTags(block.text);
178
+ if (stripped.trim() === "")
179
+ continue;
180
+ kept.push({ ...block, text: stripped });
181
+ }
182
+ else {
183
+ kept.push(block);
184
+ }
185
+ }
186
+ if (kept.length === 0)
187
+ return null;
188
+ return kept;
189
+ }
190
+ export function isLocalCommandMetadata(content) {
191
+ return stripLocalCommandMetadata(content) === null;
192
+ }
193
+ const PERMISSION_MODE_ALIASES = {
194
+ auto: "auto",
195
+ default: "default",
196
+ // Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts
197
+ // `"defaultMode": "manual"` in settings.json; honor the same alias here.
198
+ manual: "default",
199
+ acceptedits: "acceptEdits",
200
+ dontask: "dontAsk",
201
+ plan: "plan",
202
+ bypasspermissions: "bypassPermissions",
203
+ bypass: "bypassPermissions",
204
+ };
205
+ export function resolvePermissionMode(defaultMode, logger = console) {
206
+ if (defaultMode === undefined) {
207
+ return "default";
208
+ }
209
+ if (typeof defaultMode !== "string") {
210
+ logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
211
+ return "default";
212
+ }
213
+ const normalized = defaultMode.trim().toLowerCase();
214
+ if (normalized === "") {
215
+ logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
216
+ return "default";
217
+ }
218
+ const mapped = PERMISSION_MODE_ALIASES[normalized];
219
+ if (!mapped) {
220
+ logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
221
+ return "default";
222
+ }
223
+ if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
224
+ logger.error("Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.");
225
+ return "default";
226
+ }
227
+ return mapped;
228
+ }
229
+ /**
230
+ * Builds the label for the "Always Allow" permission option so the user can see
231
+ * the exact scope they are committing to. Uses the SDK-provided suggestions
232
+ * when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
233
+ * tool so "Always Allow" is never a blank check without disclosure.
234
+ */
235
+ export function describeAlwaysAllow(suggestions, toolName) {
236
+ if (!suggestions || suggestions.length === 0) {
237
+ return `Always Allow all ${toolName}`;
238
+ }
239
+ const ruleLabels = [];
240
+ const directories = [];
241
+ for (const update of suggestions) {
242
+ if (update.type === "addRules" && update.behavior === "allow") {
243
+ for (const rule of update.rules) {
244
+ ruleLabels.push(rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`);
245
+ }
246
+ }
247
+ else if (update.type === "addDirectories") {
248
+ directories.push(...update.directories);
249
+ }
250
+ }
251
+ const parts = [];
252
+ if (ruleLabels.length > 0) {
253
+ parts.push(ruleLabels.join(", "));
254
+ }
255
+ if (directories.length > 0) {
256
+ parts.push(`access to ${directories.join(", ")}`);
257
+ }
258
+ if (parts.length === 0) {
259
+ return `Always Allow all ${toolName}`;
260
+ }
261
+ return `Always Allow ${parts.join(" and ")}`;
262
+ }
263
+ /**
264
+ * Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
265
+ * exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
266
+ * valid for the entire connection lifetime, so it is captured once at
267
+ * construction.
268
+ */
269
+ class ClientConnection {
270
+ ctx;
271
+ constructor(ctx) {
272
+ this.ctx = ctx;
273
+ }
274
+ sessionUpdate(params) {
275
+ return this.ctx.notify(methods.client.session.update, params);
276
+ }
277
+ requestPermission(params, signal) {
278
+ return this.ctx.request(methods.client.session.requestPermission, params, {
279
+ cancellationSignal: signal,
280
+ });
281
+ }
282
+ readTextFile(params) {
283
+ return this.ctx.request(methods.client.fs.readTextFile, params);
284
+ }
285
+ writeTextFile(params) {
286
+ return this.ctx.request(methods.client.fs.writeTextFile, params);
287
+ }
288
+ unstable_createElicitation(params, signal) {
289
+ return this.ctx.request(methods.client.elicitation.create, params, {
290
+ cancellationSignal: signal,
291
+ });
292
+ }
293
+ unstable_completeElicitation(params) {
294
+ return this.ctx.notify(methods.client.elicitation.complete, params);
295
+ }
296
+ extNotification(method, params) {
297
+ return this.ctx.notify(method, params);
298
+ }
299
+ }
300
+ export class ClaudeAcpAgent {
301
+ sessions;
302
+ client;
303
+ clientCapabilities;
304
+ logger;
305
+ gatewayAuthRequest;
306
+ /** Grace period before a `session/cancel` forces a wedged prompt loop to
307
+ * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
308
+ * tests can shrink it. */
309
+ forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
310
+ constructor(client, logger) {
311
+ this.sessions = {};
312
+ this.client = client;
313
+ this.logger = logger ?? console;
314
+ }
315
+ async initialize(request) {
316
+ this.clientCapabilities = request.clientCapabilities;
317
+ // Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
318
+ // Only offered when the client advertises `auth._meta.gateway` capability.
319
+ const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
320
+ const gatewayAuthMethod = {
321
+ id: "gateway",
322
+ name: "Custom model gateway",
323
+ description: "Use a custom gateway to authenticate and access models",
324
+ _meta: {
325
+ gateway: {
326
+ protocol: "anthropic",
327
+ },
328
+ },
329
+ };
330
+ const gatewayBedrockAuthMethod = {
331
+ id: "gateway-bedrock",
332
+ name: "Custom model gateway",
333
+ description: "Use a custom gateway to authenticate and access models",
334
+ _meta: {
335
+ gateway: {
336
+ protocol: "bedrock",
337
+ },
338
+ },
339
+ };
340
+ const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
341
+ const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
342
+ // Detect remote environments where the OAuth browser redirect to localhost
343
+ // won't work. This matches the SDK's internal isRemote check. In these cases,
344
+ // the `auth login` subcommand would fall back to a device-code-like manual
345
+ // flow, which doesn't work well over ACP, so we offer the TUI login instead.
346
+ const isRemote = !!(process.env.NO_BROWSER ||
347
+ process.env.SSH_CONNECTION ||
348
+ process.env.SSH_CLIENT ||
349
+ process.env.SSH_TTY ||
350
+ process.env.CLAUDE_CODE_REMOTE);
351
+ const terminalAuthMethods = [];
352
+ if (isRemote) {
353
+ const remoteLoginMethod = {
354
+ description: "Run `claude /login` in the terminal",
355
+ name: "Log in with Claude",
356
+ id: "claude-login",
357
+ type: "terminal",
358
+ args: ["--cli"],
359
+ };
360
+ if (supportsMetaTerminalAuth) {
361
+ remoteLoginMethod._meta = {
362
+ "terminal-auth": {
363
+ command: process.execPath,
364
+ args: [...process.argv.slice(1), "--cli"],
365
+ label: "Claude Login",
366
+ },
367
+ };
368
+ }
369
+ if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
370
+ terminalAuthMethods.push(remoteLoginMethod);
371
+ }
372
+ }
373
+ else {
374
+ const claudeLoginMethod = {
375
+ description: "Use Claude subscription ",
376
+ name: "Claude Subscription",
377
+ id: "claude-ai-login",
378
+ type: "terminal",
379
+ args: ["--cli", "auth", "login", "--claudeai"],
380
+ };
381
+ const consoleLoginMethod = {
382
+ description: "Use Anthropic Console (API usage billing)",
383
+ name: "Anthropic Console",
384
+ id: "console-login",
385
+ type: "terminal",
386
+ args: ["--cli", "auth", "login", "--console"],
387
+ };
388
+ if (supportsMetaTerminalAuth) {
389
+ const baseArgs = process.argv.slice(1);
390
+ claudeLoginMethod._meta = {
391
+ "terminal-auth": {
392
+ command: process.execPath,
393
+ args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
394
+ label: "Claude Login",
395
+ },
396
+ };
397
+ consoleLoginMethod._meta = {
398
+ "terminal-auth": {
399
+ command: process.execPath,
400
+ args: [...baseArgs, "--cli", "auth", "login", "--console"],
401
+ label: "Anthropic Console Login",
402
+ },
403
+ };
404
+ }
405
+ if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
406
+ terminalAuthMethods.push(claudeLoginMethod);
407
+ }
408
+ if (supportsTerminalAuth || supportsMetaTerminalAuth) {
409
+ terminalAuthMethods.push(consoleLoginMethod);
410
+ }
411
+ }
412
+ return {
413
+ protocolVersion: 1,
414
+ agentCapabilities: {
415
+ _meta: {
416
+ claudeCode: {
417
+ promptQueueing: true,
418
+ },
419
+ },
420
+ promptCapabilities: {
421
+ image: true,
422
+ embeddedContext: true,
423
+ },
424
+ mcpCapabilities: {
425
+ http: true,
426
+ sse: true,
427
+ },
428
+ auth: {
429
+ logout: {},
430
+ },
431
+ loadSession: true,
432
+ sessionCapabilities: {
433
+ additionalDirectories: {},
434
+ close: {},
435
+ delete: {},
436
+ fork: {},
437
+ list: {},
438
+ resume: {},
439
+ },
440
+ },
441
+ agentInfo: {
442
+ name: packageJson.name,
443
+ title: "Claude Agent",
444
+ version: packageJson.version,
445
+ },
446
+ authMethods: [
447
+ ...terminalAuthMethods,
448
+ ...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
449
+ ],
450
+ };
451
+ }
452
+ async newSession(params) {
453
+ const response = await this.createSession(params, {
454
+ // Revisit these meta values once we support resume
455
+ resume: params._meta?.claudeCode?.options?.resume,
456
+ });
457
+ // Needs to happen after we return the session
458
+ setTimeout(() => {
459
+ this.sendAvailableCommandsUpdate(response.sessionId);
460
+ }, 0);
461
+ return response;
462
+ }
463
+ async unstable_forkSession(params) {
464
+ const response = await this.createSession({
465
+ cwd: params.cwd,
466
+ mcpServers: params.mcpServers ?? [],
467
+ additionalDirectories: params.additionalDirectories,
468
+ _meta: params._meta,
469
+ }, {
470
+ resume: params.sessionId,
471
+ forkSession: true,
472
+ });
473
+ // Needs to happen after we return the session
474
+ setTimeout(() => {
475
+ this.sendAvailableCommandsUpdate(response.sessionId);
476
+ }, 0);
477
+ return response;
478
+ }
479
+ async resumeSession(params) {
480
+ const result = await this.getOrCreateSession(params);
481
+ // Needs to happen after we return the session
482
+ setTimeout(() => {
483
+ this.sendAvailableCommandsUpdate(params.sessionId);
484
+ }, 0);
485
+ return result;
486
+ }
487
+ async loadSession(params) {
488
+ const result = await this.getOrCreateSession(params);
489
+ await this.replaySessionHistory(params.sessionId);
490
+ // Send available commands after replay so it doesn't interleave with history
491
+ setTimeout(() => {
492
+ this.sendAvailableCommandsUpdate(params.sessionId);
493
+ }, 0);
494
+ return result;
495
+ }
496
+ async listSessions(params) {
497
+ const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
498
+ const sessions = [];
499
+ for (const session of sdk_sessions) {
500
+ if (!session.cwd)
501
+ continue;
502
+ sessions.push({
503
+ sessionId: session.sessionId,
504
+ cwd: session.cwd,
505
+ title: sanitizeTitle(session.summary),
506
+ updatedAt: new Date(session.lastModified).toISOString(),
507
+ });
508
+ }
509
+ return {
510
+ sessions,
511
+ };
512
+ }
513
+ /** Read the SDK-maintained title for a session and, if it changed since the
514
+ * last time we looked, notify the client with a `session_info_update`. The
515
+ * SDK has no push event for the title it auto-generates in the background, so
516
+ * we pull it at turn-end. A missing session file or read error is non-fatal:
517
+ * the title is best-effort and another turn will retry. */
518
+ async maybeUpdateSessionTitle(sessionId, session) {
519
+ let info;
520
+ try {
521
+ info = await getSessionInfo(sessionId, { dir: session.cwd });
522
+ }
523
+ catch (error) {
524
+ this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`);
525
+ return;
526
+ }
527
+ // `customTitle` is a user-set `/rename`; `summary` is the auto-generated
528
+ // title (or first prompt). Prefer the explicit title when present.
529
+ const rawTitle = info?.customTitle ?? info?.summary;
530
+ if (!rawTitle) {
531
+ return;
532
+ }
533
+ const title = sanitizeTitle(rawTitle);
534
+ if (title === session.lastTitle) {
535
+ return;
536
+ }
537
+ session.lastTitle = title;
538
+ await this.client.sessionUpdate({
539
+ sessionId,
540
+ update: {
541
+ sessionUpdate: "session_info_update",
542
+ title,
543
+ updatedAt: new Date(info.lastModified).toISOString(),
544
+ },
545
+ });
546
+ }
547
+ async authenticate(_params) {
548
+ if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
549
+ this.gatewayAuthRequest = _params;
550
+ return;
551
+ }
552
+ throw new Error("Method not implemented.");
553
+ }
554
+ async logout(_params) {
555
+ // Clear in-memory gateway credentials supplied via `authenticate`. The
556
+ // gateway method never touches the on-disk credential store, so dropping
557
+ // this reference is the whole logout for that path.
558
+ this.gatewayAuthRequest = undefined;
559
+ // For the Claude/Console login methods the credentials live in the native
560
+ // CLI's store (keychain or config dir), which only the binary can clear.
561
+ // `claude auth logout` is non-interactive and idempotent.
562
+ const cliPath = await claudeCliPath();
563
+ try {
564
+ await execFileAsync(cliPath, ["auth", "logout"]);
565
+ }
566
+ catch (error) {
567
+ const stderr = typeof error === "object" && error && "stderr" in error
568
+ ? String(error.stderr).trim()
569
+ : undefined;
570
+ throw RequestError.internalError({ stderr: stderr || undefined }, `claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`);
571
+ }
572
+ }
573
+ async prompt(params) {
574
+ const session = this.sessions[params.sessionId];
575
+ if (!session) {
576
+ throw new Error("Session not found");
577
+ }
578
+ // The SDK query stream already terminated (see `queryClosed`); its iterator
579
+ // can't be revived, so enqueueing here would hang on a deferred that never
580
+ // settles. Fail clearly and let the client start a fresh session.
581
+ if (session.queryClosed) {
582
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
583
+ }
584
+ const userMessage = promptToClaude(params);
585
+ const promptUuid = randomUUID();
586
+ userMessage.uuid = promptUuid;
587
+ // Local-only commands (e.g. `/clear`) return a result without replaying the
588
+ // user message, so the consumer can't promote the turn from the echo.
589
+ const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
590
+ const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
591
+ // Each prompt is a Turn whose deferred the persistent consumer settles once
592
+ // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
593
+ // turn, pushes the user message onto the streaming input, makes sure the
594
+ // consumer is running, and awaits the deferred.
595
+ const turn = {
596
+ promptUuid,
597
+ isLocalOnlyCommand,
598
+ settled: false,
599
+ resolve: () => { },
600
+ reject: () => { },
601
+ };
602
+ const response = new Promise((resolve, reject) => {
603
+ turn.resolve = resolve;
604
+ turn.reject = reject;
605
+ });
606
+ session.turnQueue ??= [];
607
+ session.turnQueue.push(turn);
608
+ session.input.push(userMessage);
609
+ this.ensureConsumer(session, params.sessionId);
610
+ return response;
611
+ }
612
+ /** Lazily start the per-session consumer that drains the SDK query stream for
613
+ * the session's whole life. Idempotent: only the first `prompt()` starts it. */
614
+ ensureConsumer(session, sessionId) {
615
+ if (session.consumer) {
616
+ return;
617
+ }
618
+ // Wake-up channel so cancel() can force the consumer to settle the active
619
+ // turn "cancelled" even when query.next() is wedged and never yields again
620
+ // (issue #680). The consumer re-arms it after each fire.
621
+ session.cancelController = new AbortController();
622
+ session.consumer = this.runConsumer(session, { sessionId });
623
+ session.consumer.catch((error) => {
624
+ this.logger.error(`Session ${sessionId}: consumer terminated unexpectedly: ${error}`);
625
+ });
626
+ }
627
+ /** The single, long-lived consumer of the SDK query stream for a session. It
628
+ * forwards every message as ACP `sessionUpdate`s (so background/between-turn
629
+ * output streams live, not just while a prompt is awaiting) and settles each
630
+ * Turn's deferred when that turn ends. Replaces the per-prompt message loop;
631
+ * `params` only carries the (session-invariant) `sessionId`. */
632
+ async runConsumer(session, params) {
633
+ // Per-turn scratch, reset whenever a turn becomes active. Kept as consumer
634
+ // locals (rather than per-Turn fields) because they describe the message
635
+ // currently being processed, which is sequential — exactly one turn is
636
+ // active at a time. Mirrors the locals the old per-prompt loop held.
637
+ let lastAssistantTotalUsage = null;
638
+ let lastAssistantUsage = null;
639
+ let lastAssistantModel = null;
640
+ // When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
641
+ // problem, billing), it sets a categorical `error` field on the
642
+ // `SDKAssistantMessage` that precedes the final `result` message. We capture
643
+ // it here so the subsequent `RequestError.internalError` can forward it to
644
+ // clients as structured `data`, sparing them from pattern-matching on text.
645
+ let lastAssistantError;
646
+ // When a streaming classifier refuses a turn, the assistant message carries
647
+ // stop_reason "refusal" and structured stop_details. We capture the
648
+ // human-readable explanation so the terminal `result` can surface it.
649
+ let lastRefusalExplanation = null;
650
+ // Tracks whether we're inside a compaction. The SDK emits the terminal
651
+ // `status` (compact_result success/failed) twice for a single failed
652
+ // compaction, and the two messages are indistinguishable — so we report the
653
+ // outcome only while a compaction is in progress, then clear this.
654
+ let compactionInProgress = false;
655
+ // Anthropic API message id of the assistant message currently being
656
+ // streamed, captured from `message_start` so the streamed chunks that follow
657
+ // (whose delta events don't carry it) can all be tagged with the same,
658
+ // replay-stable id.
659
+ let currentStreamMessageId;
660
+ // The text/thinking blocks that have actually streamed live as
661
+ // `stream_event` deltas for the message the next consolidated `assistant`
662
+ // will repeat, in stream order, each accumulated to its full streamed text.
663
+ // The consolidated handler diffs each assembled block against these and
664
+ // forwards only the un-streamed remainder — nothing if it streamed in full
665
+ // (the common case), the whole block if it never streamed (a non-streaming
666
+ // gateway), or just the tail if the stream was cut short mid-block. Matching
667
+ // on content rather than the Anthropic message id makes dedupe robust to
668
+ // gateways that don't carry a stable/matching id across the stream and the
669
+ // consolidated message. Reset after each consolidated message consumes it.
670
+ const streamedBlocks = [];
671
+ // Stop reason accumulated for the active turn (result subtype, refusal,
672
+ // max_tokens, …). Reset per turn; read when the turn settles at idle.
673
+ let stopReason = "end_turn";
674
+ // How many trailing `session_state_changed: idle` messages are already
675
+ // accounted for: every user-turn result that terminates a turn (settle,
676
+ // reject, or orphan skip) is followed by one, as is a cancelled turn
677
+ // settled by the next turn's echo hand-off. The idle handler absorbs owed
678
+ // idles; an idle that arrives when NONE is owed while the active turn is
679
+ // still unsettled means the SDK ended the turn without ever emitting its
680
+ // result, so the turn will never settle on its own (issue #825).
681
+ // Stream-level debt, deliberately NOT reset per turn: a lagged idle can
682
+ // arrive after the next turn has already activated (issue #773), and the
683
+ // debt is what attributes it to the turn that owed it. Over-counting (an
684
+ // idle the SDK never emits, e.g. CLI binaries without session-state
685
+ // events — issue #497) is benign: the counter just absorbs one future
686
+ // idle, and detection degrades to the status quo rather than misfiring.
687
+ let owedTrailingIdles = 0;
688
+ const resetTurnScratch = () => {
689
+ lastAssistantTotalUsage = null;
690
+ lastAssistantUsage = null;
691
+ lastAssistantModel = null;
692
+ lastAssistantError = undefined;
693
+ lastRefusalExplanation = null;
694
+ compactionInProgress = false;
695
+ // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
696
+ // activation can fire mid-message (the replayed user echo with
697
+ // --replay-user-messages lands between a message's blocks); clearing the
698
+ // streamed-content record on activation would drop the blocks that
699
+ // streamed before the echo, so the consolidated assistant message would
700
+ // re-emit them as duplicates. streamedBlocks is bounded instead by being
701
+ // cleared when each consolidated message consumes it. #785 stopped
702
+ // resetting the streamed-content tracking here but left this line.
703
+ stopReason = "end_turn";
704
+ session.accumulatedUsage = {
705
+ inputTokens: 0,
706
+ outputTokens: 0,
707
+ cachedReadTokens: 0,
708
+ cachedWriteTokens: 0,
709
+ };
710
+ };
711
+ /** Promote a queued turn to active: it becomes the one output is attributed
712
+ * to, and its scratch starts fresh. Clears the cancelled flag so a turn
713
+ * enqueued after a prior cancel isn't treated as cancelled. Also clears any
714
+ * leftover orphan-skip count: since the SDK echoes/runs input FIFO, every
715
+ * orphan from a prior cancel has already arrived by the time a live turn
716
+ * activates, so a non-zero remainder means the SDK dropped a queued turn on
717
+ * interrupt (no orphan emitted) — drop the stale count so a later echo-less
718
+ * result isn't wrongly skipped. */
719
+ const activateTurn = (turn) => {
720
+ session.activeTurn = turn;
721
+ session.cancelled = false;
722
+ session.pendingOrphanResults = 0;
723
+ resetTurnScratch();
724
+ };
725
+ /** Ensure there is an active turn before a user-turn result that carries no
726
+ * echo to activate it, by promoting the queue head. Most turns are
727
+ * activated by their replayed user message before their result, but some
728
+ * legitimately produce a result with no matching echo: local-only commands
729
+ * (e.g. `/context`) and compaction (`/compact`, whose only user messages
730
+ * are the generated summary and a `<local-command-stdout>` replay — neither
731
+ * carries the prompt's uuid). Promoting the head settles those.
732
+ *
733
+ * But an echo-less result can also be an ORPHAN: cancel() settles+removes a
734
+ * queued turn whose user message was already pushed, so the SDK still runs
735
+ * it and emits a result with no uuid to match. Promoting the head for an
736
+ * orphan would misattribute its stop reason/usage to an unrelated later
737
+ * prompt. `session.pendingOrphanResults` counts exactly how many such
738
+ * orphans are still expected (FIFO, they arrive before any live turn's
739
+ * result), so we skip those and only promote once the count is drained. */
740
+ const ensureActiveTurn = () => {
741
+ if (session.activeTurn) {
742
+ return;
743
+ }
744
+ const head = (session.turnQueue ?? []).find((t) => !t.settled);
745
+ if (!head) {
746
+ return;
747
+ }
748
+ if ((session.pendingOrphanResults ?? 0) > 0) {
749
+ session.pendingOrphanResults--;
750
+ return;
751
+ }
752
+ activateTurn(head);
753
+ };
754
+ /** Settle the active turn's deferred exactly once, disarm the force-cancel
755
+ * backstop (the turn is over), and drop it from the queue. */
756
+ const settleActive = (result) => {
757
+ const turn = session.activeTurn;
758
+ if (!turn || turn.settled) {
759
+ return;
760
+ }
761
+ turn.settled = true;
762
+ if (session.forceCancelTimer) {
763
+ clearTimeout(session.forceCancelTimer);
764
+ session.forceCancelTimer = undefined;
765
+ }
766
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
767
+ session.activeTurn = null;
768
+ turn.resolve(result);
769
+ };
770
+ /** Reject the active turn (auth required, error result, …) without tearing
771
+ * down the consumer: the stream continues to idle and later turns proceed. */
772
+ const failActive = (error) => {
773
+ if (session.forceCancelTimer) {
774
+ clearTimeout(session.forceCancelTimer);
775
+ session.forceCancelTimer = undefined;
776
+ }
777
+ const turn = session.activeTurn;
778
+ if (!turn || turn.settled) {
779
+ return;
780
+ }
781
+ turn.settled = true;
782
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
783
+ session.activeTurn = null;
784
+ turn.reject(error);
785
+ };
786
+ /** Reject every in-flight turn — used when the stream dies. */
787
+ const failAllTurns = (error) => {
788
+ if (session.forceCancelTimer) {
789
+ clearTimeout(session.forceCancelTimer);
790
+ session.forceCancelTimer = undefined;
791
+ }
792
+ const turns = session.activeTurn
793
+ ? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
794
+ : [...(session.turnQueue ?? [])];
795
+ session.activeTurn = null;
796
+ session.turnQueue = [];
797
+ for (const turn of turns) {
798
+ if (!turn.settled) {
799
+ turn.settled = true;
800
+ turn.reject(error);
801
+ }
802
+ }
803
+ };
804
+ // The wake-up channel cancel()/teardown aborts to force the active turn to
805
+ // settle "cancelled" even when query.next() is wedged (issue #680). Re-armed
806
+ // after each fire so the consumer keeps serving later turns.
807
+ let cancelController = session.cancelController;
808
+ // The in-flight query.next(), kept across abort wake-ups that don't
809
+ // consume a message, so no yielded message is ever dropped — async
810
+ // generators serialize next() calls, so racing a SECOND next() while one
811
+ // is pending would make the abandoned one swallow a message (e.g. a
812
+ // force-cancelled turn's late result, whose orphan accounting below
813
+ // depends on actually seeing it).
814
+ let pendingNext = null;
815
+ try {
816
+ while (true) {
817
+ pendingNext ??= session.query
818
+ .next()
819
+ .then((result) => ({ kind: "message", result }));
820
+ const nextMessage = pendingNext;
821
+ // Fresh abort listener per iteration, removed when next() wins, so a
822
+ // long-lived session doesn't accumulate listeners on one signal.
823
+ let onAbort;
824
+ const abortRace = new Promise((resolve) => {
825
+ onAbort = () => resolve("abort");
826
+ cancelController.signal.addEventListener("abort", onAbort, { once: true });
827
+ });
828
+ const raced = await Promise.race([nextMessage, abortRace]);
829
+ cancelController.signal.removeEventListener("abort", onAbort);
830
+ if (raced === "abort") {
831
+ // cancel()/teardown woke us: settle the active turn "cancelled" per
832
+ // the ACP contract. The SDK never acknowledged this turn (that's why
833
+ // the force-cancel backstop fired), so if it later recovers from the
834
+ // wedge it will still emit the turn's result — with no live turn to
835
+ // match — followed by its trailing idle. Pre-count it as an orphan
836
+ // so that late result is skipped (not promoted onto the next queued
837
+ // prompt) and its trailer is recorded as owed, not read as the next
838
+ // turn being abandoned. Stale counts self-heal: activation resets
839
+ // them (see activateTurn).
840
+ if (session.activeTurn && !session.activeTurn.settled) {
841
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
842
+ }
843
+ settleActive({ stopReason: "cancelled" });
844
+ // If the session is being torn down, abandon the in-flight next()
845
+ // (swallowing any later rejection so it can't surface as unhandled)
846
+ // and stop; otherwise re-arm and keep consuming — `pendingNext`
847
+ // stays in flight so its eventual message is processed, not dropped.
848
+ if (!this.sessions[params.sessionId]) {
849
+ void nextMessage.catch(() => { });
850
+ return;
851
+ }
852
+ cancelController = new AbortController();
853
+ session.cancelController = cancelController;
854
+ continue;
855
+ }
856
+ // A message arrived: this next() is consumed; arm a fresh one next pass.
857
+ pendingNext = null;
858
+ const { value: message, done } = raced.result;
859
+ if (done || !message) {
860
+ // The stream ended. Settle the in-flight turns FIRST, then release the
861
+ // stream resources — same order as the error paths (failAllTurns before
862
+ // closeQueryStream). Settling is the user-facing contract; resource
863
+ // release is best-effort cleanup, so a throw there must not pre-empt a
864
+ // turn's real outcome.
865
+ //
866
+ // Settle the turn that was in flight so its prompt() doesn't hang:
867
+ // cancelled if a cancel is pending, otherwise the accumulated outcome.
868
+ settleActive(session.cancelled
869
+ ? { stopReason: "cancelled" }
870
+ : { stopReason, usage: sessionUsage(session) });
871
+ // Queued turns the SDK never started never ran, so reject them rather
872
+ // than reporting a success (end_turn) — or a misleading "cancelled" —
873
+ // for a prompt that produced no output. (A cancel already settled the
874
+ // turns that were queued at cancel time and removed them, so anything
875
+ // still here was enqueued afterward and was not part of the cancel.)
876
+ for (const queued of [...(session.turnQueue ?? [])]) {
877
+ if (!queued.settled) {
878
+ queued.settled = true;
879
+ queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
880
+ }
881
+ }
882
+ session.turnQueue = [];
883
+ // The query iterator can't be revived, so close the session's stream
884
+ // (marks queryClosed, drops the consumer handle, releases the dead
885
+ // subprocess/settings resources) — a later prompt() then rejects up
886
+ // front rather than restarting a consumer on the exhausted stream.
887
+ this.closeQueryStream(session);
888
+ return;
889
+ }
890
+ if (session.emitRawSDKMessages &&
891
+ shouldEmitRawMessage(session.emitRawSDKMessages, message)) {
892
+ await this.client.extNotification("_claude/sdkMessage", {
893
+ sessionId: params.sessionId,
894
+ message: message,
895
+ });
896
+ }
897
+ switch (message.type) {
898
+ case "system":
899
+ switch (message.subtype) {
900
+ case "init":
901
+ // A fresh `system`/init (e.g. after reinitialize) can carry an
902
+ // updated Fast mode state; reconcile it with what we seeded at
903
+ // session creation.
904
+ await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
905
+ break;
906
+ case "status": {
907
+ if (message.status === "compacting") {
908
+ compactionInProgress = true;
909
+ await this.client.sessionUpdate({
910
+ sessionId: message.session_id,
911
+ update: {
912
+ sessionUpdate: "agent_message_chunk",
913
+ content: { type: "text", text: "Compacting..." },
914
+ },
915
+ });
916
+ }
917
+ else if (message.compact_result === "success" && compactionInProgress) {
918
+ // The SDK signals manual `/compact` completion with a status
919
+ // message carrying `compact_result`, not the `compact_boundary`
920
+ // message (which only fires when there's content to compact).
921
+ compactionInProgress = false;
922
+ await this.client.sessionUpdate({
923
+ sessionId: message.session_id,
924
+ update: {
925
+ sessionUpdate: "agent_message_chunk",
926
+ content: { type: "text", text: "\n\nCompacting completed." },
927
+ },
928
+ });
929
+ }
930
+ else if (message.compact_result === "failed" && compactionInProgress) {
931
+ compactionInProgress = false;
932
+ const reason = message.compact_error ? `: ${message.compact_error}` : ".";
933
+ await this.client.sessionUpdate({
934
+ sessionId: message.session_id,
935
+ update: {
936
+ sessionUpdate: "agent_message_chunk",
937
+ content: { type: "text", text: `\n\nCompacting failed${reason}` },
938
+ },
939
+ });
940
+ }
941
+ break;
942
+ }
943
+ case "compact_boundary": {
944
+ // Refresh the displayed usage immediately so the client doesn't
945
+ // keep showing the stale pre-compaction size (e.g. "944k/1m")
946
+ // right after the user sees "Compacting completed", which is
947
+ // confusing and wrong.
948
+ //
949
+ // Prefer the SDK's authoritative post-compaction `used` via
950
+ // getContextUsage — it reflects the real retained context
951
+ // (system prompt + tools + surviving messages), which the
952
+ // per-message API usage numbers can't give us until the next
953
+ // turn's result. If the control request fails, fall back to the
954
+ // used:0 approximation: directionally correct (context just
955
+ // dropped dramatically) and replaced within seconds by the next
956
+ // result message.
957
+ //
958
+ // `size` keeps coming from session.contextWindowSize (learned
959
+ // from modelUsage / the model heuristic) — getContextUsage's
960
+ // window field under-reports extended 1M windows.
961
+ //
962
+ // The "Compacting completed." text is emitted from the `status`
963
+ // handler (keyed on `compact_result`), not here, so the failure
964
+ // path gets a message too.
965
+ const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
966
+ lastAssistantUsage = null;
967
+ lastAssistantTotalUsage = usedTokens ?? 0;
968
+ await this.client.sessionUpdate({
969
+ sessionId: message.session_id,
970
+ update: {
971
+ sessionUpdate: "usage_update",
972
+ used: lastAssistantTotalUsage,
973
+ size: session.contextWindowSize,
974
+ },
975
+ });
976
+ break;
977
+ }
978
+ case "local_command_output": {
979
+ await this.client.sessionUpdate({
980
+ sessionId: message.session_id,
981
+ update: {
982
+ sessionUpdate: "agent_message_chunk",
983
+ content: { type: "text", text: message.content },
984
+ },
985
+ });
986
+ break;
987
+ }
988
+ case "session_state_changed": {
989
+ if (message.state === "idle") {
990
+ // A non-cancelled turn normally settled at its terminal
991
+ // `result` already (issue #773), and that result recorded an
992
+ // owed trailing idle — absorbed here via the decrement. We
993
+ // must NOT settle `activeTurn` on an owed idle: `idle`
994
+ // carries no turn identity, and it can lag (the SDK flushes
995
+ // held-back results / drains background agents first), so by
996
+ // the time it arrives the SDK may have echoed the NEXT turn
997
+ // and activated it — settling now would resolve that new
998
+ // turn prematurely with end_turn and ~zero usage, dropping
999
+ // its real result. A cancelled turn relies on `idle`: its
1000
+ // `result` is dropped at the `session.cancelled` guard, so
1001
+ // it never settles at a result and must settle here.
1002
+ //
1003
+ // An idle that is NOT owed while the active turn is still
1004
+ // unsettled is the issue #825 signature: `idle` is the SDK's
1005
+ // authoritative turn-over signal (it fires after held-back
1006
+ // results flush and background agents drain), so a turn that
1007
+ // reaches it without a result will never get one — the model
1008
+ // stream dropped mid-turn, or an async agent
1009
+ // completed/stalled without the host turn resolving. Fail
1010
+ // the turn NOW so its session/prompt gets a terminal
1011
+ // response, instead of leaving it hanging until the next
1012
+ // prompt drains the wreckage.
1013
+ if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
1014
+ settleActive({ stopReason: "cancelled" });
1015
+ }
1016
+ else if (owedTrailingIdles > 0) {
1017
+ // Absorb a settled turn's trailing idle. Also covers a
1018
+ // cancel that landed between a turn's counted result and
1019
+ // this lagged idle (no active turn to settle): the idle
1020
+ // still belongs to that settled turn, and skipping the
1021
+ // decrement would leak the debt permanently.
1022
+ owedTrailingIdles--;
1023
+ }
1024
+ else if (!session.cancelled &&
1025
+ session.activeTurn &&
1026
+ !session.activeTurn.settled) {
1027
+ // Deliberately only the ACTIVE turn: a queued turn that
1028
+ // was never echoed is NOT failed here, because an idle
1029
+ // can legitimately precede the SDK picking up freshly
1030
+ // pushed input (the idle was emitted before the SDK read
1031
+ // it) — failing the queue head on that race would reject
1032
+ // a prompt the SDK is about to run. A turn abandoned
1033
+ // before its echo therefore still hangs until cancel or
1034
+ // the next prompt; only a timer could tell those apart.
1035
+ this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
1036
+ `for the active turn; failing the in-flight prompt (issue #825)`);
1037
+ failActive(RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
1038
+ }
1039
+ // The SDK generates the session title in a background task and
1040
+ // persists it to the session file; `idle` is the turn-over
1041
+ // signal, so it's the point at which a new title may have
1042
+ // landed. Push it to the client if it changed.
1043
+ await this.maybeUpdateSessionTitle(params.sessionId, session);
1044
+ }
1045
+ break;
1046
+ }
1047
+ case "memory_recall": {
1048
+ const isSynthesis = message.mode === "synthesize";
1049
+ const locations = isSynthesis
1050
+ ? []
1051
+ : message.memories.map((m) => ({ path: m.path }));
1052
+ const content = isSynthesis
1053
+ ? message.memories
1054
+ .filter((m) => typeof m.content === "string")
1055
+ .map((m) => ({
1056
+ type: "content",
1057
+ content: { type: "text", text: m.content },
1058
+ }))
1059
+ : [];
1060
+ const count = message.memories.length;
1061
+ const title = isSynthesis
1062
+ ? "Recalled synthesized memory"
1063
+ : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
1064
+ await this.client.sessionUpdate({
1065
+ sessionId: message.session_id,
1066
+ update: {
1067
+ sessionUpdate: "tool_call",
1068
+ toolCallId: message.uuid,
1069
+ title,
1070
+ kind: "read",
1071
+ status: "completed",
1072
+ ...(locations.length > 0 && { locations }),
1073
+ ...(content.length > 0 && { content }),
1074
+ _meta: {
1075
+ claudeCode: {
1076
+ toolName: "memory_recall",
1077
+ toolResponse: { mode: message.mode },
1078
+ },
1079
+ },
1080
+ },
1081
+ });
1082
+ break;
1083
+ }
1084
+ case "commands_changed": {
1085
+ // Push the full slash-command list after a mid-session change
1086
+ // (e.g. skills discovered dynamically as the agent works in a
1087
+ // subdirectory). The client should REPLACE its cached command
1088
+ // list with this payload: supportedCommands() is captured once
1089
+ // at initialize and never reflects mid-session changes, so we
1090
+ // forward message.commands directly rather than re-querying.
1091
+ await this.client.sessionUpdate({
1092
+ sessionId: message.session_id,
1093
+ update: {
1094
+ sessionUpdate: "available_commands_update",
1095
+ availableCommands: getAvailableSlashCommands(message.commands),
1096
+ },
1097
+ });
1098
+ break;
1099
+ }
1100
+ case "mirror_error": {
1101
+ // The SDK failed to persist session history (SessionStore
1102
+ // append rejected/timed out after retry) — potential data loss
1103
+ // the user should know about rather than a silent gap on
1104
+ // resume. Log it and surface a warning in the conversation.
1105
+ this.logger.error(`Session ${message.session_id}: failed to persist history: ${message.error}`);
1106
+ break;
1107
+ }
1108
+ case "permission_denied": {
1109
+ // A tool call was auto-denied (by a rule, the classifier,
1110
+ // dontAsk mode, etc.) before running. The tool_use block was
1111
+ // already emitted as a `tool_call`, so mark it failed with the
1112
+ // rejection reason — otherwise the client shows a tool call
1113
+ // that silently never resolves.
1114
+ const reason = message.decision_reason ?? message.message;
1115
+ await this.client.sessionUpdate({
1116
+ sessionId: message.session_id,
1117
+ update: {
1118
+ sessionUpdate: "tool_call_update",
1119
+ toolCallId: message.tool_use_id,
1120
+ status: "failed",
1121
+ content: [
1122
+ {
1123
+ type: "content",
1124
+ content: { type: "text", text: `Permission denied: ${reason}` },
1125
+ },
1126
+ ],
1127
+ _meta: {
1128
+ claudeCode: {
1129
+ toolName: message.tool_name,
1130
+ toolResponse: {
1131
+ decisionReasonType: message.decision_reason_type,
1132
+ decisionReason: message.decision_reason,
1133
+ message: message.message,
1134
+ },
1135
+ },
1136
+ },
1137
+ },
1138
+ });
1139
+ break;
1140
+ }
1141
+ case "informational": {
1142
+ // Free-form notice from the SDK (e.g. why a UserPromptSubmit/Stop
1143
+ // hook blocked continuation). Surface the text so the user sees it
1144
+ // instead of a silent stop. ACP's agent_message_chunk has no
1145
+ // severity field, so fold the level into the text for the more
1146
+ // prominent levels ('info' is transcript-only noise — leave plain).
1147
+ const text = message.level === "info"
1148
+ ? message.content
1149
+ : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
1150
+ await this.client.sessionUpdate({
1151
+ sessionId: message.session_id,
1152
+ update: {
1153
+ sessionUpdate: "agent_message_chunk",
1154
+ content: { type: "text", text },
1155
+ },
1156
+ });
1157
+ break;
1158
+ }
1159
+ case "hook_started":
1160
+ case "hook_progress":
1161
+ case "hook_response":
1162
+ case "files_persisted":
1163
+ case "task_started":
1164
+ case "task_notification":
1165
+ case "task_progress":
1166
+ case "task_updated":
1167
+ case "background_tasks_changed":
1168
+ case "control_request_progress":
1169
+ // `background_tasks_changed` is a level signal of live background
1170
+ // tasks; this adapter drives lifecycle off the task edge bookends.
1171
+ // `control_request_progress` only reports on side_question control
1172
+ // requests, which this adapter never issues.
1173
+ break;
1174
+ case "worker_shutting_down":
1175
+ // A Remote Control worker announced a graceful teardown. This is a
1176
+ // live-tail signal for remote clients to explain why a session went
1177
+ // away; it's not meaningful for a local stdio ACP session.
1178
+ break;
1179
+ case "elicitation_complete": {
1180
+ // A url-mode MCP elicitation finished server-side. Let the client
1181
+ // dismiss any UI it opened for it. Only meaningful when the
1182
+ // client supports url elicitation; ignore failures otherwise.
1183
+ if (this.clientCapabilities?.elicitation?.url) {
1184
+ try {
1185
+ await this.client.unstable_completeElicitation({
1186
+ elicitationId: message.elicitation_id,
1187
+ });
1188
+ }
1189
+ catch (error) {
1190
+ this.logger.error(`Failed to complete elicitation: ${error}`);
1191
+ }
1192
+ }
1193
+ break;
1194
+ }
1195
+ case "plugin_install":
1196
+ case "notification":
1197
+ case "api_retry":
1198
+ case "thinking_tokens":
1199
+ // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
1200
+ break;
1201
+ case "model_refusal_fallback": {
1202
+ // The SDK retried a refused turn on the fallback model and made
1203
+ // the swap persistent for the session. Without a notice the
1204
+ // user just sees regenerated output; without the state sync the
1205
+ // client's model picker (and the model-dependent options
1206
+ // rebuilt from it) keeps advertising a model the session is no
1207
+ // longer running.
1208
+ //
1209
+ // Current CLIs only emit direction "retry" (persistent swap).
1210
+ // "revert"/"sticky" are retained in the SDK enum for older
1211
+ // CLIs, where "revert" marked a turn-only fallback — for that
1212
+ // direction the session stays on the original model, so skip
1213
+ // the persistent-swap claim and the state sync.
1214
+ const persistent = message.direction !== "revert";
1215
+ const category = message.api_refusal_category
1216
+ ? ` (${message.api_refusal_category})`
1217
+ : "";
1218
+ const explanation = message.api_refusal_explanation
1219
+ ? `\n\n${message.api_refusal_explanation}`
1220
+ : "";
1221
+ const outcome = persistent
1222
+ ? `The session will continue on ${message.fallback_model}.`
1223
+ : `The session stays on ${message.original_model}.`;
1224
+ await this.client.sessionUpdate({
1225
+ sessionId: message.session_id,
1226
+ update: {
1227
+ sessionUpdate: "agent_message_chunk",
1228
+ content: {
1229
+ type: "text",
1230
+ text: `**Model fallback:** ${message.original_model} declined this request${category}; retried with ${message.fallback_model}. ${outcome}${explanation}`,
1231
+ },
1232
+ },
1233
+ });
1234
+ if (persistent) {
1235
+ await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
1236
+ }
1237
+ break;
1238
+ }
1239
+ case "model_refusal_no_fallback":
1240
+ // The refusal ends the turn as an error; the terminal `result`
1241
+ // handler settles it with ACP's `refusal` stop reason and
1242
+ // streams `lastRefusalExplanation`. The assistant frame's
1243
+ // stop_details is the primary source for that explanation —
1244
+ // this structured banner is the backup source when the frame
1245
+ // carried none (older CLIs, gateways that drop stop_details).
1246
+ //
1247
+ // `refused_user_message_uuid` is explicitly null when the
1248
+ // refused turn was not human-authored (a background
1249
+ // task-notification followup or auto-continuation) — don't
1250
+ // let those pollute the user turn's explanation. `undefined`
1251
+ // (older CLIs that omit the field) can't be attributed either
1252
+ // way, so keep seeding — the same exposure the assistant-frame
1253
+ // capture already has.
1254
+ if (!lastRefusalExplanation && message.refused_user_message_uuid !== null) {
1255
+ lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
1256
+ }
1257
+ break;
1258
+ default:
1259
+ unreachable(message, this.logger);
1260
+ break;
1261
+ }
1262
+ break;
1263
+ case "result": {
1264
+ // Task-notification followups are autonomous work triggered by a
1265
+ // task-notification system message, not by the user's prompt.
1266
+ // They should not influence the user-turn lifecycle (stop reason,
1267
+ // slash-command output forwarding) but their cost is real.
1268
+ const isTaskNotification = message.origin?.kind === "task-notification";
1269
+ // Reconcile the Fast mode toggle with the SDK's reported state.
1270
+ // Gated to user-driven turns like every other side effect below; a
1271
+ // background followup's state lands on the next user turn's result.
1272
+ // Runs even when the turn errors or was cancelled.
1273
+ if (!isTaskNotification) {
1274
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1275
+ }
1276
+ // A user-turn result needs an active turn so its stop reason is
1277
+ // attributed and the turn settles at idle. Local-only commands carry
1278
+ // no user-message echo to promote them, so do it here from the head.
1279
+ // Promote BEFORE accumulating usage, since activation resets the
1280
+ // accumulator — promoting after would discard this result's tokens.
1281
+ if (!isTaskNotification) {
1282
+ ensureActiveTurn();
1283
+ }
1284
+ // Every user-turn result terminates a turn (settle, reject, or
1285
+ // orphan skip) and the SDK follows it with a trailing
1286
+ // `session_state_changed: idle` — record the debt so the idle
1287
+ // handler absorbs that idle rather than reading it as a turn the
1288
+ // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
1289
+ // turn's own result. It is dropped at the `session.cancelled`
1290
+ // guard, and either the idle itself settles the turn (consuming
1291
+ // the trailer) or the next echo's hand-off does (which records
1292
+ // the debt there instead) — counting here too would double it.
1293
+ // Results skipped while cancelled with NO active turn — orphaned
1294
+ // queued turns the SDK still ran, or a force-cancelled turn's
1295
+ // late result after the backstop settled it — get no such settle,
1296
+ // so their trailers must be counted here or they'd later be read
1297
+ // as the next healthy turn being abandoned and false-fail it.
1298
+ if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
1299
+ owedTrailingIdles++;
1300
+ }
1301
+ // Accumulate usage into the user turn's tally. Skip task-notification
1302
+ // followups: their cost is real but is reported separately via the
1303
+ // usage_update below, and `session.accumulatedUsage` is only reset on
1304
+ // turn activation — so folding a task-notification result that lands
1305
+ // after the next turn is active (but before it settles) would leak
1306
+ // those tokens into that turn's PromptResponse.usage.
1307
+ if (!isTaskNotification) {
1308
+ session.accumulatedUsage.inputTokens += message.usage.input_tokens;
1309
+ session.accumulatedUsage.outputTokens += message.usage.output_tokens;
1310
+ session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
1311
+ session.accumulatedUsage.cachedWriteTokens +=
1312
+ message.usage.cache_creation_input_tokens;
1313
+ }
1314
+ const matchingModelUsage = lastAssistantModel
1315
+ ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
1316
+ : null;
1317
+ // Only overwrite when we have an authoritative value — a miss
1318
+ // (e.g. a turn with no top-level assistant message) would
1319
+ // otherwise discard the window learned on a prior turn and
1320
+ // leave the next prompt's mid-stream updates reporting 200k.
1321
+ if (matchingModelUsage) {
1322
+ session.contextWindowSize = matchingModelUsage.contextWindow;
1323
+ }
1324
+ // Send usage_update notification
1325
+ if (lastAssistantTotalUsage !== null) {
1326
+ await this.client.sessionUpdate({
1327
+ sessionId: params.sessionId,
1328
+ update: {
1329
+ sessionUpdate: "usage_update",
1330
+ used: lastAssistantTotalUsage,
1331
+ size: session.contextWindowSize,
1332
+ cost: {
1333
+ amount: message.total_cost_usd,
1334
+ currency: "USD",
1335
+ },
1336
+ ...(message.origin && {
1337
+ _meta: { "_claude/origin": message.origin },
1338
+ }),
1339
+ },
1340
+ });
1341
+ }
1342
+ if (session.cancelled) {
1343
+ if (!isTaskNotification) {
1344
+ stopReason = "cancelled";
1345
+ }
1346
+ break;
1347
+ }
1348
+ // A refusal can arrive on any result subtype (and may even set
1349
+ // is_error), so handle it before the subtype switch — otherwise the
1350
+ // is_error throw below would surface it as an internal error. The
1351
+ // refused assistant message carries no visible content, so surface
1352
+ // the classifier's explanation (when available) and report ACP's
1353
+ // dedicated `refusal` stop reason.
1354
+ if (message.stop_reason === "refusal" && !isTaskNotification) {
1355
+ if (lastRefusalExplanation) {
1356
+ await this.client.sessionUpdate({
1357
+ sessionId: params.sessionId,
1358
+ update: {
1359
+ sessionUpdate: "agent_message_chunk",
1360
+ content: { type: "text", text: lastRefusalExplanation },
1361
+ },
1362
+ });
1363
+ }
1364
+ stopReason = "refusal";
1365
+ settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
1366
+ break;
1367
+ }
1368
+ switch (message.subtype) {
1369
+ case "success": {
1370
+ if (message.result.includes("Please run /login")) {
1371
+ failActive(RequestError.authRequired());
1372
+ break;
1373
+ }
1374
+ if (message.stop_reason === "max_tokens") {
1375
+ if (!isTaskNotification) {
1376
+ stopReason = "max_tokens";
1377
+ }
1378
+ break;
1379
+ }
1380
+ if (message.is_error) {
1381
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
1382
+ break;
1383
+ }
1384
+ // For local-only commands (no model invocation), the result
1385
+ // text is the command output — forward it to the client.
1386
+ // Task-notification followups never originate from a user
1387
+ // slash command, so skip the forwarding for them.
1388
+ if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
1389
+ for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
1390
+ await this.client.sessionUpdate(notification);
1391
+ }
1392
+ }
1393
+ break;
1394
+ }
1395
+ case "error_during_execution": {
1396
+ if (message.stop_reason === "max_tokens") {
1397
+ if (!isTaskNotification) {
1398
+ stopReason = "max_tokens";
1399
+ }
1400
+ break;
1401
+ }
1402
+ if (message.is_error) {
1403
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1404
+ break;
1405
+ }
1406
+ if (!isTaskNotification) {
1407
+ stopReason = "end_turn";
1408
+ }
1409
+ break;
1410
+ }
1411
+ case "error_max_budget_usd":
1412
+ case "error_max_turns":
1413
+ case "error_max_structured_output_retries":
1414
+ if (message.is_error) {
1415
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1416
+ break;
1417
+ }
1418
+ if (!isTaskNotification) {
1419
+ stopReason = "max_turn_requests";
1420
+ }
1421
+ break;
1422
+ default:
1423
+ unreachable(message, this.logger);
1424
+ break;
1425
+ }
1426
+ // Settle the user turn at its terminal result so the client unlocks
1427
+ // as soon as the answer is done, rather than waiting for the SDK's
1428
+ // trailing `idle` (which can lag while background work runs — issue
1429
+ // #773). The consumer keeps draining afterward (absorbing idle and
1430
+ // forwarding any background output). is_error/auth already settled
1431
+ // via failActive; cancellation is left to the idle/abort path.
1432
+ // settleActive is idempotent, so a duplicate idle is a no-op.
1433
+ if (!isTaskNotification && !session.cancelled) {
1434
+ settleActive({ stopReason, usage: sessionUsage(session) });
1435
+ }
1436
+ break;
1437
+ }
1438
+ case "stream_event": {
1439
+ // `message_start` carries the Anthropic API message id; capture it
1440
+ // so the streamed chunks that follow (whose delta events don't carry
1441
+ // it) can all be tagged with the same, replay-stable id.
1442
+ if (message.event.type === "message_start") {
1443
+ currentStreamMessageId = message.event.message.id || undefined;
1444
+ // A new top-level message starts: clear any streamed-content
1445
+ // residue from a prior message that never reached its
1446
+ // consolidated reset — a cancelled turn breaks out before the
1447
+ // reset, and the synthetic-auth/system/local-command paths
1448
+ // `break` early too. Block indices restart at 0 each message, so
1449
+ // leftover entries would otherwise collide with this message's
1450
+ // blocks and re-emit (or truncate) already-streamed text. Gated on
1451
+ // `parent_tool_use_id === null` so a subagent stream can't clear
1452
+ // the top-level record. Fires once, before any of this message's
1453
+ // blocks, so it doesn't disturb the mid-message turn-activation
1454
+ // path the way resetting on turn activation would.
1455
+ if (message.parent_tool_use_id === null) {
1456
+ streamedBlocks.length = 0;
1457
+ }
1458
+ }
1459
+ // Accumulate the text/thinking actually streamed live, so the
1460
+ // `assistant` case below can diff its assembled blocks against what
1461
+ // already reached the client as chunks and forward only the
1462
+ // remainder. Gated on `parent_tool_use_id === null` so a subagent
1463
+ // stream can't attribute its content to the top-level message.
1464
+ // Contiguous deltas of the same block (same index and type) extend
1465
+ // the current entry; anything else opens a new one.
1466
+ if (message.parent_tool_use_id === null &&
1467
+ message.event.type === "content_block_delta") {
1468
+ const delta = message.event.delta;
1469
+ const chunk = delta.type === "text_delta"
1470
+ ? { type: "text", text: delta.text }
1471
+ : delta.type === "thinking_delta"
1472
+ ? { type: "thinking", text: delta.thinking }
1473
+ : undefined;
1474
+ // Skip empty deltas (some gateways emit empty thinking chunks —
1475
+ // #793): appending "" is a no-op, but pushing a "" entry would
1476
+ // create a block the consolidated handler's `text.length > 0`
1477
+ // guard can never consume, stalling the diff cursor and
1478
+ // re-emitting the next block as a duplicate.
1479
+ if (chunk && chunk.text.length > 0) {
1480
+ const index = message.event.index;
1481
+ const last = streamedBlocks[streamedBlocks.length - 1];
1482
+ if (last && last.index === index && last.type === chunk.type) {
1483
+ last.text += chunk.text;
1484
+ }
1485
+ else {
1486
+ streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
1487
+ }
1488
+ }
1489
+ }
1490
+ if (message.parent_tool_use_id === null &&
1491
+ (message.event.type === "message_start" || message.event.type === "message_delta")) {
1492
+ if (message.event.type === "message_start") {
1493
+ lastAssistantUsage = snapshotFromUsage(message.event.message.usage);
1494
+ const model = message.event.message.model;
1495
+ if (model && model !== "<synthetic>") {
1496
+ lastAssistantModel = model;
1497
+ // Only upgrade from the default — once a `result` has given
1498
+ // us an authoritative window, trust it over the heuristic.
1499
+ // Model switches invalidate the cached window via
1500
+ // `syncSessionConfigState`, which resets us back to the
1501
+ // default so this branch runs again for the new model.
1502
+ if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
1503
+ const inferred = inferContextWindowFromModel(model);
1504
+ if (inferred !== null) {
1505
+ session.contextWindowSize = inferred;
1506
+ }
1507
+ }
1508
+ }
1509
+ }
1510
+ else {
1511
+ const usage = message.event.usage;
1512
+ const prev = lastAssistantUsage ?? ZERO_USAGE;
1513
+ // Per Anthropic API, message_delta usage fields are *cumulative*;
1514
+ // nullable fields (input_tokens and the cache fields) fall back
1515
+ // to the prior snapshot when the server omits them from this
1516
+ // delta. Only output_tokens is guaranteed non-null.
1517
+ lastAssistantUsage = {
1518
+ input_tokens: usage.input_tokens ?? prev.input_tokens,
1519
+ output_tokens: usage.output_tokens,
1520
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? prev.cache_read_input_tokens,
1521
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? prev.cache_creation_input_tokens,
1522
+ };
1523
+ }
1524
+ const nextUsage = totalTokens(lastAssistantUsage);
1525
+ if (nextUsage !== lastAssistantTotalUsage) {
1526
+ lastAssistantTotalUsage = nextUsage;
1527
+ await this.client.sessionUpdate({
1528
+ sessionId: params.sessionId,
1529
+ update: {
1530
+ sessionUpdate: "usage_update",
1531
+ used: nextUsage,
1532
+ size: session.contextWindowSize,
1533
+ },
1534
+ });
1535
+ }
1536
+ }
1537
+ for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, this.client, this.logger, {
1538
+ clientCapabilities: this.clientCapabilities,
1539
+ cwd: session.cwd,
1540
+ taskState: session.taskState,
1541
+ emittedToolCalls: session.emittedToolCalls,
1542
+ messageId: currentStreamMessageId,
1543
+ })) {
1544
+ await this.client.sessionUpdate(notification);
1545
+ }
1546
+ break;
1547
+ }
1548
+ case "user":
1549
+ case "assistant": {
1550
+ // Record the ACP messageId -> SDK uuid mapping for this message
1551
+ // (including replays). The consolidated message carries both ids, so
1552
+ // this is where we learn the uuid the SDK's rewind/resume APIs key on
1553
+ // for the id we hand clients. Not read yet (see messageIdToUuid).
1554
+ const mappedMessageId = messageIdForGrouping(message);
1555
+ if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
1556
+ session.messageIdToUuid.set(mappedMessageId, message.uuid);
1557
+ }
1558
+ // A replayed user message echoes a queued turn back in submission
1559
+ // order. The first echo promotes that turn to active; if a different
1560
+ // turn is still active, it is handed off (settled end_turn) first.
1561
+ // Done before the `cancelled` guard so a turn enqueued after a cancel
1562
+ // is still promoted — activateTurn() clears the flag. The turn's own
1563
+ // echo is then dropped from the feed (the client already shows it).
1564
+ if (message.type === "user" && "uuid" in message && message.uuid) {
1565
+ const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
1566
+ if (queued) {
1567
+ // Only (re)activate if this isn't already the active turn — a
1568
+ // turn promoted early (e.g. by a result that preceded its echo)
1569
+ // must not have its accumulated usage reset by its own echo.
1570
+ if (session.activeTurn !== queued) {
1571
+ if (session.activeTurn) {
1572
+ // Hand off the previous turn. If a cancel is pending for it
1573
+ // (its trailing idle hasn't arrived yet), settle it
1574
+ // "cancelled" per the ACP contract rather than "end_turn" —
1575
+ // otherwise a cancel followed quickly by the next prompt
1576
+ // would report the cancelled turn as a normal completion.
1577
+ if (session.cancelled) {
1578
+ // The cancelled turn settles here, but the trailing idle
1579
+ // its interrupt produces is still in flight — record the
1580
+ // debt so that lagged idle is absorbed rather than read
1581
+ // as the freshly-activated turn ending without a result
1582
+ // (which would false-fail a healthy turn — issue #825).
1583
+ owedTrailingIdles++;
1584
+ settleActive({ stopReason: "cancelled" });
1585
+ }
1586
+ else {
1587
+ settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
1588
+ }
1589
+ }
1590
+ activateTurn(queued);
1591
+ }
1592
+ break;
1593
+ }
1594
+ if ("isReplay" in message && message.isReplay) {
1595
+ // Unrelated replay (e.g. the echo of an already-settled turn).
1596
+ break;
1597
+ }
1598
+ }
1599
+ if (session.cancelled) {
1600
+ break;
1601
+ }
1602
+ // Snapshot the latest top-level assistant usage and model so the
1603
+ // next `result` can emit a usage_update tied to the right context
1604
+ // window. Subagent messages are excluded to keep the snapshot
1605
+ // aligned with what the user's current selection is producing.
1606
+ if (message.type === "assistant" && message.parent_tool_use_id === null) {
1607
+ lastAssistantUsage = snapshotFromUsage(message.message.usage);
1608
+ lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
1609
+ if (message.message.model && message.message.model !== "<synthetic>") {
1610
+ lastAssistantModel = message.message.model;
1611
+ }
1612
+ if (message.error) {
1613
+ lastAssistantError = message.error;
1614
+ }
1615
+ if (message.message.stop_reason === "refusal") {
1616
+ // Keep any explanation already seeded by a
1617
+ // `model_refusal_no_fallback` banner — the banner/frame
1618
+ // ordering is CLI-dependent, and a frame whose stop_details
1619
+ // was dropped (the case the banner backup exists for) must
1620
+ // not clobber the seed back to null.
1621
+ lastRefusalExplanation =
1622
+ message.message.stop_details?.explanation ?? lastRefusalExplanation;
1623
+ }
1624
+ }
1625
+ // Strip <command-*>/<local-command-stdout> markers and render any
1626
+ // remaining prose. Skill bodies and built-in slash commands (e.g.
1627
+ // /usage, /status, /model) arrive wrapped in these tags; pure-marker
1628
+ // payloads (e.g. /compact's malformed output) strip to null and are
1629
+ // skipped. Mirrors the replay path at replaySessionHistory.
1630
+ if (message.message.role !== "system" &&
1631
+ typeof message.message.content === "string" &&
1632
+ message.message.content.includes("<local-command-stdout>")) {
1633
+ const stripped = stripLocalCommandMetadata(message.message.content);
1634
+ if (typeof stripped === "string") {
1635
+ for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
1636
+ clientCapabilities: this.clientCapabilities,
1637
+ parentToolUseId: message.parent_tool_use_id,
1638
+ cwd: session.cwd,
1639
+ taskState: session.taskState,
1640
+ messageId: messageIdForGrouping(message),
1641
+ })) {
1642
+ await this.client.sessionUpdate(notification);
1643
+ }
1644
+ }
1645
+ else {
1646
+ this.logger.log(message.message.content);
1647
+ }
1648
+ break;
1649
+ }
1650
+ if (typeof message.message.content === "string" &&
1651
+ message.message.content.includes("<local-command-stderr>")) {
1652
+ this.logger.error(message.message.content);
1653
+ break;
1654
+ }
1655
+ // Skip these user messages for now, since they seem to just be messages we don't want in the feed
1656
+ if (message.type === "user" &&
1657
+ (typeof message.message.content === "string" ||
1658
+ (Array.isArray(message.message.content) &&
1659
+ message.message.content.length === 1 &&
1660
+ message.message.content[0].type === "text"))) {
1661
+ break;
1662
+ }
1663
+ if (message.message.role === "system") {
1664
+ break;
1665
+ }
1666
+ if (message.type === "assistant" &&
1667
+ message.message.model === "<synthetic>" &&
1668
+ Array.isArray(message.message.content) &&
1669
+ message.message.content.length === 1 &&
1670
+ message.message.content[0].type === "text" &&
1671
+ message.message.content[0].text.includes("Please run /login")) {
1672
+ failActive(RequestError.authRequired());
1673
+ break;
1674
+ }
1675
+ let content;
1676
+ if (message.type === "assistant" && message.parent_tool_use_id === null) {
1677
+ // Top-level assistant message: each text/thinking block may have
1678
+ // already been streamed live as deltas. Diff each against what
1679
+ // streamed (`streamedBlocks`, in document order) and forward only
1680
+ // the un-streamed remainder — nothing if it streamed in full (the
1681
+ // common case), the whole block if it never streamed (a
1682
+ // non-streaming gateway), or just the tail if the stream was cut
1683
+ // short mid-block. `streamPos` walks the streamed blocks in step
1684
+ // with the assembled text/thinking blocks; tool_use and other
1685
+ // blocks pass through untouched (their own `toolUseCache` collapses
1686
+ // the streamed/assembled pair) without advancing it.
1687
+ const blocks = message.message.content;
1688
+ const kept = [];
1689
+ let streamPos = 0;
1690
+ for (const item of blocks) {
1691
+ if (item.type !== "text" && item.type !== "thinking") {
1692
+ kept.push(item);
1693
+ continue;
1694
+ }
1695
+ const full = item.type === "text" ? item.text : item.thinking;
1696
+ // Empty assembled blocks carry nothing (some gateways emit an
1697
+ // empty `thinking` block before the real text) — drop them.
1698
+ if (full.length === 0) {
1699
+ continue;
1700
+ }
1701
+ // A streamed block of the same type whose accumulated text is a
1702
+ // prefix of this one was already (at least partly) delivered as
1703
+ // chunks; consume it and forward only what's left. A non-empty
1704
+ // streamed text is required so an empty/aborted streamed block
1705
+ // doesn't swallow the assembled copy.
1706
+ const streamed = streamedBlocks[streamPos];
1707
+ if (streamed &&
1708
+ streamed.type === item.type &&
1709
+ streamed.text.length > 0 &&
1710
+ full.startsWith(streamed.text)) {
1711
+ streamPos++;
1712
+ const remainder = full.slice(streamed.text.length);
1713
+ if (remainder.length === 0) {
1714
+ continue;
1715
+ }
1716
+ // Overwrite in place with just the un-streamed tail (the
1717
+ // assembled message isn't read again after this) so the block
1718
+ // keeps its exact SDK type.
1719
+ if (item.type === "text") {
1720
+ item.text = remainder;
1721
+ }
1722
+ else {
1723
+ item.thinking = remainder;
1724
+ }
1725
+ kept.push(item);
1726
+ continue;
1727
+ }
1728
+ // Not matched: never streamed (or the stream diverged from the
1729
+ // assembled text) — forward the block in full.
1730
+ kept.push(item);
1731
+ }
1732
+ content = kept;
1733
+ // Consumed: reset so the next message's blocks accumulate fresh and
1734
+ // the record stays bounded to the in-flight message.
1735
+ streamedBlocks.length = 0;
1736
+ }
1737
+ else if (message.type === "assistant") {
1738
+ // Subagent assistant message (`parent_tool_use_id !== null`). It is
1739
+ // never streamed live and its text/thinking is internal to the tool
1740
+ // call — keep dropping it so subagent prose doesn't leak into the
1741
+ // top-level feed.
1742
+ content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
1743
+ }
1744
+ else {
1745
+ content = message.message.content;
1746
+ }
1747
+ for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
1748
+ clientCapabilities: this.clientCapabilities,
1749
+ parentToolUseId: message.parent_tool_use_id,
1750
+ cwd: session.cwd,
1751
+ taskState: session.taskState,
1752
+ emittedToolCalls: session.emittedToolCalls,
1753
+ messageId: messageIdForGrouping(message),
1754
+ })) {
1755
+ await this.client.sessionUpdate(notification);
1756
+ }
1757
+ break;
1758
+ }
1759
+ case "tool_progress": {
1760
+ await this.client.sessionUpdate({
1761
+ sessionId: message.session_id,
1762
+ update: {
1763
+ sessionUpdate: "tool_call_update",
1764
+ toolCallId: message.tool_use_id,
1765
+ status: "in_progress",
1766
+ _meta: {
1767
+ claudeCode: {
1768
+ toolName: message.tool_name,
1769
+ toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
1770
+ },
1771
+ },
1772
+ },
1773
+ });
1774
+ break;
1775
+ }
1776
+ case "rate_limit_event": {
1777
+ if (lastAssistantTotalUsage !== null) {
1778
+ await this.client.sessionUpdate({
1779
+ sessionId: message.session_id,
1780
+ update: {
1781
+ sessionUpdate: "usage_update",
1782
+ used: lastAssistantTotalUsage,
1783
+ size: session.contextWindowSize,
1784
+ _meta: { "_claude/rateLimit": message.rate_limit_info },
1785
+ },
1786
+ });
1787
+ }
1788
+ break;
1789
+ }
1790
+ // `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
1791
+ // flows) is safe to drop: turn lifecycle here is driven by
1792
+ // results/idle, and the client owns its own transcript view.
1793
+ case "tool_use_summary":
1794
+ case "auth_status":
1795
+ case "prompt_suggestion":
1796
+ case "conversation_reset":
1797
+ break;
1798
+ default:
1799
+ unreachable(message);
1800
+ break;
1801
+ }
1802
+ }
1803
+ // `while (true)` only exits via the `done` return above or the catch
1804
+ // below, so there is no normal fall-through here.
1805
+ }
1806
+ catch (error) {
1807
+ // The query stream itself died (a transport/process error surfaced from
1808
+ // query.next()). Turn-level failures (auth, error results) are handled
1809
+ // inline via failActive and never reach here. Reject every in-flight turn;
1810
+ // if the process is gone, tear the session down so the client starts fresh.
1811
+ const message = error instanceof Error ? error.message : String(error);
1812
+ const processDied = error instanceof Error &&
1813
+ (message.includes("ProcessTransport") ||
1814
+ message.includes("terminated process") ||
1815
+ message.includes("process exited with") ||
1816
+ message.includes("process terminated by signal") ||
1817
+ message.includes("Failed to write to process stdin"));
1818
+ // Either way the query iterator is finished and the consumer is exiting,
1819
+ // so release its resources via closeQueryStream (idempotent). A process
1820
+ // death is unrecoverable, so additionally evict the session so the client
1821
+ // starts fresh; other stream errors keep the session so prompt()/cancel()
1822
+ // can answer with a clear "session ended" error.
1823
+ if (processDied) {
1824
+ this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
1825
+ failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
1826
+ this.closeQueryStream(session);
1827
+ delete this.sessions[params.sessionId];
1828
+ }
1829
+ else {
1830
+ this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
1831
+ failAllTurns(error);
1832
+ this.closeQueryStream(session);
1833
+ }
1834
+ }
1835
+ }
1836
+ async cancel(params) {
1837
+ const session = this.sessions[params.sessionId];
1838
+ if (!session) {
1839
+ return;
1840
+ }
1841
+ // The stream already ended (see closeQueryStream): every in-flight turn was
1842
+ // settled when it closed, and there is no live query to interrupt. Calling
1843
+ // query.interrupt() on a finished iterator could reject and surface from
1844
+ // this fire-and-forget notification, so there is nothing to do here.
1845
+ if (session.queryClosed) {
1846
+ return;
1847
+ }
1848
+ session.cancelled = true;
1849
+ // Settle queued turns that haven't started yet (no echo seen) right away —
1850
+ // they have no in-flight SDK work to interrupt. The active turn is settled
1851
+ // by the consumer when it observes the interrupt's trailing idle (or via the
1852
+ // backstop below). Mirrors the old pendingMessages cancellation.
1853
+ if (session.turnQueue) {
1854
+ let orphaned = 0;
1855
+ for (const turn of session.turnQueue) {
1856
+ if (turn !== session.activeTurn && !turn.settled) {
1857
+ turn.settled = true;
1858
+ turn.resolve({ stopReason: "cancelled" });
1859
+ orphaned++;
1860
+ }
1861
+ }
1862
+ // Each removed queued turn's user message was already pushed to the SDK,
1863
+ // which processes input FIFO and will still emit a result for it with no
1864
+ // uuid to match. Count those so the consumer skips them (see
1865
+ // ensureActiveTurn) rather than misattributing them to the head.
1866
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
1867
+ session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
1868
+ }
1869
+ // Arm a backstop before interrupting: if a turn is actively consuming the
1870
+ // query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
1871
+ // block — issue #680), force the consumer to settle the active turn
1872
+ // "cancelled" after the floor elapses so the pending session/prompt still
1873
+ // resolves per the ACP cancellation contract instead of hanging forever. The
1874
+ // consumer clears this timer when interrupt() works and it settles through
1875
+ // the normal idle path, so on healthy cancels it is armed but never fires.
1876
+ //
1877
+ // Arm at most once per turn: the floor is an absolute ceiling from the first
1878
+ // cancel, so a client that re-sends cancel (each call still retries
1879
+ // interrupt() below) can't keep pushing the deadline out.
1880
+ if (session.activeTurn &&
1881
+ session.cancelController &&
1882
+ !session.cancelController.signal.aborted &&
1883
+ !session.forceCancelTimer) {
1884
+ const cancelController = session.cancelController;
1885
+ session.forceCancelTimer = setTimeout(() => {
1886
+ this.logger.error(`Session ${params.sessionId}: cancel floor elapsed without the SDK yielding; forcing "cancelled". The underlying query may still be wedged — a new session may be required.`);
1887
+ cancelController.abort();
1888
+ }, this.forceCancelGraceMs);
1889
+ }
1890
+ await session.query.interrupt();
1891
+ }
1892
+ /** Mark a session's SDK query stream as permanently ended and release the
1893
+ * resources tied to it: drop the consumer handle, dispose the settings
1894
+ * watchers, end the input stream, and close the query (which terminates the
1895
+ * subprocess). The query iterator is not revivable, so `prompt()`/`cancel()`
1896
+ * consult `queryClosed` and fail/short-circuit instead of acting on a dead
1897
+ * stream. Idempotent (guarded by `queryClosed`), so the consumer's done/error
1898
+ * paths and a later `teardownSession` can all call it without double-releasing.
1899
+ *
1900
+ * Deliberately does NOT abort `session.abortController`: that controller may be
1901
+ * CLIENT-supplied (`_meta.claudeCode.options.abortController`) and reused, so
1902
+ * aborting it on a spontaneous stream end would cancel the client's own work
1903
+ * or make a sibling session born aborted. `query.close()` already terminates
1904
+ * the subprocess; aborting the signal belongs in `teardownSession` (explicit
1905
+ * destroy), not here. Also does NOT remove the session from the map — that is
1906
+ * `teardownSession`'s job — so prompt() can still answer with a clear "session
1907
+ * ended" error after an unexpected stream close. The leftover session object
1908
+ * is a lightweight husk (its heavy resources are released here) and is evicted
1909
+ * on the next closeSession/deleteSession or when the connection's `dispose()`
1910
+ * runs. */
1911
+ closeQueryStream(session) {
1912
+ if (session.queryClosed) {
1913
+ return;
1914
+ }
1915
+ session.queryClosed = true;
1916
+ session.consumer = undefined;
1917
+ session.settingsManager.dispose();
1918
+ session.input.end();
1919
+ session.query.close();
1920
+ }
1921
+ /** Cleanly tear down a session: cancel in-flight work, release stream
1922
+ * resources, and remove it from the session map. */
1923
+ async teardownSession(sessionId) {
1924
+ const session = this.sessions[sessionId];
1925
+ if (!session) {
1926
+ return;
1927
+ }
1928
+ await this.cancel({ sessionId });
1929
+ // cancel() arms the force-cancel floor and interrupts gracefully, but a
1930
+ // wedged consumer only wakes when `cancelController` aborts — closeQueryStream
1931
+ // below doesn't touch it. Since we're tearing the session down anyway, wake
1932
+ // the consumer now so the in-flight prompt() resolves immediately instead of
1933
+ // after the floor, and clear the timer so it can't outlive the deleted
1934
+ // session (it isn't unref'd and would otherwise keep the event loop alive
1935
+ // until it fires).
1936
+ if (session.forceCancelTimer) {
1937
+ clearTimeout(session.forceCancelTimer);
1938
+ session.forceCancelTimer = undefined;
1939
+ }
1940
+ session.cancelController?.abort();
1941
+ this.closeQueryStream(session);
1942
+ // Abort the SDK abort signal only on explicit destroy. closeQueryStream
1943
+ // leaves it alone (it may be a client-owned controller — see its doc), but
1944
+ // here the client has asked us to close the session, so signalling abort is
1945
+ // appropriate; query.close() above has already torn the subprocess down.
1946
+ session.abortController.abort();
1947
+ delete this.sessions[sessionId];
1948
+ }
1949
+ /** Tear down all active sessions. Called when the ACP connection closes. */
1950
+ async dispose() {
1951
+ await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
1952
+ }
1953
+ async closeSession(params) {
1954
+ if (!this.sessions[params.sessionId]) {
1955
+ throw new Error("Session not found");
1956
+ }
1957
+ await this.teardownSession(params.sessionId);
1958
+ return {};
1959
+ }
1960
+ async deleteSession(params) {
1961
+ // Tear down any active in-memory state first so the on-disk file isn't
1962
+ // recreated by an outstanding query writing to it.
1963
+ if (this.sessions[params.sessionId]) {
1964
+ await this.teardownSession(params.sessionId);
1965
+ }
1966
+ await deleteSession(params.sessionId);
1967
+ return {};
1968
+ }
1969
+ async setSessionMode(params) {
1970
+ const session = this.sessions[params.sessionId];
1971
+ if (!session) {
1972
+ throw new Error("Session not found");
1973
+ }
1974
+ // The SDK query stream already ended (see closeQueryStream); the session is
1975
+ // a husk and `query.setPermissionMode` below would act on a closed query.
1976
+ // Fail with the same clear message prompt()/cancel() give for a dead stream.
1977
+ if (session.queryClosed) {
1978
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1979
+ }
1980
+ await this.applySessionMode(params.sessionId, params.modeId);
1981
+ await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId);
1982
+ return {};
1983
+ }
1984
+ async setSessionConfigOption(params) {
1985
+ const session = this.sessions[params.sessionId];
1986
+ if (!session) {
1987
+ throw new Error("Session not found");
1988
+ }
1989
+ // The SDK query stream already ended (see closeQueryStream); the session is
1990
+ // a husk and the `query.setModel`/`setPermissionMode`/`applyFlagSettings`
1991
+ // calls this triggers would act on a closed query. Fail with the same clear
1992
+ // message prompt()/cancel() give for a dead stream.
1993
+ if (session.queryClosed) {
1994
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1995
+ }
1996
+ const option = session.configOptions.find((o) => o.id === params.configId);
1997
+ if (!option) {
1998
+ throw new Error(`Unknown config option: ${params.configId}`);
1999
+ }
2000
+ // Fast mode carries a boolean value (for Clients that opted into boolean
2001
+ // config options) or the "on"/"off" select fallback, so it bypasses the
2002
+ // string-only validation the select-style options below rely on.
2003
+ if (params.configId === FAST_MODE_CONFIG_ID) {
2004
+ await this.applyFastMode(session, resolveFastModeEnabled(params));
2005
+ return { configOptions: session.configOptions };
2006
+ }
2007
+ if (typeof params.value !== "string") {
2008
+ throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
2009
+ }
2010
+ const allValues = "options" in option && Array.isArray(option.options)
2011
+ ? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
2012
+ : [];
2013
+ let validValue = allValues.find((o) => o.value === params.value);
2014
+ // For model options, fall back to resolveModelPreference when the exact
2015
+ // value doesn't match. This lets callers use human-friendly aliases like
2016
+ // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
2017
+ if (!validValue && params.configId === MODEL_CONFIG_ID) {
2018
+ const modelInfos = allValues.map((o) => ({
2019
+ value: o.value,
2020
+ displayName: o.name,
2021
+ description: o.description ?? "",
2022
+ }));
2023
+ const resolved = resolveModelPreference(modelInfos, params.value);
2024
+ if (resolved) {
2025
+ validValue = allValues.find((o) => o.value === resolved.value);
2026
+ }
2027
+ }
2028
+ if (!validValue) {
2029
+ throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
2030
+ }
2031
+ // Use the canonical option value so downstream code always receives the
2032
+ // model ID rather than the caller-supplied alias.
2033
+ const resolvedValue = validValue.value;
2034
+ if (params.configId === MODE_CONFIG_ID) {
2035
+ await this.applySessionMode(params.sessionId, resolvedValue);
2036
+ await this.client.sessionUpdate({
2037
+ sessionId: params.sessionId,
2038
+ update: {
2039
+ sessionUpdate: "current_mode_update",
2040
+ currentModeId: resolvedValue,
2041
+ },
2042
+ });
2043
+ }
2044
+ else if (params.configId === MODEL_CONFIG_ID) {
2045
+ await this.sessions[params.sessionId].query.setModel(resolvedValue);
2046
+ }
2047
+ // Effort SDK sync is handled inside applyConfigOptionValue so that direct
2048
+ // effort changes and effort changes induced by a model switch go through
2049
+ // the same path.
2050
+ await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
2051
+ return { configOptions: session.configOptions };
2052
+ }
2053
+ async applySessionMode(sessionId, modeId) {
2054
+ switch (modeId) {
2055
+ case "auto":
2056
+ case "default":
2057
+ case "acceptEdits":
2058
+ case "bypassPermissions":
2059
+ case "dontAsk":
2060
+ case "plan":
2061
+ break;
2062
+ default:
2063
+ throw new Error("Invalid Mode");
2064
+ }
2065
+ const session = this.sessions[sessionId];
2066
+ if (!session) {
2067
+ throw new Error("Session not found");
2068
+ }
2069
+ if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
2070
+ throw new Error(`Mode ${modeId} is not available in this session`);
2071
+ }
2072
+ try {
2073
+ await session.query.setPermissionMode(modeId);
2074
+ }
2075
+ catch (error) {
2076
+ if (error instanceof Error) {
2077
+ if (!error.message) {
2078
+ error.message = "Invalid Mode";
2079
+ }
2080
+ throw error;
2081
+ }
2082
+ else {
2083
+ // eslint-disable-next-line preserve-caught-error
2084
+ throw new Error("Invalid Mode");
2085
+ }
2086
+ }
2087
+ }
2088
+ async replaySessionHistory(sessionId) {
2089
+ const toolUseCache = {};
2090
+ const messages = await getSessionMessages(sessionId);
2091
+ for (const message of messages) {
2092
+ // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
2093
+ // observe live (resumed/loaded sessions), so rewind/resume can translate
2094
+ // a client-supplied id without an extra getSessionMessages read. Not read
2095
+ // yet (see Session.messageIdToUuid).
2096
+ const replayMessageId = messageIdForGrouping(message);
2097
+ const replaySession = this.sessions[sessionId];
2098
+ if (replaySession && replayMessageId && message.uuid) {
2099
+ replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
2100
+ }
2101
+ // @ts-expect-error - untyped in SDK but we handle all of these
2102
+ let content = message.message.content;
2103
+ // @ts-expect-error - untyped in SDK but we handle all of these
2104
+ if (message.message.role === "user") {
2105
+ content = stripLocalCommandMetadata(content);
2106
+ if (content === null)
2107
+ continue;
2108
+ }
2109
+ for (const notification of toAcpNotifications(
2110
+ // @ts-expect-error - untyped in SDK but we handle all of these
2111
+ content,
2112
+ // @ts-expect-error - untyped in SDK but we handle all of these
2113
+ message.message.role, sessionId, toolUseCache, this.client, this.logger, {
2114
+ registerHooks: false,
2115
+ clientCapabilities: this.clientCapabilities,
2116
+ cwd: this.sessions[sessionId]?.cwd,
2117
+ taskState: this.sessions[sessionId]?.taskState,
2118
+ messageId: replayMessageId,
2119
+ })) {
2120
+ await this.client.sessionUpdate(notification);
2121
+ }
2122
+ }
2123
+ }
2124
+ async readTextFile(params) {
2125
+ const response = await this.client.readTextFile(params);
2126
+ return response;
2127
+ }
2128
+ async writeTextFile(params) {
2129
+ const response = await this.client.writeTextFile(params);
2130
+ return response;
2131
+ }
2132
+ /** Forward a permission request to the client, wiring the tool call's
2133
+ * `signal` through as a `cancellationSignal`. When the turn is cancelled
2134
+ * while the client's prompt is still open the signal aborts, the SDK sends
2135
+ * `$/cancel_request`, and the client settles the request (a `cancelled`
2136
+ * outcome or a `requestCancelled` rejection). Either way we surface the same
2137
+ * "Tool use aborted" the callers already expect, so a cancelled dialog no
2138
+ * longer leaves the `await` hanging. */
2139
+ async requestPermissionFromClient(params, toolName, signal) {
2140
+ // The SDK may invoke `canUseTool` (and therefore this permission request)
2141
+ // before the assistant message's tool_use block streams to us. Some ACP clients
2142
+ // expect the `tool_call` a permission request references to already exist,
2143
+ // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
2144
+ // later refines it with a `tool_call_update` rather than emitting a
2145
+ // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
2146
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
2147
+ try {
2148
+ return await this.client.requestPermission(params, signal);
2149
+ }
2150
+ catch (error) {
2151
+ if (signal.aborted) {
2152
+ throw new Error("Tool use aborted", { cause: error });
2153
+ }
2154
+ throw error;
2155
+ }
2156
+ }
2157
+ /** Emit the `tool_call` a permission request references if it hasn't been sent
2158
+ * yet, so the client has the tool call before being asked to approve it. The
2159
+ * matching streamed tool_use chunk later refines it with a `tool_call_update`
2160
+ * instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
2161
+ * `toolCallNotification` helper as the streamed path so the two are identical.
2162
+ * Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
2163
+ * skipped so a permission prompt for them never surfaces a stray tool_call. */
2164
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput) {
2165
+ const session = this.sessions[sessionId];
2166
+ if (!session || !shouldEmitToolCall(toolName)) {
2167
+ return;
2168
+ }
2169
+ if (session.emittedToolCalls.has(toolCallId)) {
2170
+ return;
2171
+ }
2172
+ session.emittedToolCalls.add(toolCallId);
2173
+ const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2174
+ await this.client.sessionUpdate({
2175
+ sessionId,
2176
+ update: toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd),
2177
+ });
2178
+ }
2179
+ canUseTool(sessionId) {
2180
+ return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
2181
+ const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
2182
+ const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2183
+ const session = this.sessions[sessionId];
2184
+ if (!session) {
2185
+ return {
2186
+ behavior: "deny",
2187
+ message: "Session not found",
2188
+ };
2189
+ }
2190
+ // AskUserQuestion is surfaced to us as a normal permission check (the SDK
2191
+ // routes it through canUseTool whenever a callback is registered, rather
2192
+ // than the interactive dialog). Present it as an ACP form elicitation and
2193
+ // feed the answers back as updatedInput for the tool's own call() to read.
2194
+ if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
2195
+ // Like permission requests, the elicitation references this toolUseID, so
2196
+ // make sure the tool_call has surfaced to the client before we send it.
2197
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
2198
+ return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
2199
+ }
2200
+ // Fallback for clients WITHOUT `elicitation.form`: route each question
2201
+ // through ACP `session/request_permission` dialogs (gated by
2202
+ // ACP_ASKUSERQUESTION_FALLBACK). Placed before ExitPlanMode and the
2203
+ // bypassPermissions early-allow so a question is always asked, even in
2204
+ // bypass mode.
2205
+ if (toolName === "AskUserQuestion" && askUserQuestionFallbackEnabled(process.env)) {
2206
+ // Emit the tool_call before the first permission request (R3.1).
2207
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
2208
+ // Logger has no `debug`; `.error` reaches stderr in all contexts (the CLI
2209
+ // entrypoint also remaps console.log/debug -> stderr), so it never
2210
+ // corrupts the stdout ndJSON protocol. Used by story 002 validation.
2211
+ this.logger.error("AskUserQuestion: routing via permission fallback (client lacks elicitation.form).");
2212
+ return handleAskUserQuestionViaPermission(toolInput, async ({ question, options }) => {
2213
+ const response = await this.requestPermissionFromClient({
2214
+ options,
2215
+ sessionId,
2216
+ toolCall: {
2217
+ toolCallId: toolUseID,
2218
+ rawInput: toolInput,
2219
+ ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
2220
+ // Per-question title so the user sees which question they are
2221
+ // answering; placed AFTER the spread so it wins over the
2222
+ // tool-derived title.
2223
+ title: question.question,
2224
+ },
2225
+ }, toolName, signal);
2226
+ // RequestPermissionResponse has a DOUBLE-nested outcome (see the
2227
+ // ExitPlanMode usage below): response.outcome?.outcome is
2228
+ // "selected" | "cancelled".
2229
+ if (response.outcome?.outcome === "selected") {
2230
+ return { outcome: "selected", optionId: response.outcome.optionId };
2231
+ }
2232
+ return { outcome: "cancelled" };
2233
+ }, signal);
2234
+ }
2235
+ if (toolName === "ExitPlanMode") {
2236
+ const optionsAll = [
2237
+ { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
2238
+ {
2239
+ kind: "allow_always",
2240
+ name: "Yes, and auto-accept edits",
2241
+ optionId: "acceptEdits",
2242
+ },
2243
+ { kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
2244
+ { kind: "reject_once", name: "No, keep planning", optionId: "plan" },
2245
+ ];
2246
+ if (ALLOW_BYPASS) {
2247
+ optionsAll.unshift({
2248
+ kind: "allow_always",
2249
+ name: "Yes, and bypass permissions",
2250
+ optionId: "bypassPermissions",
2251
+ });
2252
+ }
2253
+ // Filter against the session's currently-advertised modes so we never
2254
+ // present options the active model can't honor (e.g. `auto` on Haiku).
2255
+ // `bypassPermissions` is already covered by `availableModes` via
2256
+ // `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
2257
+ // "keep planning" reject path; it's always present in `availableModes`.
2258
+ const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
2259
+ const response = await this.requestPermissionFromClient({
2260
+ options,
2261
+ sessionId,
2262
+ toolCall: {
2263
+ toolCallId: toolUseID,
2264
+ rawInput: toolInput,
2265
+ ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
2266
+ },
2267
+ }, toolName, signal);
2268
+ if (signal.aborted || response.outcome?.outcome === "cancelled") {
2269
+ throw new Error("Tool use aborted");
2270
+ }
2271
+ const selectedMode = response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
2272
+ const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
2273
+ if (selectedModeWasOffered &&
2274
+ (selectedMode === "default" ||
2275
+ selectedMode === "acceptEdits" ||
2276
+ selectedMode === "auto" ||
2277
+ selectedMode === "bypassPermissions")) {
2278
+ await this.client.sessionUpdate({
2279
+ sessionId,
2280
+ update: {
2281
+ sessionUpdate: "current_mode_update",
2282
+ currentModeId: selectedMode,
2283
+ },
2284
+ });
2285
+ await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode);
2286
+ return {
2287
+ behavior: "allow",
2288
+ updatedInput: toolInput,
2289
+ updatedPermissions: suggestions ?? [
2290
+ { type: "setMode", mode: selectedMode, destination: "session" },
2291
+ ],
2292
+ };
2293
+ }
2294
+ else {
2295
+ return {
2296
+ behavior: "deny",
2297
+ message: "User rejected request to exit plan mode.",
2298
+ };
2299
+ }
2300
+ }
2301
+ if (session.modes.currentModeId === "bypassPermissions") {
2302
+ return {
2303
+ behavior: "allow",
2304
+ updatedInput: toolInput,
2305
+ updatedPermissions: suggestions ?? [
2306
+ { type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" },
2307
+ ],
2308
+ };
2309
+ }
2310
+ const response = await this.requestPermissionFromClient({
2311
+ options: [
2312
+ {
2313
+ kind: "allow_always",
2314
+ name: alwaysAllowLabel,
2315
+ optionId: "allow_always",
2316
+ },
2317
+ { kind: "allow_once", name: "Allow", optionId: "allow" },
2318
+ { kind: "reject_once", name: "Reject", optionId: "reject" },
2319
+ ],
2320
+ sessionId,
2321
+ toolCall: {
2322
+ toolCallId: toolUseID,
2323
+ rawInput: toolInput,
2324
+ ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
2325
+ },
2326
+ }, toolName, signal);
2327
+ if (signal.aborted || response.outcome?.outcome === "cancelled") {
2328
+ throw new Error("Tool use aborted");
2329
+ }
2330
+ if (response.outcome?.outcome === "selected" &&
2331
+ (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")) {
2332
+ // If Claude Code has suggestions, it will update their settings already
2333
+ if (response.outcome.optionId === "allow_always") {
2334
+ return {
2335
+ behavior: "allow",
2336
+ updatedInput: toolInput,
2337
+ updatedPermissions: suggestions ?? [
2338
+ {
2339
+ type: "addRules",
2340
+ rules: [{ toolName }],
2341
+ behavior: "allow",
2342
+ destination: "session",
2343
+ },
2344
+ ],
2345
+ };
2346
+ }
2347
+ return {
2348
+ behavior: "allow",
2349
+ updatedInput: toolInput,
2350
+ };
2351
+ }
2352
+ else {
2353
+ return {
2354
+ behavior: "deny",
2355
+ message: "User refused permission to run tool",
2356
+ };
2357
+ }
2358
+ };
2359
+ }
2360
+ /**
2361
+ * Handle elicitation requests that originate from MCP servers by forwarding
2362
+ * them to the client over ACP. Modes the client did not advertise (or
2363
+ * requests we can't represent) are declined.
2364
+ */
2365
+ handleMcpElicitation(sessionId, support) {
2366
+ return async (request, { signal }) => {
2367
+ const isUrl = request.mode === "url";
2368
+ if ((isUrl && !support.url) || (!isUrl && !support.form)) {
2369
+ return { action: "decline" };
2370
+ }
2371
+ const createRequest = mcpElicitationToCreateRequest(request, sessionId);
2372
+ if (!createRequest) {
2373
+ return { action: "decline" };
2374
+ }
2375
+ try {
2376
+ const response = await this.client.unstable_createElicitation(createRequest, signal);
2377
+ if (signal.aborted) {
2378
+ return { action: "cancel" };
2379
+ }
2380
+ return createElicitationResponseToElicitResult(response);
2381
+ }
2382
+ catch (error) {
2383
+ // A cancellation we requested (signal aborted) settles as a cancel, not
2384
+ // a hard decline — the elicitation was abandoned, not refused.
2385
+ if (signal.aborted) {
2386
+ return { action: "cancel" };
2387
+ }
2388
+ this.logger.error(`Failed to forward MCP elicitation: ${error}`);
2389
+ return { action: "decline" };
2390
+ }
2391
+ };
2392
+ }
2393
+ /**
2394
+ * Present the built-in AskUserQuestion tool's questions as an ACP form
2395
+ * elicitation and return the answers as the tool's `updatedInput`. Called from
2396
+ * `canUseTool` since that is where the SDK routes the tool's permission check.
2397
+ */
2398
+ async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal) {
2399
+ const questions = extractAskUserQuestions(toolInput);
2400
+ if (!questions) {
2401
+ return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
2402
+ }
2403
+ const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
2404
+ let response;
2405
+ try {
2406
+ response = await this.client.unstable_createElicitation(createRequest, signal);
2407
+ }
2408
+ catch (error) {
2409
+ // A cancellation we requested (signal aborted) settles as an aborted tool
2410
+ // use, matching the post-response check below.
2411
+ if (signal.aborted) {
2412
+ throw new Error("Tool use aborted", { cause: error });
2413
+ }
2414
+ this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
2415
+ return { behavior: "deny", message: "Could not present the question to the user." };
2416
+ }
2417
+ if (signal.aborted) {
2418
+ throw new Error("Tool use aborted");
2419
+ }
2420
+ const outcome = applyAskElicitationResponse(response, toolInput, questions);
2421
+ if (outcome.action === "cancel") {
2422
+ throw new Error("Tool use aborted");
2423
+ }
2424
+ return { behavior: "allow", updatedInput: outcome.updatedInput };
2425
+ }
2426
+ /**
2427
+ * Handle `request_user_dialog` control requests — blocking dialogs the CLI
2428
+ * asks the host to render. Only kinds declared in `supportedDialogKinds`
2429
+ * are ever emitted; everything unexpected is answered `cancelled` (the
2430
+ * required answer for unrecognized kinds), which applies the dialog's
2431
+ * default behavior CLI-side. Today the only declared kind is the
2432
+ * refusal-fallback consent prompt, rendered as an ACP form elicitation.
2433
+ */
2434
+ handleUserDialog(sessionId) {
2435
+ return async (request, { signal }) => {
2436
+ if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
2437
+ return { behavior: "cancelled" };
2438
+ }
2439
+ const prompt = extractRefusalFallbackPrompt(request.payload);
2440
+ if (!prompt) {
2441
+ this.logger.error(`refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`);
2442
+ return { behavior: "cancelled" };
2443
+ }
2444
+ let response;
2445
+ try {
2446
+ response = await this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal);
2447
+ }
2448
+ catch (error) {
2449
+ // A cancellation we requested (signal aborted) is expected teardown;
2450
+ // anything else is a client failure. Either way the safe answer is
2451
+ // `cancelled` — the CLI applies the dialog's default (keep the
2452
+ // refusal) rather than switching models without consent.
2453
+ if (!signal.aborted) {
2454
+ this.logger.error(`Failed to present refusal fallback elicitation: ${error}`);
2455
+ }
2456
+ return { behavior: "cancelled" };
2457
+ }
2458
+ if (signal.aborted) {
2459
+ return { behavior: "cancelled" };
2460
+ }
2461
+ return { behavior: "completed", result: refusalFallbackResultFromResponse(response) };
2462
+ };
2463
+ }
2464
+ async sendAvailableCommandsUpdate(sessionId) {
2465
+ const session = this.sessions[sessionId];
2466
+ if (!session)
2467
+ return;
2468
+ const commands = await session.query.supportedCommands();
2469
+ await this.client.sessionUpdate({
2470
+ sessionId,
2471
+ update: {
2472
+ sessionUpdate: "available_commands_update",
2473
+ availableCommands: getAvailableSlashCommands(commands),
2474
+ },
2475
+ });
2476
+ }
2477
+ async updateConfigOption(sessionId, configId, value) {
2478
+ const session = this.sessions[sessionId];
2479
+ if (!session)
2480
+ return;
2481
+ await this.applyConfigOptionValue(sessionId, session, configId, value);
2482
+ await this.client.sessionUpdate({
2483
+ sessionId,
2484
+ update: {
2485
+ sessionUpdate: "config_option_update",
2486
+ configOptions: session.configOptions,
2487
+ },
2488
+ });
2489
+ }
2490
+ async applyConfigOptionValue(sessionId, session, configId, value) {
2491
+ if (configId === MODE_CONFIG_ID) {
2492
+ session.modes = { ...session.modes, currentModeId: value };
2493
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
2494
+ }
2495
+ else if (configId === MODEL_CONFIG_ID) {
2496
+ // `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
2497
+ // modes below; its `displayName`/`description` also let us infer the
2498
+ // context window for semantic aliases (e.g. `default`) whose ID alone
2499
+ // carries no "1m" token.
2500
+ const newModelInfo = session.modelInfos.find((m) => m.value === value);
2501
+ if (session.models.currentModelId !== value) {
2502
+ // The cached context window was learned for the previous model; reset
2503
+ // to the new model's heuristic so mid-stream updates between now and
2504
+ // the next `result` reflect the user's selection instead of the old
2505
+ // model's window.
2506
+ session.contextWindowSize =
2507
+ inferContextWindowFromModel(value, newModelInfo?.displayName, newModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW;
2508
+ }
2509
+ session.models = { ...session.models, currentModelId: value };
2510
+ // Recompute availableModes for the new model and clamp the current
2511
+ // mode if the SDK no longer offers it (today: "auto" on Haiku). An
2512
+ // unknown model (an SDK-initiated refusal fallback to a model outside
2513
+ // the user's `availableModels` allowlist — user-driven switches are
2514
+ // validated against the options first) tells us nothing about its
2515
+ // capabilities, so keep the current modes rather than spuriously
2516
+ // downgrading (e.g. kicking the user out of "auto" for a model that
2517
+ // does support it).
2518
+ const newAvailableModes = newModelInfo
2519
+ ? buildAvailableModes(newModelInfo)
2520
+ : session.modes.availableModes;
2521
+ // Capture BEFORE mutating session.modes so the log message reflects
2522
+ // the invalidated mode rather than "default".
2523
+ const previousModeId = session.modes.currentModeId;
2524
+ let modeDowngraded = false;
2525
+ if (!newAvailableModes.some((m) => m.id === previousModeId)) {
2526
+ session.modes = {
2527
+ availableModes: newAvailableModes,
2528
+ currentModeId: "default",
2529
+ };
2530
+ try {
2531
+ await session.query.setPermissionMode("default");
2532
+ }
2533
+ catch (err) {
2534
+ // Failing the entire model switch over a bookkeeping sync error is
2535
+ // worse UX than logging and continuing; the user explicitly asked
2536
+ // to change models. The next setPermissionMode from the user will
2537
+ // either succeed or surface a fresh error.
2538
+ this.logger.error(`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`, err);
2539
+ }
2540
+ modeDowngraded = true;
2541
+ }
2542
+ else {
2543
+ session.modes = { ...session.modes, availableModes: newAvailableModes };
2544
+ }
2545
+ // Rebuild config options since effort levels depend on the selected model
2546
+ const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2547
+ const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
2548
+ session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort, session.agents, session.currentAgent, {
2549
+ // The toggle follows the newly selected model: it disappears when the
2550
+ // model lacks fast support and reappears (with the retained user
2551
+ // intent) when a supporting model is selected again.
2552
+ supported: newModelInfo?.supportsFastMode ?? false,
2553
+ enabled: session.fastModeEnabled,
2554
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
2555
+ });
2556
+ // Sync effort with the SDK if it changed after the model switch
2557
+ const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2558
+ const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
2559
+ if (newEffort !== currentEffort) {
2560
+ await session.query.applyFlagSettings({
2561
+ effortLevel: toSdkEffortLevel(newEffort),
2562
+ });
2563
+ }
2564
+ // Emit current_mode_update only after session.modes AND
2565
+ // session.configOptions have been fully reconciled. This way, a failure
2566
+ // in the configOptions/effort rebuild above can't leave the client with
2567
+ // a clamped currentModeId but stale configOptions, and the notification
2568
+ // still precedes the caller's config_option_update so order-sensitive
2569
+ // clients update currentModeId before re-rendering the option list.
2570
+ if (modeDowngraded) {
2571
+ await this.client.sessionUpdate({
2572
+ sessionId,
2573
+ update: {
2574
+ sessionUpdate: "current_mode_update",
2575
+ currentModeId: "default",
2576
+ },
2577
+ });
2578
+ }
2579
+ }
2580
+ else if (configId === AGENT_CONFIG_ID) {
2581
+ // Live agent switch — no subprocess restart needed. Apply the SDK flag
2582
+ // first so a rejected control request leaves both `currentAgent` and the
2583
+ // config option untouched (no UI/SDK desync). Passing `null` clears the
2584
+ // flag layer back to the standard Claude Code agent; the change takes
2585
+ // effect on the next turn (SDK >= 0.3.161).
2586
+ await session.query.applyFlagSettings({
2587
+ agent: value === DEFAULT_AGENT_ID ? null : value,
2588
+ });
2589
+ session.currentAgent = value;
2590
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
2591
+ }
2592
+ else {
2593
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
2594
+ if (configId === EFFORT_CONFIG_ID) {
2595
+ await session.query.applyFlagSettings({
2596
+ effortLevel: toSdkEffortLevel(value),
2597
+ });
2598
+ }
2599
+ }
2600
+ }
2601
+ /** Reconcile adapter model state after the SDK persistently swapped the
2602
+ * session's model out from under us (refusal fallback). The SDK already
2603
+ * made the switch, so this must NOT call `query.setModel` — it only
2604
+ * updates our bookkeeping (currentModelId, context window, mode clamping,
2605
+ * effort/Fast-mode options) via the same `applyConfigOptionValue` path a
2606
+ * user-driven model change takes, then notifies the client. */
2607
+ async syncModelAfterRefusalFallback(sessionId, session, fallbackModel) {
2608
+ // Map the SDK-reported model onto one of the session's model options
2609
+ // (handles display names and `resolvedModel` ids). The fallback model may
2610
+ // not be among the options — e.g. excluded by the user's
2611
+ // `availableModels` allowlist — in which case we track the raw id: the
2612
+ // picker shows no selection, but the model-dependent bookkeeping and any
2613
+ // later `setModel` round-trip stay truthful to what the SDK is running.
2614
+ const resolved = resolveModelPreference(session.modelInfos, fallbackModel);
2615
+ const value = resolved?.value ?? fallbackModel;
2616
+ if (session.models.currentModelId === value)
2617
+ return;
2618
+ try {
2619
+ await this.updateConfigOption(sessionId, MODEL_CONFIG_ID, value);
2620
+ }
2621
+ catch (err) {
2622
+ // This runs on the consumer loop: a throw here tears down the query
2623
+ // stream (failAllTurns + closeQueryStream) and bricks the session —
2624
+ // far worse than stale bookkeeping. The user-driven RPC path lets the
2625
+ // same errors propagate to fail just that request; here we log and
2626
+ // move on, matching the setPermissionMode containment inside
2627
+ // applyConfigOptionValue.
2628
+ this.logger.error(`Failed to reconcile model state after refusal fallback to "${fallbackModel}":`, err);
2629
+ }
2630
+ }
2631
+ /** Replace the Fast mode option in `session.configOptions` so it reflects
2632
+ * `enabled` (and the client's current boolean-capability). A no-op when the
2633
+ * option isn't present, so callers must confirm the current model surfaces
2634
+ * it first. */
2635
+ refreshFastModeOption(session, enabled) {
2636
+ const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities));
2637
+ session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
2638
+ }
2639
+ /** Toggle Fast mode for a session: push the SDK flag, record the user's
2640
+ * intent, and refresh the Fast mode config option in place. Only reached
2641
+ * once the option exists (i.e. the current model supports fast mode), so the
2642
+ * option is guaranteed to be present in `configOptions`. */
2643
+ async applyFastMode(session, enabled) {
2644
+ // Apply the SDK flag first so a rejected control request leaves both the
2645
+ // session state and the config option untouched (no UI/SDK desync).
2646
+ await session.query.applyFlagSettings({ fastMode: enabled });
2647
+ session.fastModeEnabled = enabled;
2648
+ this.refreshFastModeOption(session, enabled);
2649
+ }
2650
+ /** Reconcile the session's Fast mode toggle with an SDK-reported
2651
+ * `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
2652
+ * The SDK can flip fast mode independently of the user — e.g. back to `on`
2653
+ * once a rate-limit `cooldown` clears — so we mirror definitive on/off
2654
+ * changes into the config option and notify the client.
2655
+ *
2656
+ * Guards, in order:
2657
+ * - absent state: nothing to reconcile.
2658
+ * - no Fast mode option: the current model doesn't support fast mode, so the
2659
+ * reported state reflects capability, not the user's intent. Leave the
2660
+ * retained setting untouched so it's correct when a supporting model is
2661
+ * reselected (the source of the earlier intent-clobber bug was mutating it
2662
+ * here).
2663
+ * - `cooldown`: a transient suspension of an already-enabled fast mode.
2664
+ * Leave the toggle as-is rather than flapping it — and never let a stray
2665
+ * cooldown spuriously enable a toggle the user has off. */
2666
+ async syncFastModeState(sessionId, session, state) {
2667
+ if (state === undefined) {
2668
+ return;
2669
+ }
2670
+ if (!session.configOptions.some((o) => o.id === FAST_MODE_CONFIG_ID)) {
2671
+ return;
2672
+ }
2673
+ if (state === "cooldown") {
2674
+ return;
2675
+ }
2676
+ const enabled = state === "on";
2677
+ if (enabled === session.fastModeEnabled) {
2678
+ return;
2679
+ }
2680
+ session.fastModeEnabled = enabled;
2681
+ this.refreshFastModeOption(session, enabled);
2682
+ await this.client.sessionUpdate({
2683
+ sessionId,
2684
+ update: {
2685
+ sessionUpdate: "config_option_update",
2686
+ configOptions: session.configOptions,
2687
+ },
2688
+ });
2689
+ }
2690
+ async getOrCreateSession(params) {
2691
+ const existingSession = this.sessions[params.sessionId];
2692
+ if (existingSession) {
2693
+ const fingerprint = computeSessionFingerprint(params);
2694
+ if (fingerprint === existingSession.sessionFingerprint) {
2695
+ return {
2696
+ sessionId: params.sessionId,
2697
+ modes: existingSession.modes,
2698
+ configOptions: existingSession.configOptions,
2699
+ };
2700
+ }
2701
+ // Session-defining params changed (e.g. cwd pointed at a git worktree,
2702
+ // or MCP servers reconfigured). Tear down the existing session and
2703
+ // recreate it so the underlying Query process picks up the new values.
2704
+ await this.teardownSession(params.sessionId);
2705
+ }
2706
+ const response = await this.createSession({
2707
+ cwd: params.cwd,
2708
+ mcpServers: params.mcpServers ?? [],
2709
+ additionalDirectories: params.additionalDirectories,
2710
+ _meta: params._meta,
2711
+ }, {
2712
+ resume: params.sessionId,
2713
+ });
2714
+ return {
2715
+ sessionId: response.sessionId,
2716
+ modes: response.modes,
2717
+ configOptions: response.configOptions,
2718
+ };
2719
+ }
2720
+ /**
2721
+ * Ensures the requested `cwd` is an absolute path that points at an existing
2722
+ * directory before we create a session. Throws an `invalidParams` error with
2723
+ * an actionable message so clients (e.g. Zed) can surface it to the user
2724
+ * instead of failing later with an opaque SDK error.
2725
+ */
2726
+ async validateCwd(cwd) {
2727
+ if (!path.isAbsolute(cwd)) {
2728
+ throw RequestError.invalidParams({ cwd }, `\`cwd\` must be an absolute path, but received: ${cwd}`);
2729
+ }
2730
+ let stats;
2731
+ try {
2732
+ stats = await fs.stat(cwd);
2733
+ }
2734
+ catch {
2735
+ throw RequestError.invalidParams({ cwd }, `\`cwd\` does not exist on the machine running the agent: ${cwd}`);
2736
+ }
2737
+ if (!stats.isDirectory()) {
2738
+ throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
2739
+ }
2740
+ }
2741
+ async createSession(params, creationOpts = {}) {
2742
+ // Validate `cwd` up front. The ACP spec requires an absolute path, and the
2743
+ // directory must actually exist on the machine running the agent. Without
2744
+ // this check a session is created against a missing directory and the
2745
+ // failure only surfaces later as a confusing "native binary failed to
2746
+ // launch" error from the SDK (see issue #749).
2747
+ await this.validateCwd(params.cwd);
2748
+ // We want to create a new session id unless it is resume,
2749
+ // but not resume + forkSession.
2750
+ let sessionId;
2751
+ if (creationOpts.forkSession) {
2752
+ sessionId = randomUUID();
2753
+ }
2754
+ else if (creationOpts.resume) {
2755
+ sessionId = creationOpts.resume;
2756
+ }
2757
+ else {
2758
+ sessionId = randomUUID();
2759
+ }
2760
+ const input = new Pushable();
2761
+ const settingsManager = new SettingsManager(params.cwd, {
2762
+ logger: this.logger,
2763
+ });
2764
+ await settingsManager.initialize();
2765
+ const mcpServers = {};
2766
+ if (Array.isArray(params.mcpServers)) {
2767
+ for (const server of params.mcpServers) {
2768
+ if ("type" in server && (server.type === "http" || server.type === "sse")) {
2769
+ // HTTP or SSE type MCP server
2770
+ mcpServers[server.name] = {
2771
+ type: server.type,
2772
+ url: server.url,
2773
+ headers: server.headers
2774
+ ? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
2775
+ : undefined,
2776
+ };
2777
+ }
2778
+ else if (!("type" in server)) {
2779
+ // Stdio type MCP server (with or without explicit type field)
2780
+ mcpServers[server.name] = {
2781
+ type: "stdio",
2782
+ command: server.command,
2783
+ args: server.args,
2784
+ env: server.env
2785
+ ? Object.fromEntries(server.env.map((e) => [e.name, e.value]))
2786
+ : undefined,
2787
+ };
2788
+ }
2789
+ }
2790
+ }
2791
+ let systemPrompt = { type: "preset", preset: "claude_code" };
2792
+ if (params._meta?.systemPrompt) {
2793
+ const customPrompt = params._meta.systemPrompt;
2794
+ if (typeof customPrompt === "string") {
2795
+ systemPrompt = customPrompt;
2796
+ }
2797
+ else if (typeof customPrompt === "object" &&
2798
+ customPrompt !== null &&
2799
+ !Array.isArray(customPrompt)) {
2800
+ // Forward all preset options (append, excludeDynamicSections, and
2801
+ // anything the SDK adds later) while locking type/preset.
2802
+ systemPrompt = {
2803
+ ...customPrompt,
2804
+ type: "preset",
2805
+ preset: "claude_code",
2806
+ };
2807
+ }
2808
+ }
2809
+ const permissionMode = resolvePermissionMode(settingsManager.getSettings().permissions?.defaultMode, this.logger);
2810
+ // Extract options from _meta if provided
2811
+ const sessionMeta = params._meta;
2812
+ const userProvidedOptions = sessionMeta?.claudeCode?.options;
2813
+ // Configure thinking behavior from environment variable
2814
+ const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
2815
+ // Parse model configuration from environment (e.g. Bedrock model overrides)
2816
+ const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
2817
+ // Elicitation modes the connected client advertised. We only forward
2818
+ // elicitations (and only re-enable AskUserQuestion) for modes the client
2819
+ // can actually render.
2820
+ const elicitationSupport = {
2821
+ form: !!this.clientCapabilities?.elicitation?.form,
2822
+ url: !!this.clientCapabilities?.elicitation?.url,
2823
+ };
2824
+ // AskUserQuestion surfaces as a `permission_ask_user_question` dialog that
2825
+ // we render as a form elicitation. Without form-elicitation support we fall
2826
+ // back to sequential ACP `session/request_permission` dialogs when the
2827
+ // fallback gate is on (the default); set ACP_ASKUSERQUESTION_FALLBACK=0 to
2828
+ // keep it disabled, exactly as upstream does.
2829
+ const disallowedTools = elicitationSupport.form || askUserQuestionFallbackEnabled(process.env)
2830
+ ? []
2831
+ : ["AskUserQuestion"];
2832
+ // Resolve which built-in tools to expose.
2833
+ // Explicit tools array from _meta.claudeCode.options takes precedence.
2834
+ // disableBuiltInTools is a legacy shorthand for tools: [] — kept for
2835
+ // backward compatibility but callers should prefer the tools array.
2836
+ const tools = userProvidedOptions?.tools ??
2837
+ (params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" });
2838
+ const abortController = userProvidedOptions?.abortController || new AbortController();
2839
+ // Per-session task state. Created here (rather than in the session record
2840
+ // below) so the TaskCreated/TaskCompleted hook callbacks can close over
2841
+ // the same Map that the streaming message handler will read from.
2842
+ const taskState = new Map();
2843
+ const options = {
2844
+ systemPrompt,
2845
+ settingSources: ["user", "project", "local"],
2846
+ ...(thinking !== undefined && { thinking }),
2847
+ ...userProvidedOptions,
2848
+ // CLAUDE_MODEL_CONFIG env var is a fallback for model
2849
+ // configuration (e.g. Bedrock model ID overrides). When the caller
2850
+ // provides settings via _meta, we intentionally ignore the env var —
2851
+ // the caller is assumed to have full control over model configuration.
2852
+ ...(!userProvidedOptions?.settings &&
2853
+ modelConfig && {
2854
+ settings: {
2855
+ ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
2856
+ ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
2857
+ },
2858
+ }),
2859
+ env: {
2860
+ ...process.env,
2861
+ ...userProvidedOptions?.env,
2862
+ ...createEnvForGateway(this.gatewayAuthRequest),
2863
+ // Opt-in to session state events like when the agent is idle
2864
+ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
2865
+ },
2866
+ // Override certain fields that must be controlled by ACP
2867
+ cwd: params.cwd,
2868
+ includePartialMessages: true,
2869
+ mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
2870
+ // If we want bypassPermissions to be an option, we have to allow it here.
2871
+ // But it doesn't work in root mode, so we only activate it if it will work.
2872
+ allowDangerouslySkipPermissions: ALLOW_BYPASS,
2873
+ permissionMode,
2874
+ canUseTool: this.canUseTool(sessionId),
2875
+ // Forward MCP elicitation requests onto ACP elicitation. Only attached
2876
+ // when the client advertised support, so non-supporting clients keep the
2877
+ // SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
2878
+ // canUseTool, not here.)
2879
+ ...(elicitationSupport.form || elicitationSupport.url
2880
+ ? { onElicitation: this.handleMcpElicitation(sessionId, elicitationSupport) }
2881
+ : {}),
2882
+ // Render the CLI's refusal-fallback consent prompt ("<model> declined —
2883
+ // retry with <fallback>?") as an ACP form elicitation. Declaring the
2884
+ // kind is the opt-in: the CLI never emits an undeclared dialog, and the
2885
+ // flow instead degrades to the classic refusal error ending the turn.
2886
+ // Gated on form elicitation since that's the only ACP surface that can
2887
+ // present a choice outside a tool call.
2888
+ ...(elicitationSupport.form
2889
+ ? {
2890
+ onUserDialog: this.handleUserDialog(sessionId),
2891
+ supportedDialogKinds: [REFUSAL_FALLBACK_DIALOG_KIND],
2892
+ }
2893
+ : {}),
2894
+ pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
2895
+ extraArgs: {
2896
+ ...userProvidedOptions?.extraArgs,
2897
+ "replay-user-messages": "",
2898
+ },
2899
+ disallowedTools: [...(userProvidedOptions?.disallowedTools || []), ...disallowedTools],
2900
+ tools,
2901
+ hooks: {
2902
+ ...userProvidedOptions?.hooks,
2903
+ PostToolUse: [
2904
+ ...(userProvidedOptions?.hooks?.PostToolUse || []),
2905
+ {
2906
+ hooks: [
2907
+ createPostToolUseHook(this.logger, {
2908
+ onEnterPlanMode: async () => {
2909
+ await this.client.sessionUpdate({
2910
+ sessionId,
2911
+ update: {
2912
+ sessionUpdate: "current_mode_update",
2913
+ currentModeId: "plan",
2914
+ },
2915
+ });
2916
+ await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
2917
+ },
2918
+ }),
2919
+ ],
2920
+ },
2921
+ ],
2922
+ TaskCreated: [
2923
+ ...(userProvidedOptions?.hooks?.TaskCreated || []),
2924
+ {
2925
+ hooks: [
2926
+ createTaskHook({
2927
+ taskState,
2928
+ onChange: async () => {
2929
+ await this.client.sessionUpdate({
2930
+ sessionId,
2931
+ update: {
2932
+ sessionUpdate: "plan",
2933
+ entries: taskStateToPlanEntries(taskState),
2934
+ },
2935
+ });
2936
+ },
2937
+ }),
2938
+ ],
2939
+ },
2940
+ ],
2941
+ TaskCompleted: [
2942
+ ...(userProvidedOptions?.hooks?.TaskCompleted || []),
2943
+ {
2944
+ hooks: [
2945
+ createTaskHook({
2946
+ taskState,
2947
+ onChange: async () => {
2948
+ await this.client.sessionUpdate({
2949
+ sessionId,
2950
+ update: {
2951
+ sessionUpdate: "plan",
2952
+ entries: taskStateToPlanEntries(taskState),
2953
+ },
2954
+ });
2955
+ },
2956
+ }),
2957
+ ],
2958
+ },
2959
+ ],
2960
+ },
2961
+ ...creationOpts,
2962
+ abortController,
2963
+ };
2964
+ // Prefer the official ACP `additionalDirectories` field. Fall back to the
2965
+ // legacy `_meta.additionalRoots` extension for clients that haven't been
2966
+ // updated yet. Either source is merged with directories supplied via
2967
+ // `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
2968
+ const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
2969
+ options.additionalDirectories = [
2970
+ ...(userProvidedOptions?.additionalDirectories ?? []),
2971
+ ...acpAdditionalDirectories,
2972
+ ];
2973
+ if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
2974
+ // Set our own session id if not resuming an existing session.
2975
+ options.sessionId = sessionId;
2976
+ }
2977
+ // Handle abort controller from meta options
2978
+ if (abortController?.signal.aborted) {
2979
+ throw new Error("Cancelled");
2980
+ }
2981
+ const q = query({
2982
+ prompt: input,
2983
+ options,
2984
+ });
2985
+ let initializationResult;
2986
+ try {
2987
+ initializationResult = await q.initializationResult();
2988
+ }
2989
+ catch (error) {
2990
+ if (creationOpts.resume &&
2991
+ error instanceof Error &&
2992
+ (error.message === "Query closed before response received" ||
2993
+ error.message.includes("No conversation found with session ID"))) {
2994
+ throw RequestError.resourceNotFound(sessionId);
2995
+ }
2996
+ throw error;
2997
+ }
2998
+ if (shouldHideClaudeAuth() &&
2999
+ initializationResult.account.subscriptionType &&
3000
+ !this.gatewayAuthRequest) {
3001
+ throw RequestError.authRequired(undefined, "This integration does not support using claude.ai subscriptions.");
3002
+ }
3003
+ // Apply user's `availableModels` allowlist from settings.json before any
3004
+ // downstream model handling. The SDK only enforces this allowlist in its
3005
+ // own UI, not in `initializationResult.models`, so we filter here to keep
3006
+ // configOptions, the current-model resolver, and the stored modelInfos
3007
+ // consistent with what the user configured.
3008
+ const settingsAvailableModels = settingsManager.getSettings().availableModels;
3009
+ const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
3010
+ const allowedModels = Array.isArray(settingsAvailableModels)
3011
+ ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
3012
+ : initializationResult.models;
3013
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger);
3014
+ // Gate `auto` (and future model-specific modes) on the resolved model's
3015
+ // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
3016
+ const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
3017
+ const availableModes = buildAvailableModes(currentModelInfo);
3018
+ // Clamp `permissionMode` if the resolved session does not offer it. The
3019
+ // common case is `permissions.defaultMode: "auto"` resolving to a model
3020
+ // that does not support auto mode (e.g. Haiku); without this clamp the
3021
+ // SDK would later throw `"auto mode unavailable for this model"` from
3022
+ // `setPermissionMode`. Keep `permissionMode` as the resolved user intent
3023
+ // (matches what was passed into `options.permissionMode` above) and use
3024
+ // `effectiveMode` for the post-clamp value the session actually runs in.
3025
+ let effectiveMode = permissionMode;
3026
+ if (!availableModes.some((m) => m.id === effectiveMode)) {
3027
+ if (effectiveMode === "auto") {
3028
+ this.logger.error(`permissions.defaultMode "auto" is not available for model ` +
3029
+ `"${models.currentModelId}"; falling back to "default".`);
3030
+ }
3031
+ else {
3032
+ this.logger.error(`permissions.defaultMode "${effectiveMode}" is not available in ` +
3033
+ `this session; falling back to "default".`);
3034
+ }
3035
+ effectiveMode = "default";
3036
+ // Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
3037
+ // try/catch since failing here would abort session creation entirely.
3038
+ try {
3039
+ await q.setPermissionMode("default");
3040
+ }
3041
+ catch (err) {
3042
+ this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
3043
+ }
3044
+ }
3045
+ const modes = {
3046
+ currentModeId: effectiveMode,
3047
+ availableModes,
3048
+ };
3049
+ const agents = await discoverCustomAgents(q);
3050
+ // Only adopt the requested agent as the selected value if it's one we
3051
+ // actually surface in the picker. A built-in (filtered out above) or
3052
+ // otherwise-unknown name would leave the config option's `currentValue`
3053
+ // pointing at an entry not in its own `options` list, which clients render
3054
+ // as a blank/invalid selection.
3055
+ const requestedAgent = userProvidedOptions?.agent;
3056
+ const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
3057
+ ? requestedAgent
3058
+ : DEFAULT_AGENT_ID;
3059
+ // Seed Fast mode from the SDK's reported state so the UI reflects reality
3060
+ // (the CLI may start a session with fast mode already on, or force it off
3061
+ // when `fastModePerSessionOptIn` is set). The toggle is only surfaced while
3062
+ // the resolved model advertises `supportsFastMode`.
3063
+ const fastModeEnabled = initializationResult.fast_mode_state !== undefined &&
3064
+ fastModeStateEnabled(initializationResult.fast_mode_state);
3065
+ const fastMode = {
3066
+ supported: currentModelInfo?.supportsFastMode ?? false,
3067
+ enabled: fastModeEnabled,
3068
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
3069
+ };
3070
+ const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode);
3071
+ // Apply the initial effort level to the SDK so it matches the UI default
3072
+ const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
3073
+ if (initialEffort &&
3074
+ typeof initialEffort.currentValue === "string" &&
3075
+ initialEffort.currentValue !== "default") {
3076
+ await q.applyFlagSettings({
3077
+ effortLevel: initialEffort.currentValue,
3078
+ });
3079
+ }
3080
+ this.sessions[sessionId] = {
3081
+ query: q,
3082
+ input: input,
3083
+ cancelled: false,
3084
+ cwd: params.cwd,
3085
+ sessionFingerprint: computeSessionFingerprint(params),
3086
+ settingsManager,
3087
+ accumulatedUsage: {
3088
+ inputTokens: 0,
3089
+ outputTokens: 0,
3090
+ cachedReadTokens: 0,
3091
+ cachedWriteTokens: 0,
3092
+ },
3093
+ modes,
3094
+ models,
3095
+ modelInfos: allowedModels,
3096
+ configOptions,
3097
+ agents,
3098
+ currentAgent,
3099
+ fastModeEnabled,
3100
+ abortController,
3101
+ emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
3102
+ contextWindowSize: inferContextWindowFromModel(models.currentModelId, currentModelInfo?.displayName, currentModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
3103
+ taskState,
3104
+ toolUseCache: {},
3105
+ emittedToolCalls: new Set(),
3106
+ messageIdToUuid: new Map(),
3107
+ };
3108
+ return {
3109
+ sessionId,
3110
+ modes,
3111
+ configOptions,
3112
+ };
3113
+ }
3114
+ }
3115
+ function shouldEmitRawMessage(config, message) {
3116
+ if (config === true)
3117
+ return true;
3118
+ if (config === false)
3119
+ return false;
3120
+ return config.some((f) => f.type === message.type &&
3121
+ (f.subtype === undefined || f.subtype === message.subtype) &&
3122
+ (f.origin === undefined || f.origin === message.origin?.kind));
3123
+ }
3124
+ function sessionUsage(session) {
3125
+ return {
3126
+ inputTokens: session.accumulatedUsage.inputTokens,
3127
+ outputTokens: session.accumulatedUsage.outputTokens,
3128
+ cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
3129
+ cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
3130
+ totalTokens: session.accumulatedUsage.inputTokens +
3131
+ session.accumulatedUsage.outputTokens +
3132
+ session.accumulatedUsage.cachedReadTokens +
3133
+ session.accumulatedUsage.cachedWriteTokens,
3134
+ };
3135
+ }
3136
+ /** Sum all four fields as a proxy for post-turn context occupancy: the current
3137
+ * turn's output becomes next turn's input. Per the Anthropic API, input_tokens
3138
+ * excludes cache tokens — cache_read and cache_creation are reported
3139
+ * separately — so summing all four is not double-counting. */
3140
+ function totalTokens(usage) {
3141
+ return (usage.input_tokens +
3142
+ usage.output_tokens +
3143
+ usage.cache_read_input_tokens +
3144
+ usage.cache_creation_input_tokens);
3145
+ }
3146
+ /**
3147
+ * Build the `data` payload attached to a `RequestError.internalError` when we
3148
+ * have a categorical error — from the Claude SDK, or one of the adapter's own
3149
+ * kinds. Returns `undefined` when no categorical error is available, matching
3150
+ * the previous behavior of passing `undefined` to `RequestError.internalError`.
3151
+ *
3152
+ * The `errorKind` field is a convention for ACP clients to dispatch on
3153
+ * without having to pattern-match the human-readable message text. Clients
3154
+ * that don't understand it fall back to the existing message-based rendering.
3155
+ */
3156
+ function errorKindData(errorKind) {
3157
+ return errorKind ? { errorKind } : undefined;
3158
+ }
3159
+ /** Project a nullable API usage object into our non-null snapshot shape.
3160
+ * Both SDK message_start and assistant message `usage` have `number | null`
3161
+ * cache fields; we coerce absent values to 0 so `totalTokens` never hits
3162
+ * NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but
3163
+ * synthetic or third-party-backend stream events have been observed emitting
3164
+ * them as null/undefined — coerce those too so a malformed upstream event
3165
+ * can't leak NaN into the wire `used` field. Delta events have different
3166
+ * semantics (cumulative + prev fallback) and are handled inline. */
3167
+ function snapshotFromUsage(usage) {
3168
+ return {
3169
+ input_tokens: usage.input_tokens ?? 0,
3170
+ output_tokens: usage.output_tokens ?? 0,
3171
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
3172
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
3173
+ };
3174
+ }
3175
+ function createEnvForGateway(request) {
3176
+ if (!request?._meta) {
3177
+ return {};
3178
+ }
3179
+ const customHeaders = Object.entries(request._meta.gateway.headers)
3180
+ .map(([key, value]) => `${key}: ${value}`)
3181
+ .join("\n");
3182
+ if (request.methodId === "gateway-bedrock") {
3183
+ return {
3184
+ CLAUDE_CODE_USE_BEDROCK: "1",
3185
+ AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
3186
+ ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
3187
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3188
+ };
3189
+ }
3190
+ return {
3191
+ ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
3192
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3193
+ ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
3194
+ };
3195
+ }
3196
+ /**
3197
+ * Build the list of permission modes the agent will advertise for the given
3198
+ * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
3199
+ * the SDK's model-level availability signal. `undefined`/`false` both exclude
3200
+ * `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
3201
+ */
3202
+ function buildAvailableModes(modelInfo) {
3203
+ const modes = [];
3204
+ // Only advertise "auto" when the SDK reports the model supports it.
3205
+ if (modelInfo?.supportsAutoMode === true) {
3206
+ modes.push({
3207
+ id: "auto",
3208
+ name: "Auto",
3209
+ description: "Use a model classifier to approve/deny permission prompts",
3210
+ });
3211
+ }
3212
+ modes.push({
3213
+ // Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces;
3214
+ // the wire id stays "default" ("manual" is only an accepted input alias).
3215
+ id: "default",
3216
+ name: "Manual",
3217
+ description: "Standard behavior, prompts for dangerous operations",
3218
+ }, {
3219
+ id: "acceptEdits",
3220
+ name: "Accept Edits",
3221
+ description: "Auto-accept file edit operations",
3222
+ }, {
3223
+ id: "plan",
3224
+ name: "Plan Mode",
3225
+ description: "Planning mode, no actual tool execution",
3226
+ }, {
3227
+ id: "dontAsk",
3228
+ name: "Don't Ask",
3229
+ description: "Don't prompt for permissions, deny if not pre-approved",
3230
+ });
3231
+ if (ALLOW_BYPASS) {
3232
+ modes.push({
3233
+ id: "bypassPermissions",
3234
+ name: "Bypass Permissions",
3235
+ description: "Bypass all permission checks",
3236
+ });
3237
+ }
3238
+ return modes;
3239
+ }
3240
+ // Translate a UI effort value into the flag-layer payload. The SDK
3241
+ // shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
3242
+ // and only clears a key when an explicit `null` is sent — see
3243
+ // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
3244
+ // `"default"` sentinel and `undefined` (effort option absent for the model) to
3245
+ // `null` ensures any previously-applied flag is actually cleared.
3246
+ function toSdkEffortLevel(value) {
3247
+ return value === undefined || value === "default" ? null : value;
3248
+ }
3249
+ // `supportedAgents()` always returns Claude Code's built-in subagents — the
3250
+ // ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
3251
+ // has configured none of their own. Those aren't meaningful *main-thread*
3252
+ // personas, so we filter them out and only surface the Agent picker when the
3253
+ // user (or a plugin/project) has configured custom agents. Update this set if
3254
+ // the SDK's built-in roster changes.
3255
+ export const BUILTIN_AGENT_NAMES = new Set([
3256
+ "claude",
3257
+ "general-purpose",
3258
+ "Explore",
3259
+ "Plan",
3260
+ "statusline-setup",
3261
+ ]);
3262
+ // Value of the synthetic "Default" entry in the agent picker, which maps to the
3263
+ // standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
3264
+ // reserved sentinel: a custom agent named exactly this would collide with it
3265
+ // (two options sharing the value, selection silently routing to `null`), so we
3266
+ // exclude that name from discovery.
3267
+ export const DEFAULT_AGENT_ID = "default";
3268
+ /** Discover user/plugin/project-configured main-thread agents, excluding the
3269
+ * built-in subagents and the reserved "default" sentinel. Returns an empty
3270
+ * list if discovery fails so a flaky control request never blocks session
3271
+ * creation. */
3272
+ export async function discoverCustomAgents(q) {
3273
+ try {
3274
+ const agents = await q.supportedAgents();
3275
+ return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
3276
+ }
3277
+ catch {
3278
+ return [];
3279
+ }
3280
+ }
3281
+ /** Stable ids for the session config options surfaced via `configOptions`.
3282
+ * Centralized so the option declarations in `buildConfigOptions` and the
3283
+ * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
3284
+ * same identifiers and can't drift apart. */
3285
+ export const MODE_CONFIG_ID = "mode";
3286
+ export const MODEL_CONFIG_ID = "model";
3287
+ export const EFFORT_CONFIG_ID = "effort";
3288
+ export const AGENT_CONFIG_ID = "agent";
3289
+ export const FAST_MODE_CONFIG_ID = "fast";
3290
+ /** Select-fallback values used when the client has not opted into boolean
3291
+ * config options (see {@link createFastModeConfigOption}). */
3292
+ export const FAST_MODE_ON = "on";
3293
+ export const FAST_MODE_OFF = "off";
3294
+ const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
3295
+ /** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle.
3296
+ * `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK
3297
+ * docs) keeps the toggle on so it reflects the user's intent — only an
3298
+ * explicit `off` clears it. */
3299
+ export function fastModeStateEnabled(state) {
3300
+ return state !== "off";
3301
+ }
3302
+ /** Whether the Client advertised support for boolean session config options
3303
+ * (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
3304
+ * config options to Clients that opt in; otherwise we fall back to a `select`.
3305
+ * See https://agentclientprotocol.com/rfds/boolean-config-option. */
3306
+ export function clientSupportsBooleanConfigOptions(clientCapabilities) {
3307
+ return clientCapabilities?.session?.configOptions?.boolean != null;
3308
+ }
3309
+ /** Build the Fast mode config option. When the Client supports boolean config
3310
+ * options we expose a native `type: "boolean"` toggle; otherwise we degrade to
3311
+ * a two-value `select` ("on"/"off") so older Clients still get a usable
3312
+ * control. */
3313
+ export function createFastModeConfigOption(enabled, useBooleanOption) {
3314
+ const base = {
3315
+ id: FAST_MODE_CONFIG_ID,
3316
+ name: "Fast mode",
3317
+ description: FAST_MODE_DESCRIPTION,
3318
+ category: "model_config",
3319
+ };
3320
+ if (useBooleanOption) {
3321
+ return { ...base, type: "boolean", currentValue: enabled };
3322
+ }
3323
+ return {
3324
+ ...base,
3325
+ type: "select",
3326
+ currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
3327
+ options: [
3328
+ { value: FAST_MODE_ON, name: "On" },
3329
+ { value: FAST_MODE_OFF, name: "Off" },
3330
+ ],
3331
+ };
3332
+ }
3333
+ /** Resolve the requested Fast mode value from a `session/set_config_option`
3334
+ * request. Accepts a native boolean (boolean-capable Clients) or the
3335
+ * "on"/"off" select-fallback strings. */
3336
+ export function resolveFastModeEnabled(params) {
3337
+ const value = params.value;
3338
+ if (typeof value === "boolean") {
3339
+ return value;
3340
+ }
3341
+ if (value === FAST_MODE_ON) {
3342
+ return true;
3343
+ }
3344
+ if (value === FAST_MODE_OFF) {
3345
+ return false;
3346
+ }
3347
+ throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`);
3348
+ }
3349
+ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
3350
+ const options = [
3351
+ {
3352
+ id: MODE_CONFIG_ID,
3353
+ name: "Mode",
3354
+ description: "Session permission mode",
3355
+ category: "mode",
3356
+ type: "select",
3357
+ currentValue: modes.currentModeId,
3358
+ options: modes.availableModes.map((m) => ({
3359
+ value: m.id,
3360
+ name: m.name,
3361
+ description: m.description,
3362
+ })),
3363
+ },
3364
+ {
3365
+ id: MODEL_CONFIG_ID,
3366
+ name: "Model",
3367
+ description: "AI model to use",
3368
+ category: "model",
3369
+ type: "select",
3370
+ currentValue: models.currentModelId,
3371
+ options: models.availableModels.map((m) => ({
3372
+ value: m.modelId,
3373
+ name: m.name,
3374
+ description: m.description ?? undefined,
3375
+ })),
3376
+ },
3377
+ ];
3378
+ // Add effort level option based on the currently selected model
3379
+ const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId);
3380
+ const supportedLevels = currentModelInfo?.supportsEffort
3381
+ ? (currentModelInfo.supportedEffortLevels ?? [])
3382
+ : [];
3383
+ if (supportedLevels.length > 0) {
3384
+ const effortOptions = [
3385
+ { value: "default", name: "Default" },
3386
+ ...supportedLevels.map((level) => ({
3387
+ value: level,
3388
+ name: level
3389
+ .split(/[_-]/)
3390
+ .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
3391
+ .join(" "),
3392
+ })),
3393
+ ];
3394
+ const includes = (l) => l === "default" || supportedLevels.includes(l);
3395
+ const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
3396
+ options.push({
3397
+ id: EFFORT_CONFIG_ID,
3398
+ name: "Effort",
3399
+ description: "Available effort levels for this model",
3400
+ category: "thought_level",
3401
+ type: "select",
3402
+ currentValue: validEffort,
3403
+ options: effortOptions,
3404
+ });
3405
+ }
3406
+ // Surface the Fast mode toggle only when the current model supports it. The
3407
+ // option renders as a native boolean toggle for Clients that opted in, and a
3408
+ // two-value select otherwise.
3409
+ if (fastMode?.supported) {
3410
+ options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption));
3411
+ }
3412
+ // Only surface the Agent picker when there's a real choice — i.e. the user
3413
+ // has configured at least one custom agent (built-ins are filtered out in
3414
+ // discoverCustomAgents). With none configured, "Default" would be the only
3415
+ // entry, so we omit the option entirely.
3416
+ if (agents.length > 0) {
3417
+ options.push({
3418
+ id: AGENT_CONFIG_ID,
3419
+ name: "Agent",
3420
+ description: "Main-thread agent persona",
3421
+ type: "select",
3422
+ currentValue: currentAgent,
3423
+ options: [
3424
+ { value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
3425
+ ...agents.map((a) => ({
3426
+ value: a.name,
3427
+ name: a.name,
3428
+ description: a.description || undefined,
3429
+ })),
3430
+ ],
3431
+ });
3432
+ }
3433
+ return options;
3434
+ }
3435
+ // Claude Code CLI persists display strings like "opus[1m]" in settings,
3436
+ // but the SDK model list uses IDs like "claude-opus-4-6-1m".
3437
+ const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
3438
+ // Captures a model family version: `4-6`/`4.7` for dated generations, or a
3439
+ // bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
3440
+ // `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
3441
+ const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
3442
+ function extractModelFamilyVersion(s) {
3443
+ // Strip "[1m]"-style context hints first — that digit is context window
3444
+ // size, not a model generation version.
3445
+ const match = s.replace(/\[\d+m\]/gi, "").match(MODEL_FAMILY_VERSION_PATTERN);
3446
+ if (!match)
3447
+ return null;
3448
+ return match[2] ? `${match[1]}.${match[2]}` : match[1];
3449
+ }
3450
+ function modelVersionsCompatible(preference, candidate) {
3451
+ const preferred = extractModelFamilyVersion(preference);
3452
+ if (!preferred)
3453
+ return true;
3454
+ const candidateVersion = extractModelFamilyVersion(candidate.value) ??
3455
+ extractModelFamilyVersion(candidate.displayName) ??
3456
+ extractModelFamilyVersion(candidate.description);
3457
+ if (!candidateVersion)
3458
+ return true;
3459
+ return preferred === candidateVersion;
3460
+ }
3461
+ function tokenizeModelPreference(model) {
3462
+ const lower = model.trim().toLowerCase();
3463
+ const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
3464
+ const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ");
3465
+ const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
3466
+ const tokens = rawTokens
3467
+ .map((token) => {
3468
+ if (token === "opusplan")
3469
+ return "opus";
3470
+ if (token === "best" || token === "default")
3471
+ return "";
3472
+ return token;
3473
+ })
3474
+ .filter((token) => token && token !== "claude")
3475
+ .filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
3476
+ return { tokens, contextHint };
3477
+ }
3478
+ function scoreModelMatch(model, tokens, contextHint) {
3479
+ const haystack = `${model.value} ${model.displayName}`.toLowerCase();
3480
+ let score = 0;
3481
+ let nonHintMatched = false;
3482
+ for (const token of tokens) {
3483
+ if (haystack.includes(token)) {
3484
+ if (token !== contextHint)
3485
+ nonHintMatched = true;
3486
+ score += token === contextHint ? 3 : 1;
3487
+ }
3488
+ }
3489
+ if (contextHint && !nonHintMatched)
3490
+ return 0;
3491
+ return score;
3492
+ }
3493
+ export function resolveModelPreference(models, preference) {
3494
+ const trimmed = preference.trim();
3495
+ if (!trimmed)
3496
+ return null;
3497
+ const lower = trimmed.toLowerCase();
3498
+ // Exact match on value or display name
3499
+ const directMatch = models.find((model) => model.value === trimmed ||
3500
+ model.value.toLowerCase() === lower ||
3501
+ model.displayName.toLowerCase() === lower);
3502
+ if (directMatch)
3503
+ return directMatch;
3504
+ // Exact match on the alias's canonical resolved id (e.g. a pinned
3505
+ // "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
3506
+ // reported and unambiguous, so it's tried before the fuzzier tiers below.
3507
+ // "default" is skipped first since it shares a resolvedModel with
3508
+ // whichever alias the CLI currently recommends — a specific pin should
3509
+ // land on that named alias, not "default".
3510
+ const resolvedMatch = models.find((model) => model.value !== "default" && model.resolvedModel?.toLowerCase() === lower) ?? models.find((model) => model.resolvedModel?.toLowerCase() === lower);
3511
+ if (resolvedMatch)
3512
+ return resolvedMatch;
3513
+ // Substring match
3514
+ const includesMatch = models.find((model) => {
3515
+ if (!modelVersionsCompatible(trimmed, model))
3516
+ return false;
3517
+ const value = model.value.toLowerCase();
3518
+ const display = model.displayName.toLowerCase();
3519
+ return value.includes(lower) || display.includes(lower) || lower.includes(value);
3520
+ });
3521
+ if (includesMatch)
3522
+ return includesMatch;
3523
+ // Tokenized matching for aliases like "opus[1m]"
3524
+ const { tokens, contextHint } = tokenizeModelPreference(trimmed);
3525
+ if (tokens.length === 0)
3526
+ return null;
3527
+ let bestMatch = null;
3528
+ let bestScore = 0;
3529
+ for (const model of models) {
3530
+ if (!modelVersionsCompatible(trimmed, model))
3531
+ continue;
3532
+ const score = scoreModelMatch(model, tokens, contextHint);
3533
+ if (0 < score && (!bestMatch || bestScore < score)) {
3534
+ bestMatch = model;
3535
+ bestScore = score;
3536
+ }
3537
+ }
3538
+ return bestMatch;
3539
+ }
3540
+ function resolveSettingsModel(models, settingsModel, logger) {
3541
+ if (settingsModel === undefined) {
3542
+ return null;
3543
+ }
3544
+ if (typeof settingsModel !== "string") {
3545
+ const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
3546
+ logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
3547
+ return null;
3548
+ }
3549
+ return resolveModelPreference(models, settingsModel);
3550
+ }
3551
+ /**
3552
+ * Restrict the SDK's model list to the user's `availableModels` allowlist
3553
+ * (already merged-and-deduped across settings sources by `SettingsManager`).
3554
+ * The user's exact entries become the model IDs surfaced via configOptions
3555
+ * and passed to `setModel`, which prevents Claude Code from silently
3556
+ * substituting a date-pinned variant (e.g. `haiku` →
3557
+ * `claude-haiku-4-5-20251001`) that the user may not have access to.
3558
+ *
3559
+ * Display info and capability flags are copied from the closest SDK match so
3560
+ * the UI still renders sensible names and effort levels.
3561
+ *
3562
+ * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
3563
+ * - `undefined` is handled by the caller (no allowlist applied).
3564
+ * - The Default option is unaffected by `availableModels` — it always remains
3565
+ * available, even when the allowlist is `[]`.
3566
+ */
3567
+ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides) {
3568
+ // Default is always preserved per the docs. Synthesize one if the SDK
3569
+ // didn't surface it so downstream code (e.g. `getAvailableModels` picking
3570
+ // `models[0]` as a fallback) still has something to work with.
3571
+ const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
3572
+ value: "default",
3573
+ displayName: "Default",
3574
+ description: "",
3575
+ };
3576
+ const result = [defaultModel];
3577
+ const seen = new Set([defaultModel.value]);
3578
+ const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
3579
+ // Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6")
3580
+ // in availableModels but require provider-specific IDs at the API. We still
3581
+ // resolve `sdkMatch` against the alias (`trimmed`) — that's what the
3582
+ // matching heuristics above are built for, and override targets (ARNs,
3583
+ // opaque provider IDs) often won't textually resemble anything in
3584
+ // `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
3585
+ // override target, so it's what `setModel` ends up passing to the API.
3586
+ for (const entry of allowlist) {
3587
+ const trimmed = entry.trim();
3588
+ if (!trimmed || seen.has(trimmed))
3589
+ continue;
3590
+ const overridden = settingsModelOverrides?.[trimmed];
3591
+ const effective = overridden ?? trimmed;
3592
+ if (seen.has(effective))
3593
+ continue;
3594
+ const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
3595
+ if (sdkMatch) {
3596
+ result.push({ ...sdkMatch, value: effective });
3597
+ }
3598
+ else {
3599
+ result.push({ value: effective, displayName: trimmed, description: "" });
3600
+ }
3601
+ seen.add(effective);
3602
+ }
3603
+ // The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
3604
+ // allowlist, the same way Default is. Per the model-config docs it adds an
3605
+ // entry "without replacing the built-in aliases" and "appears at the bottom of
3606
+ // the /model picker", so we append it last and skip the allowlist filter; this
3607
+ // keeps a slim alias allowlist from hiding the custom model row.
3608
+ // https://code.claude.com/docs/en/model-config#add-a-custom-model-option
3609
+ const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
3610
+ if (customModelOption && !seen.has(customModelOption)) {
3611
+ const customModel = sdkModels.find((m) => m.value === customModelOption);
3612
+ if (customModel) {
3613
+ result.push(customModel);
3614
+ seen.add(customModel.value);
3615
+ }
3616
+ }
3617
+ return result;
3618
+ }
3619
+ async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
3620
+ const settings = settingsManager.getSettings();
3621
+ let currentModel = models[0];
3622
+ let resolvedFromInput;
3623
+ // Model priority (highest to lowest):
3624
+ // 1. ANTHROPIC_MODEL environment variable
3625
+ // 2. settings.model (user configuration)
3626
+ // 3. models[0] (default first model)
3627
+ if (process.env.ANTHROPIC_MODEL) {
3628
+ const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
3629
+ if (match) {
3630
+ currentModel = match;
3631
+ resolvedFromInput = process.env.ANTHROPIC_MODEL;
3632
+ }
3633
+ }
3634
+ else if (typeof settings.model === "string") {
3635
+ const match = resolveSettingsModel(models, settings.model, logger);
3636
+ if (match) {
3637
+ currentModel = match;
3638
+ resolvedFromInput = settings.model;
3639
+ }
3640
+ }
3641
+ // Skip the setModel round-trip when we can prove the SDK has already landed
3642
+ // on the same model. Two cases qualify:
3643
+ // (a) No override applied — currentModel stayed at models[0]; the SDK is on
3644
+ // its own default and we have nothing to sync.
3645
+ // (b) The resolver returned the user's input verbatim AND that value exists
3646
+ // in the SDK's original model list — meaning no fuzzy match or
3647
+ // allowlist rewrite was involved, and the SDK (which reads the same
3648
+ // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
3649
+ // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
3650
+ // setModel call so we don't drift from the user's intended pin.
3651
+ const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
3652
+ const skipSetModel = resolvedFromInput === undefined ||
3653
+ (currentModel.value === resolvedFromInput && sdkSawSameValue);
3654
+ if (!skipSetModel) {
3655
+ await query.setModel(currentModel.value);
3656
+ }
3657
+ return {
3658
+ availableModels: models.map((model) => ({
3659
+ modelId: model.value,
3660
+ name: model.displayName,
3661
+ description: model.description,
3662
+ })),
3663
+ currentModelId: currentModel.value,
3664
+ };
3665
+ }
3666
+ function getAvailableSlashCommands(commands) {
3667
+ const UNSUPPORTED_COMMANDS = [
3668
+ "clear",
3669
+ "cost",
3670
+ "keybindings-help",
3671
+ "login",
3672
+ "logout",
3673
+ "output-style:new",
3674
+ "release-notes",
3675
+ "todos",
3676
+ ];
3677
+ return commands
3678
+ .map((command) => {
3679
+ const input = command.argumentHint
3680
+ ? {
3681
+ hint: Array.isArray(command.argumentHint)
3682
+ ? command.argumentHint.join(" ")
3683
+ : command.argumentHint,
3684
+ }
3685
+ : null;
3686
+ let name = command.name;
3687
+ if (command.name.endsWith(" (MCP)")) {
3688
+ name = `mcp:${name.replace(" (MCP)", "")}`;
3689
+ }
3690
+ return {
3691
+ name,
3692
+ description: command.description || "",
3693
+ input,
3694
+ };
3695
+ })
3696
+ .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
3697
+ }
3698
+ function formatUriAsLink(uri) {
3699
+ try {
3700
+ if (uri.startsWith("file://")) {
3701
+ const path = uri.slice(7); // Remove "file://"
3702
+ const name = path.split("/").pop() || path;
3703
+ return `[@${name}](${uri})`;
3704
+ }
3705
+ else if (uri.startsWith("zed://")) {
3706
+ const parts = uri.split("/");
3707
+ const name = parts[parts.length - 1] || uri;
3708
+ return `[@${name}](${uri})`;
3709
+ }
3710
+ return uri;
3711
+ }
3712
+ catch {
3713
+ return uri;
3714
+ }
3715
+ }
3716
+ export function promptToClaude(prompt) {
3717
+ const content = [];
3718
+ const context = [];
3719
+ for (const chunk of prompt.prompt) {
3720
+ switch (chunk.type) {
3721
+ case "text": {
3722
+ let text = chunk.text;
3723
+ // change /mcp:server:command args -> /server:command (MCP) args
3724
+ const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/);
3725
+ if (mcpMatch) {
3726
+ const [, server, command, args] = mcpMatch;
3727
+ text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
3728
+ }
3729
+ content.push({ type: "text", text });
3730
+ break;
3731
+ }
3732
+ case "resource_link": {
3733
+ const formattedUri = formatUriAsLink(chunk.uri);
3734
+ content.push({
3735
+ type: "text",
3736
+ text: formattedUri,
3737
+ });
3738
+ break;
3739
+ }
3740
+ case "resource": {
3741
+ if ("text" in chunk.resource) {
3742
+ const formattedUri = formatUriAsLink(chunk.resource.uri);
3743
+ content.push({
3744
+ type: "text",
3745
+ text: formattedUri,
3746
+ });
3747
+ context.push({
3748
+ type: "text",
3749
+ text: `\n<context ref="${chunk.resource.uri}">\n${chunk.resource.text}\n</context>`,
3750
+ });
3751
+ }
3752
+ // Ignore blob resources (unsupported)
3753
+ break;
3754
+ }
3755
+ case "image":
3756
+ if (chunk.data) {
3757
+ content.push({
3758
+ type: "image",
3759
+ source: {
3760
+ type: "base64",
3761
+ data: chunk.data,
3762
+ media_type: chunk.mimeType,
3763
+ },
3764
+ });
3765
+ }
3766
+ else if (chunk.uri && chunk.uri.startsWith("http")) {
3767
+ content.push({
3768
+ type: "image",
3769
+ source: {
3770
+ type: "url",
3771
+ url: chunk.uri,
3772
+ },
3773
+ });
3774
+ }
3775
+ break;
3776
+ // Ignore audio and other unsupported types
3777
+ default:
3778
+ break;
3779
+ }
3780
+ }
3781
+ content.push(...context);
3782
+ return {
3783
+ type: "user",
3784
+ message: {
3785
+ role: "user",
3786
+ content: content,
3787
+ },
3788
+ session_id: prompt.sessionId,
3789
+ parent_tool_use_id: null,
3790
+ };
3791
+ }
3792
+ /**
3793
+ * Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
3794
+ * transcript message (replay) so chunk grouping is identical in both views.
3795
+ *
3796
+ * Assistant turns are keyed by the Anthropic API message id (`message.id`),
3797
+ * which is identical at `message_start`, on the consolidated assistant message,
3798
+ * and in the persisted transcript — unlike the per-`stream_event` uuid, which is
3799
+ * unique per event and never persisted. User messages have no API id, but they
3800
+ * are never streamed, so their (stable) SDK uuid is used instead. ACP message
3801
+ * ids are opaque strings, so no particular format is required.
3802
+ */
3803
+ export function messageIdForGrouping(message) {
3804
+ if (message.type === "assistant") {
3805
+ const inner = message.message;
3806
+ const apiId = inner && typeof inner === "object" && "id" in inner
3807
+ ? inner.id
3808
+ : undefined;
3809
+ if (typeof apiId === "string" && apiId.length > 0) {
3810
+ return apiId;
3811
+ }
3812
+ }
3813
+ return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
3814
+ }
3815
+ /**
3816
+ * Stamps an ACP `messageId` onto a session update, but only on the message/
3817
+ * thought chunk variants that carry one — tool_call/plan/etc. updates never do.
3818
+ * No-op when `messageId` is falsy, so callers can pass it through unconditionally.
3819
+ */
3820
+ function applyMessageId(update, messageId) {
3821
+ if (messageId &&
3822
+ (update.sessionUpdate === "agent_message_chunk" ||
3823
+ update.sessionUpdate === "user_message_chunk" ||
3824
+ update.sessionUpdate === "agent_thought_chunk")) {
3825
+ update.messageId = messageId;
3826
+ }
3827
+ }
3828
+ /** Built-in tools that drive the task list (headless/SDK sessions use these
3829
+ * instead of TodoWrite). Their tool_use/tool_result are surfaced as `plan`
3830
+ * snapshots rather than as tool_calls. */
3831
+ function isTaskTool(toolName) {
3832
+ return (toolName === "TaskCreate" ||
3833
+ toolName === "TaskUpdate" ||
3834
+ toolName === "TaskList" ||
3835
+ toolName === "TaskGet");
3836
+ }
3837
+ /** Whether a tool's tool_use surfaces to the client as a standalone
3838
+ * `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
3839
+ * suppressed (their plan snapshot is emitted at tool_result time), so neither
3840
+ * produces a tool_call. */
3841
+ function shouldEmitToolCall(toolName) {
3842
+ return toolName !== "TodoWrite" && !isTaskTool(toolName);
3843
+ }
3844
+ /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
3845
+ * notification for a tool_use. Shared by every site that surfaces a tool call:
3846
+ * the streamed tool_use path (first encounter → tool_call, later encounter →
3847
+ * refine) and the permission flow (`ensureToolCallEmitted`), so they can't
3848
+ * drift. The initial `tool_call` carries `status: "pending"` and, for Bash, the
3849
+ * `terminal_info` _meta that the later `terminal_output`/`terminal_exit`
3850
+ * updates key off of; a refining `tool_call_update` carries neither. */
3851
+ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
3852
+ if (refine) {
3853
+ return {
3854
+ _meta: { claudeCode: { toolName: toolUse.name } },
3855
+ toolCallId: toolUse.id,
3856
+ sessionUpdate: "tool_call_update",
3857
+ rawInput,
3858
+ ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
3859
+ };
3860
+ }
3861
+ return {
3862
+ _meta: {
3863
+ claudeCode: { toolName: toolUse.name },
3864
+ ...(toolUse.name === "Bash" && supportsTerminalOutput
3865
+ ? { terminal_info: { terminal_id: toolUse.id } }
3866
+ : {}),
3867
+ },
3868
+ toolCallId: toolUse.id,
3869
+ sessionUpdate: "tool_call",
3870
+ rawInput,
3871
+ status: "pending",
3872
+ ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
3873
+ };
3874
+ }
3875
+ /**
3876
+ * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
3877
+ * Only handles text, image, and thinking chunks for now.
3878
+ */
3879
+ export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger, options) {
3880
+ const taskState = options?.taskState ?? new Map();
3881
+ const registerHooks = options?.registerHooks !== false;
3882
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
3883
+ if (typeof content === "string") {
3884
+ if (content.length === 0) {
3885
+ return [];
3886
+ }
3887
+ const update = {
3888
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
3889
+ content: {
3890
+ type: "text",
3891
+ text: content,
3892
+ },
3893
+ };
3894
+ applyMessageId(update, options?.messageId);
3895
+ if (options?.parentToolUseId) {
3896
+ update._meta = {
3897
+ ...update._meta,
3898
+ claudeCode: {
3899
+ ...(update._meta?.claudeCode || {}),
3900
+ parentToolUseId: options.parentToolUseId,
3901
+ },
3902
+ };
3903
+ }
3904
+ return [{ sessionId, update }];
3905
+ }
3906
+ const output = [];
3907
+ // Only handle the first chunk for streaming; extend as needed for batching
3908
+ for (const chunk of content) {
3909
+ let update = null;
3910
+ switch (chunk.type) {
3911
+ case "text":
3912
+ case "text_delta":
3913
+ if (chunk.text.length > 0) {
3914
+ update = {
3915
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
3916
+ content: {
3917
+ type: "text",
3918
+ text: chunk.text,
3919
+ },
3920
+ };
3921
+ }
3922
+ break;
3923
+ case "image":
3924
+ update = {
3925
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
3926
+ content: {
3927
+ type: "image",
3928
+ data: chunk.source.type === "base64" ? chunk.source.data : "",
3929
+ mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
3930
+ uri: chunk.source.type === "url" ? chunk.source.url : undefined,
3931
+ },
3932
+ };
3933
+ break;
3934
+ case "thinking":
3935
+ case "thinking_delta":
3936
+ // Recent models default `thinking.display` to "omitted", which streams
3937
+ // signature-only thinking blocks whose text is empty.
3938
+ if (chunk.thinking.length > 0) {
3939
+ update = {
3940
+ sessionUpdate: "agent_thought_chunk",
3941
+ content: {
3942
+ type: "text",
3943
+ text: chunk.thinking,
3944
+ },
3945
+ };
3946
+ }
3947
+ break;
3948
+ case "tool_use":
3949
+ case "server_tool_use":
3950
+ case "mcp_tool_use": {
3951
+ const alreadyCached = chunk.id in toolUseCache;
3952
+ toolUseCache[chunk.id] = chunk;
3953
+ if (chunk.name === "TodoWrite") {
3954
+ // @ts-expect-error - sometimes input is empty object or undefined
3955
+ if (Array.isArray(chunk.input?.todos)) {
3956
+ update = {
3957
+ sessionUpdate: "plan",
3958
+ entries: planEntries(chunk.input),
3959
+ };
3960
+ }
3961
+ }
3962
+ else if (isTaskTool(chunk.name)) {
3963
+ // Task* tool_use is suppressed; the plan update is emitted at
3964
+ // tool_result time once we have the task ID (for TaskCreate) and
3965
+ // confirmation that the change took effect.
3966
+ }
3967
+ else {
3968
+ // Only register hooks on first encounter to avoid double-firing
3969
+ if (registerHooks && !alreadyCached) {
3970
+ // Capture the tool name in the closure rather than re-reading the
3971
+ // cache when the hook fires. The cache entry is pruned at
3972
+ // tool_result time, and a PostToolUse hook can fire after that, so
3973
+ // closing over the name keeps the diff working without depending on
3974
+ // (or pinning) the cache entry's lifetime.
3975
+ const toolName = chunk.name;
3976
+ registerHookCallback(chunk.id, {
3977
+ onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
3978
+ // Both `Edit` and `Write` produce a structuredPatch in their
3979
+ // PostToolUse tool_response. For Edit the diff replaces the
3980
+ // optimistic content built at tool_use time. For Write the
3981
+ // optimistic content (built from `input.content` alone with
3982
+ // `oldText: null`) shows "creation" semantics regardless of
3983
+ // whether the file existed; the structuredPatch from the
3984
+ // hook lets us emit the real diff for `type: "update"`. The
3985
+ // helper returns `{}` if the response shape isn't usable.
3986
+ const editDiff = toolName === "Edit" || toolName === "Write"
3987
+ ? toolUpdateFromDiffToolResponse(toolResponse)
3988
+ : {};
3989
+ const update = {
3990
+ _meta: {
3991
+ claudeCode: {
3992
+ toolResponse,
3993
+ toolName,
3994
+ },
3995
+ },
3996
+ toolCallId: toolUseId,
3997
+ sessionUpdate: "tool_call_update",
3998
+ ...editDiff,
3999
+ };
4000
+ await client.sessionUpdate({
4001
+ sessionId,
4002
+ update,
4003
+ });
4004
+ },
4005
+ });
4006
+ }
4007
+ let rawInput;
4008
+ try {
4009
+ rawInput = JSON.parse(JSON.stringify(chunk.input));
4010
+ }
4011
+ catch {
4012
+ // ignore if we can't turn it to JSON
4013
+ }
4014
+ // Emit a `tool_call` only the first time this id surfaces to the
4015
+ // client; afterwards refine it with a `tool_call_update`. The first
4016
+ // surface may be this stream chunk OR an earlier permission request
4017
+ // (see `ensureToolCallEmitted`), so emission is tracked separately
4018
+ // from `toolUseCache`. Without an `emittedToolCalls` set we fall back
4019
+ // to cache presence — the historical streaming-only behavior.
4020
+ const emittedToolCalls = options?.emittedToolCalls;
4021
+ const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
4022
+ emittedToolCalls?.add(chunk.id);
4023
+ if (alreadyEmitted) {
4024
+ // Already surfaced (full assistant message after streaming, or a
4025
+ // permission request emitted it first) — refine with a
4026
+ // tool_call_update rather than emitting a duplicate tool_call.
4027
+ update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd, true);
4028
+ }
4029
+ else {
4030
+ // First surface (streaming content_block_start or replay) — send as
4031
+ // tool_call (with terminal_info for Bash).
4032
+ update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd);
4033
+ }
4034
+ }
4035
+ break;
4036
+ }
4037
+ case "tool_result":
4038
+ case "tool_search_tool_result":
4039
+ case "web_fetch_tool_result":
4040
+ case "web_search_tool_result":
4041
+ case "code_execution_tool_result":
4042
+ case "bash_code_execution_tool_result":
4043
+ case "text_editor_code_execution_tool_result":
4044
+ case "mcp_tool_result": {
4045
+ options?.emittedToolCalls?.delete(chunk.tool_use_id);
4046
+ const toolUse = toolUseCache[chunk.tool_use_id];
4047
+ if (!toolUse) {
4048
+ logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
4049
+ break;
4050
+ }
4051
+ if (isTaskTool(toolUse.name)) {
4052
+ // Headless/SDK sessions emit Task* tools instead of TodoWrite.
4053
+ // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
4054
+ // and TaskGet are read-only so we just suppress their tool_call /
4055
+ // tool_result events. The plan update is emitted as a snapshot of
4056
+ // the accumulated state, mirroring the legacy TodoWrite behavior.
4057
+ const isError = "is_error" in chunk && chunk.is_error;
4058
+ if (!isError) {
4059
+ if (toolUse.name === "TaskCreate") {
4060
+ applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
4061
+ }
4062
+ else if (toolUse.name === "TaskUpdate") {
4063
+ applyTaskUpdate(taskState, toolUse.input);
4064
+ }
4065
+ }
4066
+ if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
4067
+ update = {
4068
+ sessionUpdate: "plan",
4069
+ entries: taskStateToPlanEntries(taskState),
4070
+ };
4071
+ }
4072
+ }
4073
+ else if (toolUse.name !== "TodoWrite") {
4074
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
4075
+ // When terminal output is supported, send terminal_output as a
4076
+ // separate notification to match codex-acp's streaming lifecycle:
4077
+ // 1. tool_call → _meta.terminal_info (already sent above)
4078
+ // 2. tool_call_update → _meta.terminal_output (sent here)
4079
+ // 3. tool_call_update → _meta.terminal_exit (sent below with status)
4080
+ if (toolMeta?.terminal_output) {
4081
+ output.push({
4082
+ sessionId,
4083
+ update: {
4084
+ _meta: {
4085
+ terminal_output: toolMeta.terminal_output,
4086
+ ...(options?.parentToolUseId
4087
+ ? { claudeCode: { parentToolUseId: options.parentToolUseId } }
4088
+ : {}),
4089
+ },
4090
+ toolCallId: chunk.tool_use_id,
4091
+ sessionUpdate: "tool_call_update",
4092
+ },
4093
+ });
4094
+ }
4095
+ update = {
4096
+ _meta: {
4097
+ claudeCode: {
4098
+ toolName: toolUse.name,
4099
+ },
4100
+ ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
4101
+ },
4102
+ toolCallId: chunk.tool_use_id,
4103
+ sessionUpdate: "tool_call_update",
4104
+ status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
4105
+ rawOutput: chunk.content,
4106
+ ...toolUpdate,
4107
+ };
4108
+ }
4109
+ // The tool_use is fully resolved now — drop it so a long session doesn't
4110
+ // retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
4111
+ // over the tool name and no longer reads the cache, so pruning here is
4112
+ // safe regardless of hook/result ordering.
4113
+ delete toolUseCache[chunk.tool_use_id];
4114
+ break;
4115
+ }
4116
+ case "document":
4117
+ case "search_result":
4118
+ case "redacted_thinking":
4119
+ case "input_json_delta":
4120
+ case "citations_delta":
4121
+ case "signature_delta":
4122
+ case "container_upload":
4123
+ case "compaction":
4124
+ case "compaction_delta":
4125
+ case "advisor_tool_result":
4126
+ case "mid_conv_system":
4127
+ case "fallback":
4128
+ break;
4129
+ default:
4130
+ unreachable(chunk, logger);
4131
+ break;
4132
+ }
4133
+ if (update) {
4134
+ if (options?.parentToolUseId) {
4135
+ update._meta = {
4136
+ ...update._meta,
4137
+ claudeCode: {
4138
+ ...(update._meta?.claudeCode || {}),
4139
+ parentToolUseId: options.parentToolUseId,
4140
+ },
4141
+ };
4142
+ }
4143
+ applyMessageId(update, options?.messageId);
4144
+ output.push({ sessionId, update });
4145
+ }
4146
+ }
4147
+ return output;
4148
+ }
4149
+ export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
4150
+ const event = message.event;
4151
+ switch (event.type) {
4152
+ case "content_block_start":
4153
+ return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
4154
+ clientCapabilities: options?.clientCapabilities,
4155
+ parentToolUseId: message.parent_tool_use_id,
4156
+ cwd: options?.cwd,
4157
+ taskState: options?.taskState,
4158
+ emittedToolCalls: options?.emittedToolCalls,
4159
+ messageId: options?.messageId,
4160
+ });
4161
+ case "content_block_delta":
4162
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
4163
+ clientCapabilities: options?.clientCapabilities,
4164
+ parentToolUseId: message.parent_tool_use_id,
4165
+ cwd: options?.cwd,
4166
+ taskState: options?.taskState,
4167
+ emittedToolCalls: options?.emittedToolCalls,
4168
+ messageId: options?.messageId,
4169
+ });
4170
+ // No content. `ping` is a Messages-API keep-alive event that the SDK's
4171
+ // `BetaRawMessageStreamEvent` union doesn't include even though the
4172
+ // wire format emits it; the `as never` cast lets us no-op it here
4173
+ // instead of letting it fall through to `unreachable`.
4174
+ case "ping":
4175
+ case "message_start":
4176
+ case "message_delta":
4177
+ case "message_stop":
4178
+ case "content_block_stop":
4179
+ return [];
4180
+ default:
4181
+ unreachable(event, logger);
4182
+ return [];
4183
+ }
4184
+ }
4185
+ /** Run a `session/prompt` while honoring `$/cancel_request` for it. ACP clients
4186
+ * normally stop a turn with the `session/cancel` notification, but `signal`
4187
+ * (the prompt request's abort signal) also fires when the client sends the
4188
+ * generic `$/cancel_request` for this prompt — the protocol's complementary
4189
+ * cancellation fallback. Route that to the same `agent.cancel` path so a client
4190
+ * using only the generic mechanism still stops the turn (and the prompt
4191
+ * resolves "cancelled" instead of running to completion).
4192
+ *
4193
+ * The listener is scoped to this call: once the prompt settles it is removed,
4194
+ * so a later teardown-time abort of the (per-request) signal can't cancel a
4195
+ * subsequent turn. `signal` also aborts on connection close, in which case
4196
+ * cancelling the in-flight turn is the desired behavior anyway. */
4197
+ export async function runPromptWithCancellation(agent, params, signal) {
4198
+ const onAbort = () => {
4199
+ // Fire-and-forget: nothing awaits this listener, so swallow (and log) any
4200
+ // rejection rather than surfacing it as an unhandled rejection.
4201
+ agent.cancel({ sessionId: params.sessionId }).catch((error) => {
4202
+ agent.logger.error(`Failed to cancel prompt via $/cancel_request: ${error}`);
4203
+ });
4204
+ };
4205
+ signal.addEventListener("abort", onAbort, { once: true });
4206
+ try {
4207
+ return await agent.prompt(params);
4208
+ }
4209
+ finally {
4210
+ signal.removeEventListener("abort", onAbort);
4211
+ }
4212
+ }
4213
+ export function runAcp() {
4214
+ const input = nodeToWebWritable(process.stdout);
4215
+ const output = nodeToWebReadable(process.stdin);
4216
+ const stream = ndJsonStream(input, output);
4217
+ // `connect(...)` returns a connection-scoped peer handle (`connection.client`)
4218
+ // that stays valid for the whole connection, so the agent captures it once.
4219
+ // Handlers close over `agent`, which is assigned synchronously right after
4220
+ // `connect()` returns — before the connection processes any inbound message.
4221
+ // It cannot be `const`: its value depends on `connection.client`, which does
4222
+ // not exist until `connect()` has been called.
4223
+ // eslint-disable-next-line prefer-const
4224
+ let agent;
4225
+ const connection = acpAgent({ name: agentName })
4226
+ .onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
4227
+ .onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
4228
+ .onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params))
4229
+ .onRequest(methods.agent.session.fork, (ctx) => agent.unstable_forkSession(ctx.params))
4230
+ .onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
4231
+ .onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
4232
+ .onRequest(methods.agent.session.resume, (ctx) => agent.resumeSession(ctx.params))
4233
+ .onRequest(methods.agent.session.close, (ctx) => agent.closeSession(ctx.params))
4234
+ .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
4235
+ .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
4236
+ .onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
4237
+ .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
4238
+ .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
4239
+ .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
4240
+ .connect(stream);
4241
+ agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
4242
+ return { connection, agent };
4243
+ }
4244
+ function commonPrefixLength(a, b) {
4245
+ let i = 0;
4246
+ while (i < a.length && i < b.length && a[i] === b[i]) {
4247
+ i++;
4248
+ }
4249
+ return i;
4250
+ }
4251
+ /** Best-effort first guess of a model's context window, used only as a
4252
+ * fallback when the SDK's authoritative `getContextUsage` is unavailable (and
4253
+ * until a `result` message arrives with the `modelUsage` value).
4254
+ *
4255
+ * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
4256
+ * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
4257
+ * matching things like "10m" or embedded substrings. Semantic aliases like
4258
+ * `default` carry no such token in the ID, but the SDK's human-facing
4259
+ * `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
4260
+ * pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
4261
+ * `ModelInfo` exposes no structured context-window field, so this text scan is
4262
+ * the only pre-`result` signal available. A miss falls back to the default
4263
+ * window and is corrected by `result.modelUsage` within one turn. */
4264
+ function inferContextWindowFromModel(...texts) {
4265
+ if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
4266
+ return 1_000_000;
4267
+ return null;
4268
+ }
4269
+ /** Fetch the SDK's authoritative context-window occupancy via the
4270
+ * `getContextUsage` control request. Unlike the per-message API usage numbers
4271
+ * (which only count message tokens), this `totalTokens` includes the system
4272
+ * prompt, tool schemas, MCP tools, and memory-file overhead — the real
4273
+ * occupancy the user sees. Returns `null` on any control-request failure.
4274
+ *
4275
+ * Note: we deliberately do NOT use this response's window fields for `size`.
4276
+ * They have been observed to under-report extended (1M) context windows, so
4277
+ * the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
4278
+ * which handle the 1M variants correctly. */
4279
+ async function fetchContextUsedTokens(query, logger) {
4280
+ try {
4281
+ const usage = await query.getContextUsage();
4282
+ return usage.totalTokens;
4283
+ }
4284
+ catch (error) {
4285
+ logger.error("Failed to fetch context usage from SDK:", error);
4286
+ return null;
4287
+ }
4288
+ }
4289
+ /** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
4290
+ * option. The `maxThinkingTokens` option it used to feed is deprecated and
4291
+ * reduced to on/off on current models, so map the value to explicit thinking
4292
+ * config instead: unset → `undefined` (SDK default, adaptive on models that
4293
+ * support it); `0` → disabled; a positive integer → a fixed token budget.
4294
+ * Anything else is ignored with a warning. */
4295
+ function resolveThinkingConfig(raw, logger) {
4296
+ if (raw === undefined)
4297
+ return undefined;
4298
+ const parsed = Number.parseInt(raw, 10);
4299
+ if (Number.isNaN(parsed) || parsed < 0) {
4300
+ logger.error(`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${raw}'.`);
4301
+ return undefined;
4302
+ }
4303
+ return parsed === 0 ? { type: "disabled" } : { type: "enabled", budgetTokens: parsed };
4304
+ }
4305
+ function parseModelConfig(raw) {
4306
+ if (!raw)
4307
+ return undefined;
4308
+ const parsed = JSON.parse(raw);
4309
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
4310
+ throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object");
4311
+ }
4312
+ const result = {};
4313
+ if (parsed.modelOverrides !== undefined)
4314
+ result.modelOverrides = parsed.modelOverrides;
4315
+ if (parsed.availableModels !== undefined)
4316
+ result.availableModels = parsed.availableModels;
4317
+ return Object.keys(result).length > 0 ? result : undefined;
4318
+ }
4319
+ function getMatchingModelUsage(modelUsage, currentModel) {
4320
+ let bestKey = null;
4321
+ let bestLen = 0;
4322
+ for (const key of Object.keys(modelUsage)) {
4323
+ const len = commonPrefixLength(key, currentModel);
4324
+ if (len > bestLen) {
4325
+ bestLen = len;
4326
+ bestKey = key;
4327
+ }
4328
+ }
4329
+ if (bestKey) {
4330
+ return modelUsage[bestKey];
4331
+ }
4332
+ }