claude-code-acp-ts 0.38.2 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-agent.d.ts +243 -30
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +1436 -268
- package/dist/elicitation.d.ts +82 -0
- package/dist/elicitation.d.ts.map +1 -0
- package/dist/elicitation.js +211 -0
- package/dist/index.js +7 -4
- package/dist/settings.js +9 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +34 -7
- package/dist/utils.js +3 -5
- package/package.json +18 -14
package/dist/acp-agent.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
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";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import * as fs from "node:fs/promises";
|
|
4
6
|
import * as os from "node:os";
|
|
5
7
|
import * as path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
6
9
|
import packageJson from "../package.json" with { type: "json" };
|
|
10
|
+
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, mcpElicitationToCreateRequest, } from "./elicitation.js";
|
|
7
11
|
import { SettingsManager } from "./settings.js";
|
|
8
12
|
import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
9
13
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
10
14
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
11
16
|
const MAX_TITLE_LENGTH = 256;
|
|
12
17
|
function sanitizeTitle(text) {
|
|
13
18
|
// Replace newlines and collapse whitespace
|
|
@@ -27,6 +32,16 @@ const ZERO_USAGE = Object.freeze({
|
|
|
27
32
|
cache_creation_input_tokens: 0,
|
|
28
33
|
});
|
|
29
34
|
const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
35
|
+
/** Floor after `session/cancel` before the adapter forces the active prompt
|
|
36
|
+
* loop to return "cancelled". `query.interrupt()` normally makes the SDK
|
|
37
|
+
* yield a trailing idle within milliseconds, and the loop returns through its
|
|
38
|
+
* usual path — so this timer is armed and cleared, never fired, on healthy
|
|
39
|
+
* cancels. It only trips when the SDK is genuinely wedged (e.g. a
|
|
40
|
+
* `TaskOutput { block: true }` poll against a hung background task — issue
|
|
41
|
+
* #680) and never yields. The value is deliberately loose: it's an
|
|
42
|
+
* "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
|
|
43
|
+
* pre-empt a slow-but-healthy interrupt. */
|
|
44
|
+
const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
|
|
30
45
|
/** Compute a stable fingerprint of the session-defining params so we can
|
|
31
46
|
* detect when a loadSession/resumeSession call requires tearing down and
|
|
32
47
|
* recreating the underlying Query process. MCP servers are sorted by name
|
|
@@ -81,6 +96,10 @@ function isMuslLibc() {
|
|
|
81
96
|
function shouldHideClaudeAuth() {
|
|
82
97
|
return process.argv.includes("--hide-claude-auth");
|
|
83
98
|
}
|
|
99
|
+
/** Returned to clients when a prompt or cancel targets a session whose SDK
|
|
100
|
+
* query stream has already ended (ran to `done` or died). The stream is not
|
|
101
|
+
* revivable, so the only recovery is a fresh session. */
|
|
102
|
+
const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session.";
|
|
84
103
|
// Bypass Permissions doesn't work if we are a root/sudo user
|
|
85
104
|
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
|
86
105
|
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
|
@@ -92,9 +111,39 @@ const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
|
|
92
111
|
// payload in these XML-like markers that the CLI uses for its own display.
|
|
93
112
|
// The live prompt loop drops them; replay must strip them too or they leak
|
|
94
113
|
// into the UI on session/load.
|
|
95
|
-
const
|
|
114
|
+
const LOCAL_COMMAND_MARKERS = [
|
|
115
|
+
"command-name",
|
|
116
|
+
"command-message",
|
|
117
|
+
"command-args",
|
|
118
|
+
"local-command-stdout",
|
|
119
|
+
"local-command-stderr",
|
|
120
|
+
].map((tag) => ({ open: `<${tag}>`, close: `</${tag}>` }));
|
|
121
|
+
// Single-pass scanner that removes each `<tag>…</tag>` marker (matching the
|
|
122
|
+
// nearest closing tag of the same name, like a lazy regex would).
|
|
96
123
|
function stripMarkerTags(text) {
|
|
97
|
-
|
|
124
|
+
const dead = new Set();
|
|
125
|
+
let result = "";
|
|
126
|
+
let copiedUpTo = 0;
|
|
127
|
+
let i = 0;
|
|
128
|
+
while (i < text.length) {
|
|
129
|
+
if (text[i] === "<") {
|
|
130
|
+
const marker = LOCAL_COMMAND_MARKERS.find((m) => !dead.has(m.open) && text.startsWith(m.open, i));
|
|
131
|
+
if (marker) {
|
|
132
|
+
const end = text.indexOf(marker.close, i + marker.open.length);
|
|
133
|
+
if (end !== -1) {
|
|
134
|
+
result += text.slice(copiedUpTo, i);
|
|
135
|
+
i = copiedUpTo = end + marker.close.length;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// No closing marker remains anywhere ahead, and `indexOf` only ever
|
|
139
|
+
// searches forward from here on, so stop treating this tag as an
|
|
140
|
+
// opener — that avoids rescanning the tail for it on every match.
|
|
141
|
+
dead.add(marker.open);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
return result + text.slice(copiedUpTo);
|
|
98
147
|
}
|
|
99
148
|
/**
|
|
100
149
|
* Return user-message content with local-command marker tags removed, or
|
|
@@ -200,13 +249,56 @@ export function describeAlwaysAllow(suggestions, toolName) {
|
|
|
200
249
|
}
|
|
201
250
|
return `Always Allow ${parts.join(" and ")}`;
|
|
202
251
|
}
|
|
203
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
|
|
254
|
+
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
|
|
255
|
+
* valid for the entire connection lifetime, so it is captured once at
|
|
256
|
+
* construction.
|
|
257
|
+
*/
|
|
258
|
+
class ClientConnection {
|
|
259
|
+
ctx;
|
|
260
|
+
constructor(ctx) {
|
|
261
|
+
this.ctx = ctx;
|
|
262
|
+
}
|
|
263
|
+
sessionUpdate(params) {
|
|
264
|
+
return this.ctx.notify(methods.client.session.update, params);
|
|
265
|
+
}
|
|
266
|
+
requestPermission(params, signal) {
|
|
267
|
+
return this.ctx.request(methods.client.session.requestPermission, params, {
|
|
268
|
+
cancellationSignal: signal,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
readTextFile(params) {
|
|
272
|
+
return this.ctx.request(methods.client.fs.readTextFile, params);
|
|
273
|
+
}
|
|
274
|
+
writeTextFile(params) {
|
|
275
|
+
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
|
276
|
+
}
|
|
277
|
+
unstable_createElicitation(params, signal) {
|
|
278
|
+
return this.ctx.request(methods.client.elicitation.create, params, {
|
|
279
|
+
cancellationSignal: signal,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
unstable_completeElicitation(params) {
|
|
283
|
+
return this.ctx.notify(methods.client.elicitation.complete, params);
|
|
284
|
+
}
|
|
285
|
+
extNotification(method, params) {
|
|
286
|
+
return this.ctx.notify(method, params);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
204
289
|
export class ClaudeAcpAgent {
|
|
290
|
+
sessions;
|
|
291
|
+
client;
|
|
292
|
+
clientCapabilities;
|
|
293
|
+
logger;
|
|
294
|
+
gatewayAuthRequest;
|
|
295
|
+
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
296
|
+
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
297
|
+
* tests can shrink it. */
|
|
298
|
+
forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
|
|
205
299
|
constructor(client, logger) {
|
|
206
|
-
this.backgroundTerminals = {};
|
|
207
300
|
this.sessions = {};
|
|
208
301
|
this.client = client;
|
|
209
|
-
this.toolUseCache = {};
|
|
210
302
|
this.logger = logger ?? console;
|
|
211
303
|
}
|
|
212
304
|
async initialize(request) {
|
|
@@ -322,6 +414,9 @@ export class ClaudeAcpAgent {
|
|
|
322
414
|
http: true,
|
|
323
415
|
sse: true,
|
|
324
416
|
},
|
|
417
|
+
auth: {
|
|
418
|
+
logout: {},
|
|
419
|
+
},
|
|
325
420
|
loadSession: true,
|
|
326
421
|
sessionCapabilities: {
|
|
327
422
|
additionalDirectories: {},
|
|
@@ -404,6 +499,40 @@ export class ClaudeAcpAgent {
|
|
|
404
499
|
sessions,
|
|
405
500
|
};
|
|
406
501
|
}
|
|
502
|
+
/** Read the SDK-maintained title for a session and, if it changed since the
|
|
503
|
+
* last time we looked, notify the client with a `session_info_update`. The
|
|
504
|
+
* SDK has no push event for the title it auto-generates in the background, so
|
|
505
|
+
* we pull it at turn-end. A missing session file or read error is non-fatal:
|
|
506
|
+
* the title is best-effort and another turn will retry. */
|
|
507
|
+
async maybeUpdateSessionTitle(sessionId, session) {
|
|
508
|
+
let info;
|
|
509
|
+
try {
|
|
510
|
+
info = await getSessionInfo(sessionId, { dir: session.cwd });
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
this.logger.error(`Session ${sessionId}: failed to read session info: ${error}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// `customTitle` is a user-set `/rename`; `summary` is the auto-generated
|
|
517
|
+
// title (or first prompt). Prefer the explicit title when present.
|
|
518
|
+
const rawTitle = info?.customTitle ?? info?.summary;
|
|
519
|
+
if (!rawTitle) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const title = sanitizeTitle(rawTitle);
|
|
523
|
+
if (title === session.lastTitle) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
session.lastTitle = title;
|
|
527
|
+
await this.client.sessionUpdate({
|
|
528
|
+
sessionId,
|
|
529
|
+
update: {
|
|
530
|
+
sessionUpdate: "session_info_update",
|
|
531
|
+
title,
|
|
532
|
+
updatedAt: new Date(info.lastModified).toISOString(),
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
}
|
|
407
536
|
async authenticate(_params) {
|
|
408
537
|
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
|
409
538
|
this.gatewayAuthRequest = _params;
|
|
@@ -411,67 +540,306 @@ export class ClaudeAcpAgent {
|
|
|
411
540
|
}
|
|
412
541
|
throw new Error("Method not implemented.");
|
|
413
542
|
}
|
|
543
|
+
async logout(_params) {
|
|
544
|
+
// Clear in-memory gateway credentials supplied via `authenticate`. The
|
|
545
|
+
// gateway method never touches the on-disk credential store, so dropping
|
|
546
|
+
// this reference is the whole logout for that path.
|
|
547
|
+
this.gatewayAuthRequest = undefined;
|
|
548
|
+
// For the Claude/Console login methods the credentials live in the native
|
|
549
|
+
// CLI's store (keychain or config dir), which only the binary can clear.
|
|
550
|
+
// `claude auth logout` is non-interactive and idempotent.
|
|
551
|
+
const cliPath = await claudeCliPath();
|
|
552
|
+
try {
|
|
553
|
+
await execFileAsync(cliPath, ["auth", "logout"]);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
const stderr = typeof error === "object" && error && "stderr" in error
|
|
557
|
+
? String(error.stderr).trim()
|
|
558
|
+
: undefined;
|
|
559
|
+
throw RequestError.internalError({ stderr: stderr || undefined }, `claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
414
562
|
async prompt(params) {
|
|
415
563
|
const session = this.sessions[params.sessionId];
|
|
416
564
|
if (!session) {
|
|
417
565
|
throw new Error("Session not found");
|
|
418
566
|
}
|
|
567
|
+
// The SDK query stream already terminated (see `queryClosed`); its iterator
|
|
568
|
+
// can't be revived, so enqueueing here would hang on a deferred that never
|
|
569
|
+
// settles. Fail clearly and let the client start a fresh session.
|
|
570
|
+
if (session.queryClosed) {
|
|
571
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
572
|
+
}
|
|
573
|
+
const userMessage = promptToClaude(params);
|
|
574
|
+
const promptUuid = randomUUID();
|
|
575
|
+
userMessage.uuid = promptUuid;
|
|
576
|
+
// Local-only commands (e.g. `/clear`) return a result without replaying the
|
|
577
|
+
// user message, so the consumer can't promote the turn from the echo.
|
|
578
|
+
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
579
|
+
const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
|
580
|
+
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
|
581
|
+
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
|
582
|
+
// turn, pushes the user message onto the streaming input, makes sure the
|
|
583
|
+
// consumer is running, and awaits the deferred.
|
|
584
|
+
const turn = {
|
|
585
|
+
promptUuid,
|
|
586
|
+
isLocalOnlyCommand,
|
|
587
|
+
settled: false,
|
|
588
|
+
resolve: () => { },
|
|
589
|
+
reject: () => { },
|
|
590
|
+
};
|
|
591
|
+
const response = new Promise((resolve, reject) => {
|
|
592
|
+
turn.resolve = resolve;
|
|
593
|
+
turn.reject = reject;
|
|
594
|
+
});
|
|
595
|
+
session.turnQueue ??= [];
|
|
596
|
+
session.turnQueue.push(turn);
|
|
597
|
+
session.input.push(userMessage);
|
|
598
|
+
this.ensureConsumer(session, params.sessionId);
|
|
599
|
+
return response;
|
|
600
|
+
}
|
|
601
|
+
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
602
|
+
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
|
|
603
|
+
ensureConsumer(session, sessionId) {
|
|
604
|
+
if (session.consumer) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// Wake-up channel so cancel() can force the consumer to settle the active
|
|
608
|
+
// turn "cancelled" even when query.next() is wedged and never yields again
|
|
609
|
+
// (issue #680). The consumer re-arms it after each fire.
|
|
610
|
+
session.cancelController = new AbortController();
|
|
611
|
+
session.consumer = this.runConsumer(session, { sessionId });
|
|
612
|
+
session.consumer.catch((error) => {
|
|
613
|
+
this.logger.error(`Session ${sessionId}: consumer terminated unexpectedly: ${error}`);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
/** The single, long-lived consumer of the SDK query stream for a session. It
|
|
617
|
+
* forwards every message as ACP `sessionUpdate`s (so background/between-turn
|
|
618
|
+
* output streams live, not just while a prompt is awaiting) and settles each
|
|
619
|
+
* Turn's deferred when that turn ends. Replaces the per-prompt message loop;
|
|
620
|
+
* `params` only carries the (session-invariant) `sessionId`. */
|
|
621
|
+
async runConsumer(session, params) {
|
|
622
|
+
// Per-turn scratch, reset whenever a turn becomes active. Kept as consumer
|
|
623
|
+
// locals (rather than per-Turn fields) because they describe the message
|
|
624
|
+
// currently being processed, which is sequential — exactly one turn is
|
|
625
|
+
// active at a time. Mirrors the locals the old per-prompt loop held.
|
|
419
626
|
let lastAssistantTotalUsage = null;
|
|
420
627
|
let lastAssistantUsage = null;
|
|
421
628
|
let lastAssistantModel = null;
|
|
422
629
|
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
|
|
423
630
|
// problem, billing), it sets a categorical `error` field on the
|
|
424
|
-
// `SDKAssistantMessage` that precedes the final `result` message. We
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
// pattern-matching on the human-readable message text.
|
|
631
|
+
// `SDKAssistantMessage` that precedes the final `result` message. We capture
|
|
632
|
+
// it here so the subsequent `RequestError.internalError` can forward it to
|
|
633
|
+
// clients as structured `data`, sparing them from pattern-matching on text.
|
|
428
634
|
let lastAssistantError;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
635
|
+
// When a streaming classifier refuses a turn, the assistant message carries
|
|
636
|
+
// stop_reason "refusal" and structured stop_details. We capture the
|
|
637
|
+
// human-readable explanation so the terminal `result` can surface it.
|
|
638
|
+
let lastRefusalExplanation = null;
|
|
639
|
+
// Tracks whether we're inside a compaction. The SDK emits the terminal
|
|
640
|
+
// `status` (compact_result success/failed) twice for a single failed
|
|
641
|
+
// compaction, and the two messages are indistinguishable — so we report the
|
|
642
|
+
// outcome only while a compaction is in progress, then clear this.
|
|
643
|
+
let compactionInProgress = false;
|
|
644
|
+
// Anthropic API message id of the assistant message currently being
|
|
645
|
+
// streamed, captured from `message_start` so the streamed chunks that follow
|
|
646
|
+
// (whose delta events don't carry it) can all be tagged with the same,
|
|
647
|
+
// replay-stable id.
|
|
648
|
+
let currentStreamMessageId;
|
|
649
|
+
// The text/thinking blocks that have actually streamed live as
|
|
650
|
+
// `stream_event` deltas for the message the next consolidated `assistant`
|
|
651
|
+
// will repeat, in stream order, each accumulated to its full streamed text.
|
|
652
|
+
// The consolidated handler diffs each assembled block against these and
|
|
653
|
+
// forwards only the un-streamed remainder — nothing if it streamed in full
|
|
654
|
+
// (the common case), the whole block if it never streamed (a non-streaming
|
|
655
|
+
// gateway), or just the tail if the stream was cut short mid-block. Matching
|
|
656
|
+
// on content rather than the Anthropic message id makes dedupe robust to
|
|
657
|
+
// gateways that don't carry a stable/matching id across the stream and the
|
|
658
|
+
// consolidated message. Reset after each consolidated message consumes it.
|
|
659
|
+
const streamedBlocks = [];
|
|
660
|
+
// Stop reason accumulated for the active turn (result subtype, refusal,
|
|
661
|
+
// max_tokens, …). Reset per turn; read when the turn settles at idle.
|
|
662
|
+
let stopReason = "end_turn";
|
|
663
|
+
const resetTurnScratch = () => {
|
|
664
|
+
lastAssistantTotalUsage = null;
|
|
665
|
+
lastAssistantUsage = null;
|
|
666
|
+
lastAssistantModel = null;
|
|
667
|
+
lastAssistantError = undefined;
|
|
668
|
+
lastRefusalExplanation = null;
|
|
669
|
+
compactionInProgress = false;
|
|
670
|
+
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
|
|
671
|
+
// activation can fire mid-message (the replayed user echo with
|
|
672
|
+
// --replay-user-messages lands between a message's blocks); clearing the
|
|
673
|
+
// streamed-content record on activation would drop the blocks that
|
|
674
|
+
// streamed before the echo, so the consolidated assistant message would
|
|
675
|
+
// re-emit them as duplicates. streamedBlocks is bounded instead by being
|
|
676
|
+
// cleared when each consolidated message consumes it. #785 stopped
|
|
677
|
+
// resetting the streamed-content tracking here but left this line.
|
|
678
|
+
stopReason = "end_turn";
|
|
679
|
+
session.accumulatedUsage = {
|
|
680
|
+
inputTokens: 0,
|
|
681
|
+
outputTokens: 0,
|
|
682
|
+
cachedReadTokens: 0,
|
|
683
|
+
cachedWriteTokens: 0,
|
|
684
|
+
};
|
|
685
|
+
};
|
|
686
|
+
/** Promote a queued turn to active: it becomes the one output is attributed
|
|
687
|
+
* to, and its scratch starts fresh. Clears the cancelled flag so a turn
|
|
688
|
+
* enqueued after a prior cancel isn't treated as cancelled. Also clears any
|
|
689
|
+
* leftover orphan-skip count: since the SDK echoes/runs input FIFO, every
|
|
690
|
+
* orphan from a prior cancel has already arrived by the time a live turn
|
|
691
|
+
* activates, so a non-zero remainder means the SDK dropped a queued turn on
|
|
692
|
+
* interrupt (no orphan emitted) — drop the stale count so a later echo-less
|
|
693
|
+
* result isn't wrongly skipped. */
|
|
694
|
+
const activateTurn = (turn) => {
|
|
695
|
+
session.activeTurn = turn;
|
|
696
|
+
session.cancelled = false;
|
|
697
|
+
session.pendingOrphanResults = 0;
|
|
698
|
+
resetTurnScratch();
|
|
699
|
+
};
|
|
700
|
+
/** Ensure there is an active turn before a user-turn result that carries no
|
|
701
|
+
* echo to activate it, by promoting the queue head. Most turns are
|
|
702
|
+
* activated by their replayed user message before their result, but some
|
|
703
|
+
* legitimately produce a result with no matching echo: local-only commands
|
|
704
|
+
* (e.g. `/context`) and compaction (`/compact`, whose only user messages
|
|
705
|
+
* are the generated summary and a `<local-command-stdout>` replay — neither
|
|
706
|
+
* carries the prompt's uuid). Promoting the head settles those.
|
|
707
|
+
*
|
|
708
|
+
* But an echo-less result can also be an ORPHAN: cancel() settles+removes a
|
|
709
|
+
* queued turn whose user message was already pushed, so the SDK still runs
|
|
710
|
+
* it and emits a result with no uuid to match. Promoting the head for an
|
|
711
|
+
* orphan would misattribute its stop reason/usage to an unrelated later
|
|
712
|
+
* prompt. `session.pendingOrphanResults` counts exactly how many such
|
|
713
|
+
* orphans are still expected (FIFO, they arrive before any live turn's
|
|
714
|
+
* result), so we skip those and only promote once the count is drained. */
|
|
715
|
+
const ensureActiveTurn = () => {
|
|
716
|
+
if (session.activeTurn) {
|
|
717
|
+
return;
|
|
445
718
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
// zero usage instead of `cancelled`.
|
|
456
|
-
session.cancelled = false;
|
|
457
|
-
session.accumulatedUsage = {
|
|
458
|
-
inputTokens: 0,
|
|
459
|
-
outputTokens: 0,
|
|
460
|
-
cachedReadTokens: 0,
|
|
461
|
-
cachedWriteTokens: 0,
|
|
719
|
+
const head = (session.turnQueue ?? []).find((t) => !t.settled);
|
|
720
|
+
if (!head) {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if ((session.pendingOrphanResults ?? 0) > 0) {
|
|
724
|
+
session.pendingOrphanResults--;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
activateTurn(head);
|
|
462
728
|
};
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
729
|
+
/** Settle the active turn's deferred exactly once, disarm the force-cancel
|
|
730
|
+
* backstop (the turn is over), and drop it from the queue. */
|
|
731
|
+
const settleActive = (result) => {
|
|
732
|
+
const turn = session.activeTurn;
|
|
733
|
+
if (!turn || turn.settled) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
turn.settled = true;
|
|
737
|
+
if (session.forceCancelTimer) {
|
|
738
|
+
clearTimeout(session.forceCancelTimer);
|
|
739
|
+
session.forceCancelTimer = undefined;
|
|
740
|
+
}
|
|
741
|
+
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
742
|
+
session.activeTurn = null;
|
|
743
|
+
turn.resolve(result);
|
|
744
|
+
};
|
|
745
|
+
/** Reject the active turn (auth required, error result, …) without tearing
|
|
746
|
+
* down the consumer: the stream continues to idle and later turns proceed. */
|
|
747
|
+
const failActive = (error) => {
|
|
748
|
+
if (session.forceCancelTimer) {
|
|
749
|
+
clearTimeout(session.forceCancelTimer);
|
|
750
|
+
session.forceCancelTimer = undefined;
|
|
751
|
+
}
|
|
752
|
+
const turn = session.activeTurn;
|
|
753
|
+
if (!turn || turn.settled) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
turn.settled = true;
|
|
757
|
+
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
758
|
+
session.activeTurn = null;
|
|
759
|
+
turn.reject(error);
|
|
760
|
+
};
|
|
761
|
+
/** Reject every in-flight turn — used when the stream dies. */
|
|
762
|
+
const failAllTurns = (error) => {
|
|
763
|
+
if (session.forceCancelTimer) {
|
|
764
|
+
clearTimeout(session.forceCancelTimer);
|
|
765
|
+
session.forceCancelTimer = undefined;
|
|
766
|
+
}
|
|
767
|
+
const turns = session.activeTurn
|
|
768
|
+
? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
|
|
769
|
+
: [...(session.turnQueue ?? [])];
|
|
770
|
+
session.activeTurn = null;
|
|
771
|
+
session.turnQueue = [];
|
|
772
|
+
for (const turn of turns) {
|
|
773
|
+
if (!turn.settled) {
|
|
774
|
+
turn.settled = true;
|
|
775
|
+
turn.reject(error);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
// The wake-up channel cancel()/teardown aborts to force the active turn to
|
|
780
|
+
// settle "cancelled" even when query.next() is wedged (issue #680). Re-armed
|
|
781
|
+
// after each fire so the consumer keeps serving later turns.
|
|
782
|
+
let cancelController = session.cancelController;
|
|
467
783
|
try {
|
|
468
784
|
while (true) {
|
|
469
|
-
const
|
|
785
|
+
const nextMessage = session.query.next();
|
|
786
|
+
// Fresh abort listener per iteration, removed when next() wins, so a
|
|
787
|
+
// long-lived session doesn't accumulate listeners on one signal.
|
|
788
|
+
let onAbort;
|
|
789
|
+
const abortRace = new Promise((resolve) => {
|
|
790
|
+
onAbort = () => resolve("abort");
|
|
791
|
+
cancelController.signal.addEventListener("abort", onAbort, { once: true });
|
|
792
|
+
});
|
|
793
|
+
const raced = await Promise.race([
|
|
794
|
+
nextMessage.then((result) => ({ kind: "message", result })),
|
|
795
|
+
abortRace,
|
|
796
|
+
]);
|
|
797
|
+
cancelController.signal.removeEventListener("abort", onAbort);
|
|
798
|
+
if (raced === "abort") {
|
|
799
|
+
// cancel()/teardown woke us. Abandon the in-flight next() (swallowing
|
|
800
|
+
// any later rejection so it can't surface as unhandled) and settle the
|
|
801
|
+
// active turn "cancelled" per the ACP contract. If the session is
|
|
802
|
+
// being torn down, stop; otherwise re-arm and keep consuming.
|
|
803
|
+
void nextMessage.catch(() => { });
|
|
804
|
+
settleActive({ stopReason: "cancelled" });
|
|
805
|
+
if (!this.sessions[params.sessionId]) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
cancelController = new AbortController();
|
|
809
|
+
session.cancelController = cancelController;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const { value: message, done } = raced.result;
|
|
470
813
|
if (done || !message) {
|
|
471
|
-
|
|
472
|
-
|
|
814
|
+
// The stream ended. Settle the in-flight turns FIRST, then release the
|
|
815
|
+
// stream resources — same order as the error paths (failAllTurns before
|
|
816
|
+
// closeQueryStream). Settling is the user-facing contract; resource
|
|
817
|
+
// release is best-effort cleanup, so a throw there must not pre-empt a
|
|
818
|
+
// turn's real outcome.
|
|
819
|
+
//
|
|
820
|
+
// Settle the turn that was in flight so its prompt() doesn't hang:
|
|
821
|
+
// cancelled if a cancel is pending, otherwise the accumulated outcome.
|
|
822
|
+
settleActive(session.cancelled
|
|
823
|
+
? { stopReason: "cancelled" }
|
|
824
|
+
: { stopReason, usage: sessionUsage(session) });
|
|
825
|
+
// Queued turns the SDK never started never ran, so reject them rather
|
|
826
|
+
// than reporting a success (end_turn) — or a misleading "cancelled" —
|
|
827
|
+
// for a prompt that produced no output. (A cancel already settled the
|
|
828
|
+
// turns that were queued at cancel time and removed them, so anything
|
|
829
|
+
// still here was enqueued afterward and was not part of the cancel.)
|
|
830
|
+
for (const queued of [...(session.turnQueue ?? [])]) {
|
|
831
|
+
if (!queued.settled) {
|
|
832
|
+
queued.settled = true;
|
|
833
|
+
queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
|
|
834
|
+
}
|
|
473
835
|
}
|
|
474
|
-
|
|
836
|
+
session.turnQueue = [];
|
|
837
|
+
// The query iterator can't be revived, so close the session's stream
|
|
838
|
+
// (marks queryClosed, drops the consumer handle, releases the dead
|
|
839
|
+
// subprocess/settings resources) — a later prompt() then rejects up
|
|
840
|
+
// front rather than restarting a consumer on the exhausted stream.
|
|
841
|
+
this.closeQueryStream(session);
|
|
842
|
+
return;
|
|
475
843
|
}
|
|
476
844
|
if (session.emitRawSDKMessages &&
|
|
477
845
|
shouldEmitRawMessage(session.emitRawSDKMessages, message)) {
|
|
@@ -487,6 +855,7 @@ export class ClaudeAcpAgent {
|
|
|
487
855
|
break;
|
|
488
856
|
case "status": {
|
|
489
857
|
if (message.status === "compacting") {
|
|
858
|
+
compactionInProgress = true;
|
|
490
859
|
await this.client.sessionUpdate({
|
|
491
860
|
sessionId: message.session_id,
|
|
492
861
|
update: {
|
|
@@ -495,37 +864,65 @@ export class ClaudeAcpAgent {
|
|
|
495
864
|
},
|
|
496
865
|
});
|
|
497
866
|
}
|
|
867
|
+
else if (message.compact_result === "success" && compactionInProgress) {
|
|
868
|
+
// The SDK signals manual `/compact` completion with a status
|
|
869
|
+
// message carrying `compact_result`, not the `compact_boundary`
|
|
870
|
+
// message (which only fires when there's content to compact).
|
|
871
|
+
compactionInProgress = false;
|
|
872
|
+
await this.client.sessionUpdate({
|
|
873
|
+
sessionId: message.session_id,
|
|
874
|
+
update: {
|
|
875
|
+
sessionUpdate: "agent_message_chunk",
|
|
876
|
+
content: { type: "text", text: "\n\nCompacting completed." },
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
else if (message.compact_result === "failed" && compactionInProgress) {
|
|
881
|
+
compactionInProgress = false;
|
|
882
|
+
const reason = message.compact_error ? `: ${message.compact_error}` : ".";
|
|
883
|
+
await this.client.sessionUpdate({
|
|
884
|
+
sessionId: message.session_id,
|
|
885
|
+
update: {
|
|
886
|
+
sessionUpdate: "agent_message_chunk",
|
|
887
|
+
content: { type: "text", text: `\n\nCompacting failed${reason}` },
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
}
|
|
498
891
|
break;
|
|
499
892
|
}
|
|
500
893
|
case "compact_boundary": {
|
|
501
|
-
//
|
|
502
|
-
// the stale pre-compaction
|
|
894
|
+
// Refresh the displayed usage immediately so the client doesn't
|
|
895
|
+
// keep showing the stale pre-compaction size (e.g. "944k/1m")
|
|
896
|
+
// right after the user sees "Compacting completed", which is
|
|
897
|
+
// confusing and wrong.
|
|
503
898
|
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
899
|
+
// Prefer the SDK's authoritative post-compaction `used` via
|
|
900
|
+
// getContextUsage — it reflects the real retained context
|
|
901
|
+
// (system prompt + tools + surviving messages), which the
|
|
902
|
+
// per-message API usage numbers can't give us until the next
|
|
903
|
+
// turn's result. If the control request fails, fall back to the
|
|
904
|
+
// used:0 approximation: directionally correct (context just
|
|
905
|
+
// dropped dramatically) and replaced within seconds by the next
|
|
906
|
+
// result message.
|
|
907
|
+
//
|
|
908
|
+
// `size` keeps coming from session.contextWindowSize (learned
|
|
909
|
+
// from modelUsage / the model heuristic) — getContextUsage's
|
|
910
|
+
// window field under-reports extended 1M windows.
|
|
911
|
+
//
|
|
912
|
+
// The "Compacting completed." text is emitted from the `status`
|
|
913
|
+
// handler (keyed on `compact_result`), not here, so the failure
|
|
914
|
+
// path gets a message too.
|
|
915
|
+
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
|
513
916
|
lastAssistantUsage = null;
|
|
917
|
+
lastAssistantTotalUsage = usedTokens ?? 0;
|
|
514
918
|
await this.client.sessionUpdate({
|
|
515
919
|
sessionId: message.session_id,
|
|
516
920
|
update: {
|
|
517
921
|
sessionUpdate: "usage_update",
|
|
518
|
-
used:
|
|
922
|
+
used: lastAssistantTotalUsage,
|
|
519
923
|
size: session.contextWindowSize,
|
|
520
924
|
},
|
|
521
925
|
});
|
|
522
|
-
await this.client.sessionUpdate({
|
|
523
|
-
sessionId: message.session_id,
|
|
524
|
-
update: {
|
|
525
|
-
sessionUpdate: "agent_message_chunk",
|
|
526
|
-
content: { type: "text", text: "\n\nCompacting completed." },
|
|
527
|
-
},
|
|
528
|
-
});
|
|
529
926
|
break;
|
|
530
927
|
}
|
|
531
928
|
case "local_command_output": {
|
|
@@ -540,10 +937,25 @@ export class ClaudeAcpAgent {
|
|
|
540
937
|
}
|
|
541
938
|
case "session_state_changed": {
|
|
542
939
|
if (message.state === "idle") {
|
|
940
|
+
// A non-cancelled turn already settled at its terminal
|
|
941
|
+
// `result` (issue #773), so this trailing `idle` is just
|
|
942
|
+
// absorbed. We must NOT settle `activeTurn` here in that case:
|
|
943
|
+
// `idle` carries no turn identity, and it can lag (the SDK
|
|
944
|
+
// flushes held-back results / drains background agents first),
|
|
945
|
+
// so by the time it arrives the SDK may have echoed the NEXT
|
|
946
|
+
// turn and activated it — settling now would resolve that new
|
|
947
|
+
// turn prematurely with end_turn and ~zero usage, dropping its
|
|
948
|
+
// real result. Only a cancelled turn relies on `idle`: its
|
|
949
|
+
// `result` is dropped at the `session.cancelled` guard, so it
|
|
950
|
+
// never settles at a result and must settle here.
|
|
543
951
|
if (session.cancelled) {
|
|
544
|
-
stopReason
|
|
952
|
+
settleActive({ stopReason: "cancelled" });
|
|
545
953
|
}
|
|
546
|
-
|
|
954
|
+
// The SDK generates the session title in a background task and
|
|
955
|
+
// persists it to the session file; `idle` is the turn-over
|
|
956
|
+
// signal, so it's the point at which a new title may have
|
|
957
|
+
// landed. Push it to the client if it changed.
|
|
958
|
+
await this.maybeUpdateSessionTitle(params.sessionId, session);
|
|
547
959
|
}
|
|
548
960
|
break;
|
|
549
961
|
}
|
|
@@ -584,6 +996,81 @@ export class ClaudeAcpAgent {
|
|
|
584
996
|
});
|
|
585
997
|
break;
|
|
586
998
|
}
|
|
999
|
+
case "commands_changed": {
|
|
1000
|
+
// Push the full slash-command list after a mid-session change
|
|
1001
|
+
// (e.g. skills discovered dynamically as the agent works in a
|
|
1002
|
+
// subdirectory). The client should REPLACE its cached command
|
|
1003
|
+
// list with this payload: supportedCommands() is captured once
|
|
1004
|
+
// at initialize and never reflects mid-session changes, so we
|
|
1005
|
+
// forward message.commands directly rather than re-querying.
|
|
1006
|
+
await this.client.sessionUpdate({
|
|
1007
|
+
sessionId: message.session_id,
|
|
1008
|
+
update: {
|
|
1009
|
+
sessionUpdate: "available_commands_update",
|
|
1010
|
+
availableCommands: getAvailableSlashCommands(message.commands),
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
case "mirror_error": {
|
|
1016
|
+
// The SDK failed to persist session history (SessionStore
|
|
1017
|
+
// append rejected/timed out after retry) — potential data loss
|
|
1018
|
+
// the user should know about rather than a silent gap on
|
|
1019
|
+
// resume. Log it and surface a warning in the conversation.
|
|
1020
|
+
this.logger.error(`Session ${message.session_id}: failed to persist history: ${message.error}`);
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
case "permission_denied": {
|
|
1024
|
+
// A tool call was auto-denied (by a rule, the classifier,
|
|
1025
|
+
// dontAsk mode, etc.) before running. The tool_use block was
|
|
1026
|
+
// already emitted as a `tool_call`, so mark it failed with the
|
|
1027
|
+
// rejection reason — otherwise the client shows a tool call
|
|
1028
|
+
// that silently never resolves.
|
|
1029
|
+
const reason = message.decision_reason ?? message.message;
|
|
1030
|
+
await this.client.sessionUpdate({
|
|
1031
|
+
sessionId: message.session_id,
|
|
1032
|
+
update: {
|
|
1033
|
+
sessionUpdate: "tool_call_update",
|
|
1034
|
+
toolCallId: message.tool_use_id,
|
|
1035
|
+
status: "failed",
|
|
1036
|
+
content: [
|
|
1037
|
+
{
|
|
1038
|
+
type: "content",
|
|
1039
|
+
content: { type: "text", text: `Permission denied: ${reason}` },
|
|
1040
|
+
},
|
|
1041
|
+
],
|
|
1042
|
+
_meta: {
|
|
1043
|
+
claudeCode: {
|
|
1044
|
+
toolName: message.tool_name,
|
|
1045
|
+
toolResponse: {
|
|
1046
|
+
decisionReasonType: message.decision_reason_type,
|
|
1047
|
+
decisionReason: message.decision_reason,
|
|
1048
|
+
message: message.message,
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
break;
|
|
1055
|
+
}
|
|
1056
|
+
case "informational": {
|
|
1057
|
+
// Free-form notice from the SDK (e.g. why a UserPromptSubmit/Stop
|
|
1058
|
+
// hook blocked continuation). Surface the text so the user sees it
|
|
1059
|
+
// instead of a silent stop. ACP's agent_message_chunk has no
|
|
1060
|
+
// severity field, so fold the level into the text for the more
|
|
1061
|
+
// prominent levels ('info' is transcript-only noise — leave plain).
|
|
1062
|
+
const text = message.level === "info"
|
|
1063
|
+
? message.content
|
|
1064
|
+
: `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
|
|
1065
|
+
await this.client.sessionUpdate({
|
|
1066
|
+
sessionId: message.session_id,
|
|
1067
|
+
update: {
|
|
1068
|
+
sessionUpdate: "agent_message_chunk",
|
|
1069
|
+
content: { type: "text", text },
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
587
1074
|
case "hook_started":
|
|
588
1075
|
case "hook_progress":
|
|
589
1076
|
case "hook_response":
|
|
@@ -592,12 +1079,33 @@ export class ClaudeAcpAgent {
|
|
|
592
1079
|
case "task_notification":
|
|
593
1080
|
case "task_progress":
|
|
594
1081
|
case "task_updated":
|
|
595
|
-
|
|
1082
|
+
break;
|
|
1083
|
+
case "worker_shutting_down":
|
|
1084
|
+
// A Remote Control worker announced a graceful teardown. This is a
|
|
1085
|
+
// live-tail signal for remote clients to explain why a session went
|
|
1086
|
+
// away; it's not meaningful for a local stdio ACP session.
|
|
1087
|
+
break;
|
|
1088
|
+
case "elicitation_complete": {
|
|
1089
|
+
// A url-mode MCP elicitation finished server-side. Let the client
|
|
1090
|
+
// dismiss any UI it opened for it. Only meaningful when the
|
|
1091
|
+
// client supports url elicitation; ignore failures otherwise.
|
|
1092
|
+
if (this.clientCapabilities?.elicitation?.url) {
|
|
1093
|
+
try {
|
|
1094
|
+
await this.client.unstable_completeElicitation({
|
|
1095
|
+
elicitationId: message.elicitation_id,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
this.logger.error(`Failed to complete elicitation: ${error}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
596
1104
|
case "plugin_install":
|
|
597
1105
|
case "notification":
|
|
598
1106
|
case "api_retry":
|
|
599
|
-
case "
|
|
600
|
-
case "
|
|
1107
|
+
case "thinking_tokens":
|
|
1108
|
+
case "model_refusal_fallback":
|
|
601
1109
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
602
1110
|
break;
|
|
603
1111
|
default:
|
|
@@ -606,11 +1114,32 @@ export class ClaudeAcpAgent {
|
|
|
606
1114
|
}
|
|
607
1115
|
break;
|
|
608
1116
|
case "result": {
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
1117
|
+
// Task-notification followups are autonomous work triggered by a
|
|
1118
|
+
// task-notification system message, not by the user's prompt.
|
|
1119
|
+
// They should not influence the user-turn lifecycle (stop reason,
|
|
1120
|
+
// slash-command output forwarding) but their cost is real.
|
|
1121
|
+
const isTaskNotification = message.origin?.kind === "task-notification";
|
|
1122
|
+
// A user-turn result needs an active turn so its stop reason is
|
|
1123
|
+
// attributed and the turn settles at idle. Local-only commands carry
|
|
1124
|
+
// no user-message echo to promote them, so do it here from the head.
|
|
1125
|
+
// Promote BEFORE accumulating usage, since activation resets the
|
|
1126
|
+
// accumulator — promoting after would discard this result's tokens.
|
|
1127
|
+
if (!isTaskNotification) {
|
|
1128
|
+
ensureActiveTurn();
|
|
1129
|
+
}
|
|
1130
|
+
// Accumulate usage into the user turn's tally. Skip task-notification
|
|
1131
|
+
// followups: their cost is real but is reported separately via the
|
|
1132
|
+
// usage_update below, and `session.accumulatedUsage` is only reset on
|
|
1133
|
+
// turn activation — so folding a task-notification result that lands
|
|
1134
|
+
// after the next turn is active (but before it settles) would leak
|
|
1135
|
+
// those tokens into that turn's PromptResponse.usage.
|
|
1136
|
+
if (!isTaskNotification) {
|
|
1137
|
+
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
|
|
1138
|
+
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
|
|
1139
|
+
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
|
1140
|
+
session.accumulatedUsage.cachedWriteTokens +=
|
|
1141
|
+
message.usage.cache_creation_input_tokens;
|
|
1142
|
+
}
|
|
614
1143
|
const matchingModelUsage = lastAssistantModel
|
|
615
1144
|
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
616
1145
|
: null;
|
|
@@ -621,11 +1150,6 @@ export class ClaudeAcpAgent {
|
|
|
621
1150
|
if (matchingModelUsage) {
|
|
622
1151
|
session.contextWindowSize = matchingModelUsage.contextWindow;
|
|
623
1152
|
}
|
|
624
|
-
// Task-notification followups are autonomous work triggered by a
|
|
625
|
-
// task-notification system message, not by the user's prompt.
|
|
626
|
-
// They should not influence the user-turn lifecycle (stop reason,
|
|
627
|
-
// slash-command output forwarding) but their cost is real.
|
|
628
|
-
const isTaskNotification = message.origin?.kind === "task-notification";
|
|
629
1153
|
// Send usage_update notification
|
|
630
1154
|
if (lastAssistantTotalUsage !== null) {
|
|
631
1155
|
await this.client.sessionUpdate({
|
|
@@ -650,10 +1174,31 @@ export class ClaudeAcpAgent {
|
|
|
650
1174
|
}
|
|
651
1175
|
break;
|
|
652
1176
|
}
|
|
1177
|
+
// A refusal can arrive on any result subtype (and may even set
|
|
1178
|
+
// is_error), so handle it before the subtype switch — otherwise the
|
|
1179
|
+
// is_error throw below would surface it as an internal error. The
|
|
1180
|
+
// refused assistant message carries no visible content, so surface
|
|
1181
|
+
// the classifier's explanation (when available) and report ACP's
|
|
1182
|
+
// dedicated `refusal` stop reason.
|
|
1183
|
+
if (message.stop_reason === "refusal" && !isTaskNotification) {
|
|
1184
|
+
if (lastRefusalExplanation) {
|
|
1185
|
+
await this.client.sessionUpdate({
|
|
1186
|
+
sessionId: params.sessionId,
|
|
1187
|
+
update: {
|
|
1188
|
+
sessionUpdate: "agent_message_chunk",
|
|
1189
|
+
content: { type: "text", text: lastRefusalExplanation },
|
|
1190
|
+
},
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
stopReason = "refusal";
|
|
1194
|
+
settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
653
1197
|
switch (message.subtype) {
|
|
654
1198
|
case "success": {
|
|
655
1199
|
if (message.result.includes("Please run /login")) {
|
|
656
|
-
|
|
1200
|
+
failActive(RequestError.authRequired());
|
|
1201
|
+
break;
|
|
657
1202
|
}
|
|
658
1203
|
if (message.stop_reason === "max_tokens") {
|
|
659
1204
|
if (!isTaskNotification) {
|
|
@@ -662,14 +1207,15 @@ export class ClaudeAcpAgent {
|
|
|
662
1207
|
break;
|
|
663
1208
|
}
|
|
664
1209
|
if (message.is_error) {
|
|
665
|
-
|
|
1210
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
|
|
1211
|
+
break;
|
|
666
1212
|
}
|
|
667
1213
|
// For local-only commands (no model invocation), the result
|
|
668
1214
|
// text is the command output — forward it to the client.
|
|
669
1215
|
// Task-notification followups never originate from a user
|
|
670
1216
|
// slash command, so skip the forwarding for them.
|
|
671
|
-
if (isLocalOnlyCommand && !isTaskNotification) {
|
|
672
|
-
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId,
|
|
1217
|
+
if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
|
|
1218
|
+
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
|
|
673
1219
|
await this.client.sessionUpdate(notification);
|
|
674
1220
|
}
|
|
675
1221
|
}
|
|
@@ -683,7 +1229,8 @@ export class ClaudeAcpAgent {
|
|
|
683
1229
|
break;
|
|
684
1230
|
}
|
|
685
1231
|
if (message.is_error) {
|
|
686
|
-
|
|
1232
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
1233
|
+
break;
|
|
687
1234
|
}
|
|
688
1235
|
if (!isTaskNotification) {
|
|
689
1236
|
stopReason = "end_turn";
|
|
@@ -694,7 +1241,8 @@ export class ClaudeAcpAgent {
|
|
|
694
1241
|
case "error_max_turns":
|
|
695
1242
|
case "error_max_structured_output_retries":
|
|
696
1243
|
if (message.is_error) {
|
|
697
|
-
|
|
1244
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
1245
|
+
break;
|
|
698
1246
|
}
|
|
699
1247
|
if (!isTaskNotification) {
|
|
700
1248
|
stopReason = "max_turn_requests";
|
|
@@ -704,9 +1252,70 @@ export class ClaudeAcpAgent {
|
|
|
704
1252
|
unreachable(message, this.logger);
|
|
705
1253
|
break;
|
|
706
1254
|
}
|
|
1255
|
+
// Settle the user turn at its terminal result so the client unlocks
|
|
1256
|
+
// as soon as the answer is done, rather than waiting for the SDK's
|
|
1257
|
+
// trailing `idle` (which can lag while background work runs — issue
|
|
1258
|
+
// #773). The consumer keeps draining afterward (absorbing idle and
|
|
1259
|
+
// forwarding any background output). is_error/auth already settled
|
|
1260
|
+
// via failActive; cancellation is left to the idle/abort path.
|
|
1261
|
+
// settleActive is idempotent, so a duplicate idle is a no-op.
|
|
1262
|
+
if (!isTaskNotification && !session.cancelled) {
|
|
1263
|
+
settleActive({ stopReason, usage: sessionUsage(session) });
|
|
1264
|
+
}
|
|
707
1265
|
break;
|
|
708
1266
|
}
|
|
709
1267
|
case "stream_event": {
|
|
1268
|
+
// `message_start` carries the Anthropic API message id; capture it
|
|
1269
|
+
// so the streamed chunks that follow (whose delta events don't carry
|
|
1270
|
+
// it) can all be tagged with the same, replay-stable id.
|
|
1271
|
+
if (message.event.type === "message_start") {
|
|
1272
|
+
currentStreamMessageId = message.event.message.id || undefined;
|
|
1273
|
+
// A new top-level message starts: clear any streamed-content
|
|
1274
|
+
// residue from a prior message that never reached its
|
|
1275
|
+
// consolidated reset — a cancelled turn breaks out before the
|
|
1276
|
+
// reset, and the synthetic-auth/system/local-command paths
|
|
1277
|
+
// `break` early too. Block indices restart at 0 each message, so
|
|
1278
|
+
// leftover entries would otherwise collide with this message's
|
|
1279
|
+
// blocks and re-emit (or truncate) already-streamed text. Gated on
|
|
1280
|
+
// `parent_tool_use_id === null` so a subagent stream can't clear
|
|
1281
|
+
// the top-level record. Fires once, before any of this message's
|
|
1282
|
+
// blocks, so it doesn't disturb the mid-message turn-activation
|
|
1283
|
+
// path the way resetting on turn activation would.
|
|
1284
|
+
if (message.parent_tool_use_id === null) {
|
|
1285
|
+
streamedBlocks.length = 0;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
// Accumulate the text/thinking actually streamed live, so the
|
|
1289
|
+
// `assistant` case below can diff its assembled blocks against what
|
|
1290
|
+
// already reached the client as chunks and forward only the
|
|
1291
|
+
// remainder. Gated on `parent_tool_use_id === null` so a subagent
|
|
1292
|
+
// stream can't attribute its content to the top-level message.
|
|
1293
|
+
// Contiguous deltas of the same block (same index and type) extend
|
|
1294
|
+
// the current entry; anything else opens a new one.
|
|
1295
|
+
if (message.parent_tool_use_id === null &&
|
|
1296
|
+
message.event.type === "content_block_delta") {
|
|
1297
|
+
const delta = message.event.delta;
|
|
1298
|
+
const chunk = delta.type === "text_delta"
|
|
1299
|
+
? { type: "text", text: delta.text }
|
|
1300
|
+
: delta.type === "thinking_delta"
|
|
1301
|
+
? { type: "thinking", text: delta.thinking }
|
|
1302
|
+
: undefined;
|
|
1303
|
+
// Skip empty deltas (some gateways emit empty thinking chunks —
|
|
1304
|
+
// #793): appending "" is a no-op, but pushing a "" entry would
|
|
1305
|
+
// create a block the consolidated handler's `text.length > 0`
|
|
1306
|
+
// guard can never consume, stalling the diff cursor and
|
|
1307
|
+
// re-emitting the next block as a duplicate.
|
|
1308
|
+
if (chunk && chunk.text.length > 0) {
|
|
1309
|
+
const index = message.event.index;
|
|
1310
|
+
const last = streamedBlocks[streamedBlocks.length - 1];
|
|
1311
|
+
if (last && last.index === index && last.type === chunk.type) {
|
|
1312
|
+
last.text += chunk.text;
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
710
1319
|
if (message.parent_tool_use_id === null &&
|
|
711
1320
|
(message.event.type === "message_start" || message.event.type === "message_delta")) {
|
|
712
1321
|
if (message.event.type === "message_start") {
|
|
@@ -754,10 +1363,11 @@ export class ClaudeAcpAgent {
|
|
|
754
1363
|
});
|
|
755
1364
|
}
|
|
756
1365
|
}
|
|
757
|
-
for (const notification of streamEventToAcpNotifications(message, params.sessionId,
|
|
1366
|
+
for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
758
1367
|
clientCapabilities: this.clientCapabilities,
|
|
759
1368
|
cwd: session.cwd,
|
|
760
1369
|
taskState: session.taskState,
|
|
1370
|
+
messageId: currentStreamMessageId,
|
|
761
1371
|
})) {
|
|
762
1372
|
await this.client.sessionUpdate(notification);
|
|
763
1373
|
}
|
|
@@ -765,28 +1375,49 @@ export class ClaudeAcpAgent {
|
|
|
765
1375
|
}
|
|
766
1376
|
case "user":
|
|
767
1377
|
case "assistant": {
|
|
768
|
-
|
|
769
|
-
|
|
1378
|
+
// Record the ACP messageId -> SDK uuid mapping for this message
|
|
1379
|
+
// (including replays). The consolidated message carries both ids, so
|
|
1380
|
+
// this is where we learn the uuid the SDK's rewind/resume APIs key on
|
|
1381
|
+
// for the id we hand clients. Not read yet (see messageIdToUuid).
|
|
1382
|
+
const mappedMessageId = messageIdForGrouping(message);
|
|
1383
|
+
if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
|
|
1384
|
+
session.messageIdToUuid.set(mappedMessageId, message.uuid);
|
|
770
1385
|
}
|
|
771
|
-
//
|
|
1386
|
+
// A replayed user message echoes a queued turn back in submission
|
|
1387
|
+
// order. The first echo promotes that turn to active; if a different
|
|
1388
|
+
// turn is still active, it is handed off (settled end_turn) first.
|
|
1389
|
+
// Done before the `cancelled` guard so a turn enqueued after a cancel
|
|
1390
|
+
// is still promoted — activateTurn() clears the flag. The turn's own
|
|
1391
|
+
// echo is then dropped from the feed (the client already shows it).
|
|
772
1392
|
if (message.type === "user" && "uuid" in message && message.uuid) {
|
|
773
|
-
|
|
1393
|
+
const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
|
|
1394
|
+
if (queued) {
|
|
1395
|
+
// Only (re)activate if this isn't already the active turn — a
|
|
1396
|
+
// turn promoted early (e.g. by a result that preceded its echo)
|
|
1397
|
+
// must not have its accumulated usage reset by its own echo.
|
|
1398
|
+
if (session.activeTurn !== queued) {
|
|
1399
|
+
if (session.activeTurn) {
|
|
1400
|
+
// Hand off the previous turn. If a cancel is pending for it
|
|
1401
|
+
// (its trailing idle hasn't arrived yet), settle it
|
|
1402
|
+
// "cancelled" per the ACP contract rather than "end_turn" —
|
|
1403
|
+
// otherwise a cancel followed quickly by the next prompt
|
|
1404
|
+
// would report the cancelled turn as a normal completion.
|
|
1405
|
+
settleActive(session.cancelled
|
|
1406
|
+
? { stopReason: "cancelled" }
|
|
1407
|
+
: { stopReason: "end_turn", usage: sessionUsage(session) });
|
|
1408
|
+
}
|
|
1409
|
+
activateTurn(queued);
|
|
1410
|
+
}
|
|
774
1411
|
break;
|
|
775
1412
|
}
|
|
776
|
-
const pending = session.pendingMessages.get(message.uuid);
|
|
777
|
-
if (pending) {
|
|
778
|
-
pending.resolve(false);
|
|
779
|
-
session.pendingMessages.delete(message.uuid);
|
|
780
|
-
handedOff = true;
|
|
781
|
-
// the current loop stops with end_turn,
|
|
782
|
-
// the loop of the next prompt continues running
|
|
783
|
-
return { stopReason: "end_turn", usage: sessionUsage(session) };
|
|
784
|
-
}
|
|
785
1413
|
if ("isReplay" in message && message.isReplay) {
|
|
786
|
-
//
|
|
1414
|
+
// Unrelated replay (e.g. the echo of an already-settled turn).
|
|
787
1415
|
break;
|
|
788
1416
|
}
|
|
789
1417
|
}
|
|
1418
|
+
if (session.cancelled) {
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
790
1421
|
// Snapshot the latest top-level assistant usage and model so the
|
|
791
1422
|
// next `result` can emit a usage_update tied to the right context
|
|
792
1423
|
// window. Subagent messages are excluded to keep the snapshot
|
|
@@ -800,21 +1431,26 @@ export class ClaudeAcpAgent {
|
|
|
800
1431
|
if (message.error) {
|
|
801
1432
|
lastAssistantError = message.error;
|
|
802
1433
|
}
|
|
1434
|
+
if (message.message.stop_reason === "refusal") {
|
|
1435
|
+
lastRefusalExplanation = message.message.stop_details?.explanation ?? null;
|
|
1436
|
+
}
|
|
803
1437
|
}
|
|
804
1438
|
// Strip <command-*>/<local-command-stdout> markers and render any
|
|
805
1439
|
// remaining prose. Skill bodies and built-in slash commands (e.g.
|
|
806
1440
|
// /usage, /status, /model) arrive wrapped in these tags; pure-marker
|
|
807
1441
|
// payloads (e.g. /compact's malformed output) strip to null and are
|
|
808
1442
|
// skipped. Mirrors the replay path at replaySessionHistory.
|
|
809
|
-
if (
|
|
1443
|
+
if (message.message.role !== "system" &&
|
|
1444
|
+
typeof message.message.content === "string" &&
|
|
810
1445
|
message.message.content.includes("<local-command-stdout>")) {
|
|
811
1446
|
const stripped = stripLocalCommandMetadata(message.message.content);
|
|
812
1447
|
if (typeof stripped === "string") {
|
|
813
|
-
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId,
|
|
1448
|
+
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
814
1449
|
clientCapabilities: this.clientCapabilities,
|
|
815
1450
|
parentToolUseId: message.parent_tool_use_id,
|
|
816
1451
|
cwd: session.cwd,
|
|
817
1452
|
taskState: session.taskState,
|
|
1453
|
+
messageId: messageIdForGrouping(message),
|
|
818
1454
|
})) {
|
|
819
1455
|
await this.client.sessionUpdate(notification);
|
|
820
1456
|
}
|
|
@@ -837,106 +1473,171 @@ export class ClaudeAcpAgent {
|
|
|
837
1473
|
message.message.content[0].type === "text"))) {
|
|
838
1474
|
break;
|
|
839
1475
|
}
|
|
1476
|
+
if (message.message.role === "system") {
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
840
1479
|
if (message.type === "assistant" &&
|
|
841
1480
|
message.message.model === "<synthetic>" &&
|
|
842
1481
|
Array.isArray(message.message.content) &&
|
|
843
1482
|
message.message.content.length === 1 &&
|
|
844
1483
|
message.message.content[0].type === "text" &&
|
|
845
1484
|
message.message.content[0].text.includes("Please run /login")) {
|
|
846
|
-
|
|
1485
|
+
failActive(RequestError.authRequired());
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1488
|
+
let content;
|
|
1489
|
+
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
1490
|
+
// Top-level assistant message: each text/thinking block may have
|
|
1491
|
+
// already been streamed live as deltas. Diff each against what
|
|
1492
|
+
// streamed (`streamedBlocks`, in document order) and forward only
|
|
1493
|
+
// the un-streamed remainder — nothing if it streamed in full (the
|
|
1494
|
+
// common case), the whole block if it never streamed (a
|
|
1495
|
+
// non-streaming gateway), or just the tail if the stream was cut
|
|
1496
|
+
// short mid-block. `streamPos` walks the streamed blocks in step
|
|
1497
|
+
// with the assembled text/thinking blocks; tool_use and other
|
|
1498
|
+
// blocks pass through untouched (their own `toolUseCache` collapses
|
|
1499
|
+
// the streamed/assembled pair) without advancing it.
|
|
1500
|
+
const blocks = message.message.content;
|
|
1501
|
+
const kept = [];
|
|
1502
|
+
let streamPos = 0;
|
|
1503
|
+
for (const item of blocks) {
|
|
1504
|
+
if (item.type !== "text" && item.type !== "thinking") {
|
|
1505
|
+
kept.push(item);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
const full = item.type === "text" ? item.text : item.thinking;
|
|
1509
|
+
// Empty assembled blocks carry nothing (some gateways emit an
|
|
1510
|
+
// empty `thinking` block before the real text) — drop them.
|
|
1511
|
+
if (full.length === 0) {
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
// A streamed block of the same type whose accumulated text is a
|
|
1515
|
+
// prefix of this one was already (at least partly) delivered as
|
|
1516
|
+
// chunks; consume it and forward only what's left. A non-empty
|
|
1517
|
+
// streamed text is required so an empty/aborted streamed block
|
|
1518
|
+
// doesn't swallow the assembled copy.
|
|
1519
|
+
const streamed = streamedBlocks[streamPos];
|
|
1520
|
+
if (streamed &&
|
|
1521
|
+
streamed.type === item.type &&
|
|
1522
|
+
streamed.text.length > 0 &&
|
|
1523
|
+
full.startsWith(streamed.text)) {
|
|
1524
|
+
streamPos++;
|
|
1525
|
+
const remainder = full.slice(streamed.text.length);
|
|
1526
|
+
if (remainder.length === 0) {
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
// Overwrite in place with just the un-streamed tail (the
|
|
1530
|
+
// assembled message isn't read again after this) so the block
|
|
1531
|
+
// keeps its exact SDK type.
|
|
1532
|
+
if (item.type === "text") {
|
|
1533
|
+
item.text = remainder;
|
|
1534
|
+
}
|
|
1535
|
+
else {
|
|
1536
|
+
item.thinking = remainder;
|
|
1537
|
+
}
|
|
1538
|
+
kept.push(item);
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
// Not matched: never streamed (or the stream diverged from the
|
|
1542
|
+
// assembled text) — forward the block in full.
|
|
1543
|
+
kept.push(item);
|
|
1544
|
+
}
|
|
1545
|
+
content = kept;
|
|
1546
|
+
// Consumed: reset so the next message's blocks accumulate fresh and
|
|
1547
|
+
// the record stays bounded to the in-flight message.
|
|
1548
|
+
streamedBlocks.length = 0;
|
|
1549
|
+
}
|
|
1550
|
+
else if (message.type === "assistant") {
|
|
1551
|
+
// Subagent assistant message (`parent_tool_use_id !== null`). It is
|
|
1552
|
+
// never streamed live and its text/thinking is internal to the tool
|
|
1553
|
+
// call — keep dropping it so subagent prose doesn't leak into the
|
|
1554
|
+
// top-level feed.
|
|
1555
|
+
content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
|
|
847
1556
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger, {
|
|
1557
|
+
else {
|
|
1558
|
+
content = message.message.content;
|
|
1559
|
+
}
|
|
1560
|
+
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
853
1561
|
clientCapabilities: this.clientCapabilities,
|
|
854
1562
|
parentToolUseId: message.parent_tool_use_id,
|
|
855
1563
|
cwd: session.cwd,
|
|
856
1564
|
taskState: session.taskState,
|
|
1565
|
+
messageId: messageIdForGrouping(message),
|
|
857
1566
|
})) {
|
|
858
1567
|
await this.client.sessionUpdate(notification);
|
|
859
1568
|
}
|
|
860
1569
|
break;
|
|
861
1570
|
}
|
|
862
|
-
case "tool_progress":
|
|
1571
|
+
case "tool_progress": {
|
|
1572
|
+
await this.client.sessionUpdate({
|
|
1573
|
+
sessionId: message.session_id,
|
|
1574
|
+
update: {
|
|
1575
|
+
sessionUpdate: "tool_call_update",
|
|
1576
|
+
toolCallId: message.tool_use_id,
|
|
1577
|
+
status: "in_progress",
|
|
1578
|
+
_meta: {
|
|
1579
|
+
claudeCode: {
|
|
1580
|
+
toolName: message.tool_name,
|
|
1581
|
+
toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
|
|
1582
|
+
},
|
|
1583
|
+
},
|
|
1584
|
+
},
|
|
1585
|
+
});
|
|
1586
|
+
break;
|
|
1587
|
+
}
|
|
1588
|
+
case "rate_limit_event": {
|
|
1589
|
+
if (lastAssistantTotalUsage !== null) {
|
|
1590
|
+
await this.client.sessionUpdate({
|
|
1591
|
+
sessionId: message.session_id,
|
|
1592
|
+
update: {
|
|
1593
|
+
sessionUpdate: "usage_update",
|
|
1594
|
+
used: lastAssistantTotalUsage,
|
|
1595
|
+
size: session.contextWindowSize,
|
|
1596
|
+
_meta: { "_claude/rateLimit": message.rate_limit_info },
|
|
1597
|
+
},
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
863
1602
|
case "tool_use_summary":
|
|
864
1603
|
case "auth_status":
|
|
865
1604
|
case "prompt_suggestion":
|
|
866
|
-
case "rate_limit_event":
|
|
867
1605
|
break;
|
|
868
1606
|
default:
|
|
869
1607
|
unreachable(message);
|
|
870
1608
|
break;
|
|
871
1609
|
}
|
|
872
1610
|
}
|
|
873
|
-
|
|
1611
|
+
// `while (true)` only exits via the `done` return above or the catch
|
|
1612
|
+
// below, so there is no normal fall-through here.
|
|
874
1613
|
}
|
|
875
1614
|
catch (error) {
|
|
876
|
-
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
// the
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
this.logger.error(`Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`);
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
catch (drainErr) {
|
|
898
|
-
this.logger.error(`Session ${params.sessionId}: failed to drain query after prompt error:`, drainErr);
|
|
899
|
-
}
|
|
900
|
-
if (error instanceof RequestError || !(error instanceof Error)) {
|
|
901
|
-
throw error;
|
|
902
|
-
}
|
|
903
|
-
const message = error.message;
|
|
904
|
-
if (message.includes("ProcessTransport") ||
|
|
905
|
-
message.includes("terminated process") ||
|
|
906
|
-
message.includes("process exited with") ||
|
|
907
|
-
message.includes("process terminated by signal") ||
|
|
908
|
-
message.includes("Failed to write to process stdin")) {
|
|
1615
|
+
// The query stream itself died (a transport/process error surfaced from
|
|
1616
|
+
// query.next()). Turn-level failures (auth, error results) are handled
|
|
1617
|
+
// inline via failActive and never reach here. Reject every in-flight turn;
|
|
1618
|
+
// if the process is gone, tear the session down so the client starts fresh.
|
|
1619
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1620
|
+
const processDied = error instanceof Error &&
|
|
1621
|
+
(message.includes("ProcessTransport") ||
|
|
1622
|
+
message.includes("terminated process") ||
|
|
1623
|
+
message.includes("process exited with") ||
|
|
1624
|
+
message.includes("process terminated by signal") ||
|
|
1625
|
+
message.includes("Failed to write to process stdin"));
|
|
1626
|
+
// Either way the query iterator is finished and the consumer is exiting,
|
|
1627
|
+
// so release its resources via closeQueryStream (idempotent). A process
|
|
1628
|
+
// death is unrecoverable, so additionally evict the session so the client
|
|
1629
|
+
// starts fresh; other stream errors keep the session so prompt()/cancel()
|
|
1630
|
+
// can answer with a clear "session ended" error.
|
|
1631
|
+
if (processDied) {
|
|
909
1632
|
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
|
|
910
|
-
session.
|
|
911
|
-
|
|
1633
|
+
failAllTurns(RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session."));
|
|
1634
|
+
this.closeQueryStream(session);
|
|
912
1635
|
delete this.sessions[params.sessionId];
|
|
913
|
-
throw RequestError.internalError(undefined, "The Claude Agent process exited unexpectedly. Please start a new session.");
|
|
914
1636
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
session.promptRunning = false;
|
|
920
|
-
if (errored) {
|
|
921
|
-
// The query stream was just drained — handing pending prompts off
|
|
922
|
-
// onto it would let them race with the recovery. Cancel them so
|
|
923
|
-
// each waiting prompt() returns stopReason: "cancelled" and the
|
|
924
|
-
// client can decide whether to retry.
|
|
925
|
-
for (const pending of session.pendingMessages.values()) {
|
|
926
|
-
pending.resolve(true);
|
|
927
|
-
}
|
|
928
|
-
session.pendingMessages.clear();
|
|
929
|
-
}
|
|
930
|
-
else if (session.pendingMessages.size > 0) {
|
|
931
|
-
// This usually should not happen, but in case the loop finishes
|
|
932
|
-
// without claude sending all message replays, we resolve the
|
|
933
|
-
// next pending prompt call to ensure no prompts get stuck.
|
|
934
|
-
const next = [...session.pendingMessages.entries()].sort((a, b) => a[1].order - b[1].order)[0];
|
|
935
|
-
if (next) {
|
|
936
|
-
next[1].resolve(false);
|
|
937
|
-
session.pendingMessages.delete(next[0]);
|
|
938
|
-
}
|
|
939
|
-
}
|
|
1637
|
+
else {
|
|
1638
|
+
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
|
1639
|
+
failAllTurns(error);
|
|
1640
|
+
this.closeQueryStream(session);
|
|
940
1641
|
}
|
|
941
1642
|
}
|
|
942
1643
|
}
|
|
@@ -945,24 +1646,112 @@ export class ClaudeAcpAgent {
|
|
|
945
1646
|
if (!session) {
|
|
946
1647
|
return;
|
|
947
1648
|
}
|
|
1649
|
+
// The stream already ended (see closeQueryStream): every in-flight turn was
|
|
1650
|
+
// settled when it closed, and there is no live query to interrupt. Calling
|
|
1651
|
+
// query.interrupt() on a finished iterator could reject and surface from
|
|
1652
|
+
// this fire-and-forget notification, so there is nothing to do here.
|
|
1653
|
+
if (session.queryClosed) {
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
948
1656
|
session.cancelled = true;
|
|
949
|
-
|
|
950
|
-
|
|
1657
|
+
// Settle queued turns that haven't started yet (no echo seen) right away —
|
|
1658
|
+
// they have no in-flight SDK work to interrupt. The active turn is settled
|
|
1659
|
+
// by the consumer when it observes the interrupt's trailing idle (or via the
|
|
1660
|
+
// backstop below). Mirrors the old pendingMessages cancellation.
|
|
1661
|
+
if (session.turnQueue) {
|
|
1662
|
+
let orphaned = 0;
|
|
1663
|
+
for (const turn of session.turnQueue) {
|
|
1664
|
+
if (turn !== session.activeTurn && !turn.settled) {
|
|
1665
|
+
turn.settled = true;
|
|
1666
|
+
turn.resolve({ stopReason: "cancelled" });
|
|
1667
|
+
orphaned++;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
// Each removed queued turn's user message was already pushed to the SDK,
|
|
1671
|
+
// which processes input FIFO and will still emit a result for it with no
|
|
1672
|
+
// uuid to match. Count those so the consumer skips them (see
|
|
1673
|
+
// ensureActiveTurn) rather than misattributing them to the head.
|
|
1674
|
+
session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
|
|
1675
|
+
session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
|
|
1676
|
+
}
|
|
1677
|
+
// Arm a backstop before interrupting: if a turn is actively consuming the
|
|
1678
|
+
// query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
|
|
1679
|
+
// block — issue #680), force the consumer to settle the active turn
|
|
1680
|
+
// "cancelled" after the floor elapses so the pending session/prompt still
|
|
1681
|
+
// resolves per the ACP cancellation contract instead of hanging forever. The
|
|
1682
|
+
// consumer clears this timer when interrupt() works and it settles through
|
|
1683
|
+
// the normal idle path, so on healthy cancels it is armed but never fires.
|
|
1684
|
+
//
|
|
1685
|
+
// Arm at most once per turn: the floor is an absolute ceiling from the first
|
|
1686
|
+
// cancel, so a client that re-sends cancel (each call still retries
|
|
1687
|
+
// interrupt() below) can't keep pushing the deadline out.
|
|
1688
|
+
if (session.activeTurn &&
|
|
1689
|
+
session.cancelController &&
|
|
1690
|
+
!session.cancelController.signal.aborted &&
|
|
1691
|
+
!session.forceCancelTimer) {
|
|
1692
|
+
const cancelController = session.cancelController;
|
|
1693
|
+
session.forceCancelTimer = setTimeout(() => {
|
|
1694
|
+
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.`);
|
|
1695
|
+
cancelController.abort();
|
|
1696
|
+
}, this.forceCancelGraceMs);
|
|
951
1697
|
}
|
|
952
|
-
session.pendingMessages.clear();
|
|
953
1698
|
await session.query.interrupt();
|
|
954
1699
|
}
|
|
955
|
-
/**
|
|
956
|
-
*
|
|
1700
|
+
/** Mark a session's SDK query stream as permanently ended and release the
|
|
1701
|
+
* resources tied to it: drop the consumer handle, dispose the settings
|
|
1702
|
+
* watchers, end the input stream, and close the query (which terminates the
|
|
1703
|
+
* subprocess). The query iterator is not revivable, so `prompt()`/`cancel()`
|
|
1704
|
+
* consult `queryClosed` and fail/short-circuit instead of acting on a dead
|
|
1705
|
+
* stream. Idempotent (guarded by `queryClosed`), so the consumer's done/error
|
|
1706
|
+
* paths and a later `teardownSession` can all call it without double-releasing.
|
|
1707
|
+
*
|
|
1708
|
+
* Deliberately does NOT abort `session.abortController`: that controller may be
|
|
1709
|
+
* CLIENT-supplied (`_meta.claudeCode.options.abortController`) and reused, so
|
|
1710
|
+
* aborting it on a spontaneous stream end would cancel the client's own work
|
|
1711
|
+
* or make a sibling session born aborted. `query.close()` already terminates
|
|
1712
|
+
* the subprocess; aborting the signal belongs in `teardownSession` (explicit
|
|
1713
|
+
* destroy), not here. Also does NOT remove the session from the map — that is
|
|
1714
|
+
* `teardownSession`'s job — so prompt() can still answer with a clear "session
|
|
1715
|
+
* ended" error after an unexpected stream close. The leftover session object
|
|
1716
|
+
* is a lightweight husk (its heavy resources are released here) and is evicted
|
|
1717
|
+
* on the next closeSession/deleteSession or when the connection's `dispose()`
|
|
1718
|
+
* runs. */
|
|
1719
|
+
closeQueryStream(session) {
|
|
1720
|
+
if (session.queryClosed) {
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
session.queryClosed = true;
|
|
1724
|
+
session.consumer = undefined;
|
|
1725
|
+
session.settingsManager.dispose();
|
|
1726
|
+
session.input.end();
|
|
1727
|
+
session.query.close();
|
|
1728
|
+
}
|
|
1729
|
+
/** Cleanly tear down a session: cancel in-flight work, release stream
|
|
1730
|
+
* resources, and remove it from the session map. */
|
|
957
1731
|
async teardownSession(sessionId) {
|
|
958
1732
|
const session = this.sessions[sessionId];
|
|
959
1733
|
if (!session) {
|
|
960
1734
|
return;
|
|
961
1735
|
}
|
|
962
1736
|
await this.cancel({ sessionId });
|
|
963
|
-
|
|
1737
|
+
// cancel() arms the force-cancel floor and interrupts gracefully, but a
|
|
1738
|
+
// wedged consumer only wakes when `cancelController` aborts — closeQueryStream
|
|
1739
|
+
// below doesn't touch it. Since we're tearing the session down anyway, wake
|
|
1740
|
+
// the consumer now so the in-flight prompt() resolves immediately instead of
|
|
1741
|
+
// after the floor, and clear the timer so it can't outlive the deleted
|
|
1742
|
+
// session (it isn't unref'd and would otherwise keep the event loop alive
|
|
1743
|
+
// until it fires).
|
|
1744
|
+
if (session.forceCancelTimer) {
|
|
1745
|
+
clearTimeout(session.forceCancelTimer);
|
|
1746
|
+
session.forceCancelTimer = undefined;
|
|
1747
|
+
}
|
|
1748
|
+
session.cancelController?.abort();
|
|
1749
|
+
this.closeQueryStream(session);
|
|
1750
|
+
// Abort the SDK abort signal only on explicit destroy. closeQueryStream
|
|
1751
|
+
// leaves it alone (it may be a client-owned controller — see its doc), but
|
|
1752
|
+
// here the client has asked us to close the session, so signalling abort is
|
|
1753
|
+
// appropriate; query.close() above has already torn the subprocess down.
|
|
964
1754
|
session.abortController.abort();
|
|
965
|
-
session.query.close();
|
|
966
1755
|
delete this.sessions[sessionId];
|
|
967
1756
|
}
|
|
968
1757
|
/** Tear down all active sessions. Called when the ACP connection closes. */
|
|
@@ -976,7 +1765,7 @@ export class ClaudeAcpAgent {
|
|
|
976
1765
|
await this.teardownSession(params.sessionId);
|
|
977
1766
|
return {};
|
|
978
1767
|
}
|
|
979
|
-
async
|
|
1768
|
+
async deleteSession(params) {
|
|
980
1769
|
// Tear down any active in-memory state first so the on-disk file isn't
|
|
981
1770
|
// recreated by an outstanding query writing to it.
|
|
982
1771
|
if (this.sessions[params.sessionId]) {
|
|
@@ -985,22 +1774,16 @@ export class ClaudeAcpAgent {
|
|
|
985
1774
|
await deleteSession(params.sessionId);
|
|
986
1775
|
return {};
|
|
987
1776
|
}
|
|
988
|
-
async
|
|
1777
|
+
async setSessionMode(params) {
|
|
989
1778
|
const session = this.sessions[params.sessionId];
|
|
990
1779
|
if (!session) {
|
|
991
1780
|
throw new Error("Session not found");
|
|
992
1781
|
}
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
await session.query.setModel(modelId);
|
|
999
|
-
await this.updateConfigOption(params.sessionId, "model", modelId);
|
|
1000
|
-
}
|
|
1001
|
-
async setSessionMode(params) {
|
|
1002
|
-
if (!this.sessions[params.sessionId]) {
|
|
1003
|
-
throw new Error("Session not found");
|
|
1782
|
+
// The SDK query stream already ended (see closeQueryStream); the session is
|
|
1783
|
+
// a husk and `query.setPermissionMode` below would act on a closed query.
|
|
1784
|
+
// Fail with the same clear message prompt()/cancel() give for a dead stream.
|
|
1785
|
+
if (session.queryClosed) {
|
|
1786
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1004
1787
|
}
|
|
1005
1788
|
await this.applySessionMode(params.sessionId, params.modeId);
|
|
1006
1789
|
await this.updateConfigOption(params.sessionId, "mode", params.modeId);
|
|
@@ -1011,6 +1794,13 @@ export class ClaudeAcpAgent {
|
|
|
1011
1794
|
if (!session) {
|
|
1012
1795
|
throw new Error("Session not found");
|
|
1013
1796
|
}
|
|
1797
|
+
// The SDK query stream already ended (see closeQueryStream); the session is
|
|
1798
|
+
// a husk and the `query.setModel`/`setPermissionMode`/`applyFlagSettings`
|
|
1799
|
+
// calls this triggers would act on a closed query. Fail with the same clear
|
|
1800
|
+
// message prompt()/cancel() give for a dead stream.
|
|
1801
|
+
if (session.queryClosed) {
|
|
1802
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1803
|
+
}
|
|
1014
1804
|
if (typeof params.value !== "string") {
|
|
1015
1805
|
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
|
1016
1806
|
}
|
|
@@ -1100,6 +1890,15 @@ export class ClaudeAcpAgent {
|
|
|
1100
1890
|
const toolUseCache = {};
|
|
1101
1891
|
const messages = await getSessionMessages(sessionId);
|
|
1102
1892
|
for (const message of messages) {
|
|
1893
|
+
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
|
1894
|
+
// observe live (resumed/loaded sessions), so rewind/resume can translate
|
|
1895
|
+
// a client-supplied id without an extra getSessionMessages read. Not read
|
|
1896
|
+
// yet (see Session.messageIdToUuid).
|
|
1897
|
+
const replayMessageId = messageIdForGrouping(message);
|
|
1898
|
+
const replaySession = this.sessions[sessionId];
|
|
1899
|
+
if (replaySession && replayMessageId && message.uuid) {
|
|
1900
|
+
replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
|
|
1901
|
+
}
|
|
1103
1902
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
1104
1903
|
let content = message.message.content;
|
|
1105
1904
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
@@ -1117,6 +1916,7 @@ export class ClaudeAcpAgent {
|
|
|
1117
1916
|
clientCapabilities: this.clientCapabilities,
|
|
1118
1917
|
cwd: this.sessions[sessionId]?.cwd,
|
|
1119
1918
|
taskState: this.sessions[sessionId]?.taskState,
|
|
1919
|
+
messageId: replayMessageId,
|
|
1120
1920
|
})) {
|
|
1121
1921
|
await this.client.sessionUpdate(notification);
|
|
1122
1922
|
}
|
|
@@ -1130,6 +1930,24 @@ export class ClaudeAcpAgent {
|
|
|
1130
1930
|
const response = await this.client.writeTextFile(params);
|
|
1131
1931
|
return response;
|
|
1132
1932
|
}
|
|
1933
|
+
/** Forward a permission request to the client, wiring the tool call's
|
|
1934
|
+
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
|
1935
|
+
* while the client's prompt is still open the signal aborts, the SDK sends
|
|
1936
|
+
* `$/cancel_request`, and the client settles the request (a `cancelled`
|
|
1937
|
+
* outcome or a `requestCancelled` rejection). Either way we surface the same
|
|
1938
|
+
* "Tool use aborted" the callers already expect, so a cancelled dialog no
|
|
1939
|
+
* longer leaves the `await` hanging. */
|
|
1940
|
+
async requestPermissionFromClient(params, signal) {
|
|
1941
|
+
try {
|
|
1942
|
+
return await this.client.requestPermission(params, signal);
|
|
1943
|
+
}
|
|
1944
|
+
catch (error) {
|
|
1945
|
+
if (signal.aborted) {
|
|
1946
|
+
throw new Error("Tool use aborted", { cause: error });
|
|
1947
|
+
}
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1133
1951
|
canUseTool(sessionId) {
|
|
1134
1952
|
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
|
1135
1953
|
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
|
@@ -1141,6 +1959,13 @@ export class ClaudeAcpAgent {
|
|
|
1141
1959
|
message: "Session not found",
|
|
1142
1960
|
};
|
|
1143
1961
|
}
|
|
1962
|
+
// AskUserQuestion is surfaced to us as a normal permission check (the SDK
|
|
1963
|
+
// routes it through canUseTool whenever a callback is registered, rather
|
|
1964
|
+
// than the interactive dialog). Present it as an ACP form elicitation and
|
|
1965
|
+
// feed the answers back as updatedInput for the tool's own call() to read.
|
|
1966
|
+
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
|
1967
|
+
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
|
1968
|
+
}
|
|
1144
1969
|
if (toolName === "ExitPlanMode") {
|
|
1145
1970
|
const optionsAll = [
|
|
1146
1971
|
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
|
@@ -1165,7 +1990,7 @@ export class ClaudeAcpAgent {
|
|
|
1165
1990
|
// `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
|
|
1166
1991
|
// "keep planning" reject path; it's always present in `availableModes`.
|
|
1167
1992
|
const options = optionsAll.filter((o) => session.modes.availableModes.some((m) => m.id === o.optionId));
|
|
1168
|
-
const response = await this.
|
|
1993
|
+
const response = await this.requestPermissionFromClient({
|
|
1169
1994
|
options,
|
|
1170
1995
|
sessionId,
|
|
1171
1996
|
toolCall: {
|
|
@@ -1173,7 +1998,7 @@ export class ClaudeAcpAgent {
|
|
|
1173
1998
|
rawInput: toolInput,
|
|
1174
1999
|
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
1175
2000
|
},
|
|
1176
|
-
});
|
|
2001
|
+
}, signal);
|
|
1177
2002
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
1178
2003
|
throw new Error("Tool use aborted");
|
|
1179
2004
|
}
|
|
@@ -1216,7 +2041,7 @@ export class ClaudeAcpAgent {
|
|
|
1216
2041
|
],
|
|
1217
2042
|
};
|
|
1218
2043
|
}
|
|
1219
|
-
const response = await this.
|
|
2044
|
+
const response = await this.requestPermissionFromClient({
|
|
1220
2045
|
options: [
|
|
1221
2046
|
{
|
|
1222
2047
|
kind: "allow_always",
|
|
@@ -1232,7 +2057,7 @@ export class ClaudeAcpAgent {
|
|
|
1232
2057
|
rawInput: toolInput,
|
|
1233
2058
|
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
1234
2059
|
},
|
|
1235
|
-
});
|
|
2060
|
+
}, signal);
|
|
1236
2061
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
1237
2062
|
throw new Error("Tool use aborted");
|
|
1238
2063
|
}
|
|
@@ -1266,6 +2091,72 @@ export class ClaudeAcpAgent {
|
|
|
1266
2091
|
}
|
|
1267
2092
|
};
|
|
1268
2093
|
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Handle elicitation requests that originate from MCP servers by forwarding
|
|
2096
|
+
* them to the client over ACP. Modes the client did not advertise (or
|
|
2097
|
+
* requests we can't represent) are declined.
|
|
2098
|
+
*/
|
|
2099
|
+
handleMcpElicitation(sessionId, support) {
|
|
2100
|
+
return async (request, { signal }) => {
|
|
2101
|
+
const isUrl = request.mode === "url";
|
|
2102
|
+
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
|
2103
|
+
return { action: "decline" };
|
|
2104
|
+
}
|
|
2105
|
+
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
|
2106
|
+
if (!createRequest) {
|
|
2107
|
+
return { action: "decline" };
|
|
2108
|
+
}
|
|
2109
|
+
try {
|
|
2110
|
+
const response = await this.client.unstable_createElicitation(createRequest, signal);
|
|
2111
|
+
if (signal.aborted) {
|
|
2112
|
+
return { action: "cancel" };
|
|
2113
|
+
}
|
|
2114
|
+
return createElicitationResponseToElicitResult(response);
|
|
2115
|
+
}
|
|
2116
|
+
catch (error) {
|
|
2117
|
+
// A cancellation we requested (signal aborted) settles as a cancel, not
|
|
2118
|
+
// a hard decline — the elicitation was abandoned, not refused.
|
|
2119
|
+
if (signal.aborted) {
|
|
2120
|
+
return { action: "cancel" };
|
|
2121
|
+
}
|
|
2122
|
+
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
|
2123
|
+
return { action: "decline" };
|
|
2124
|
+
}
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Present the built-in AskUserQuestion tool's questions as an ACP form
|
|
2129
|
+
* elicitation and return the answers as the tool's `updatedInput`. Called from
|
|
2130
|
+
* `canUseTool` since that is where the SDK routes the tool's permission check.
|
|
2131
|
+
*/
|
|
2132
|
+
async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal) {
|
|
2133
|
+
const questions = extractAskUserQuestions(toolInput);
|
|
2134
|
+
if (!questions) {
|
|
2135
|
+
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
|
2136
|
+
}
|
|
2137
|
+
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
|
2138
|
+
let response;
|
|
2139
|
+
try {
|
|
2140
|
+
response = await this.client.unstable_createElicitation(createRequest, signal);
|
|
2141
|
+
}
|
|
2142
|
+
catch (error) {
|
|
2143
|
+
// A cancellation we requested (signal aborted) settles as an aborted tool
|
|
2144
|
+
// use, matching the post-response check below.
|
|
2145
|
+
if (signal.aborted) {
|
|
2146
|
+
throw new Error("Tool use aborted", { cause: error });
|
|
2147
|
+
}
|
|
2148
|
+
this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
|
2149
|
+
return { behavior: "deny", message: "Could not present the question to the user." };
|
|
2150
|
+
}
|
|
2151
|
+
if (signal.aborted) {
|
|
2152
|
+
throw new Error("Tool use aborted");
|
|
2153
|
+
}
|
|
2154
|
+
const outcome = applyAskElicitationResponse(response, toolInput, questions);
|
|
2155
|
+
if (outcome.action === "cancel") {
|
|
2156
|
+
throw new Error("Tool use aborted");
|
|
2157
|
+
}
|
|
2158
|
+
return { behavior: "allow", updatedInput: outcome.updatedInput };
|
|
2159
|
+
}
|
|
1269
2160
|
async sendAvailableCommandsUpdate(sessionId) {
|
|
1270
2161
|
const session = this.sessions[sessionId];
|
|
1271
2162
|
if (!session)
|
|
@@ -1298,18 +2189,22 @@ export class ClaudeAcpAgent {
|
|
|
1298
2189
|
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
1299
2190
|
}
|
|
1300
2191
|
else if (configId === "model") {
|
|
2192
|
+
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
|
|
2193
|
+
// modes below; its `displayName`/`description` also let us infer the
|
|
2194
|
+
// context window for semantic aliases (e.g. `default`) whose ID alone
|
|
2195
|
+
// carries no "1m" token.
|
|
2196
|
+
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
1301
2197
|
if (session.models.currentModelId !== value) {
|
|
1302
2198
|
// The cached context window was learned for the previous model; reset
|
|
1303
2199
|
// to the new model's heuristic so mid-stream updates between now and
|
|
1304
2200
|
// the next `result` reflect the user's selection instead of the old
|
|
1305
2201
|
// model's window.
|
|
1306
|
-
session.contextWindowSize =
|
|
2202
|
+
session.contextWindowSize =
|
|
2203
|
+
inferContextWindowFromModel(value, newModelInfo?.displayName, newModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW;
|
|
1307
2204
|
}
|
|
1308
2205
|
session.models = { ...session.models, currentModelId: value };
|
|
1309
2206
|
// Recompute availableModes for the new model and clamp the current
|
|
1310
2207
|
// mode if the SDK no longer offers it (today: "auto" on Haiku).
|
|
1311
|
-
// `ModelInfo.supportsAutoMode` is the canonical SDK signal.
|
|
1312
|
-
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
1313
2208
|
const newAvailableModes = buildAvailableModes(newModelInfo);
|
|
1314
2209
|
// Capture BEFORE mutating session.modes so the log message reflects
|
|
1315
2210
|
// the invalidated mode rather than "default".
|
|
@@ -1338,7 +2233,7 @@ export class ClaudeAcpAgent {
|
|
|
1338
2233
|
// Rebuild config options since effort levels depend on the selected model
|
|
1339
2234
|
const effortOpt = session.configOptions.find((o) => o.id === "effort");
|
|
1340
2235
|
const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
|
1341
|
-
session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort);
|
|
2236
|
+
session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort, session.agents, session.currentAgent);
|
|
1342
2237
|
// Sync effort with the SDK if it changed after the model switch
|
|
1343
2238
|
const newEffortOpt = session.configOptions.find((o) => o.id === "effort");
|
|
1344
2239
|
const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
|
@@ -1363,6 +2258,18 @@ export class ClaudeAcpAgent {
|
|
|
1363
2258
|
});
|
|
1364
2259
|
}
|
|
1365
2260
|
}
|
|
2261
|
+
else if (configId === "agent") {
|
|
2262
|
+
// Live agent switch — no subprocess restart needed. Apply the SDK flag
|
|
2263
|
+
// first so a rejected control request leaves both `currentAgent` and the
|
|
2264
|
+
// config option untouched (no UI/SDK desync). Passing `null` clears the
|
|
2265
|
+
// flag layer back to the standard Claude Code agent; the change takes
|
|
2266
|
+
// effect on the next turn (SDK >= 0.3.161).
|
|
2267
|
+
await session.query.applyFlagSettings({
|
|
2268
|
+
agent: value === DEFAULT_AGENT_ID ? null : value,
|
|
2269
|
+
});
|
|
2270
|
+
session.currentAgent = value;
|
|
2271
|
+
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
2272
|
+
}
|
|
1366
2273
|
else {
|
|
1367
2274
|
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
1368
2275
|
if (configId === "effort") {
|
|
@@ -1380,7 +2287,6 @@ export class ClaudeAcpAgent {
|
|
|
1380
2287
|
return {
|
|
1381
2288
|
sessionId: params.sessionId,
|
|
1382
2289
|
modes: existingSession.modes,
|
|
1383
|
-
models: existingSession.models,
|
|
1384
2290
|
configOptions: existingSession.configOptions,
|
|
1385
2291
|
};
|
|
1386
2292
|
}
|
|
@@ -1400,11 +2306,37 @@ export class ClaudeAcpAgent {
|
|
|
1400
2306
|
return {
|
|
1401
2307
|
sessionId: response.sessionId,
|
|
1402
2308
|
modes: response.modes,
|
|
1403
|
-
models: response.models,
|
|
1404
2309
|
configOptions: response.configOptions,
|
|
1405
2310
|
};
|
|
1406
2311
|
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Ensures the requested `cwd` is an absolute path that points at an existing
|
|
2314
|
+
* directory before we create a session. Throws an `invalidParams` error with
|
|
2315
|
+
* an actionable message so clients (e.g. Zed) can surface it to the user
|
|
2316
|
+
* instead of failing later with an opaque SDK error.
|
|
2317
|
+
*/
|
|
2318
|
+
async validateCwd(cwd) {
|
|
2319
|
+
if (!path.isAbsolute(cwd)) {
|
|
2320
|
+
throw RequestError.invalidParams({ cwd }, `\`cwd\` must be an absolute path, but received: ${cwd}`);
|
|
2321
|
+
}
|
|
2322
|
+
let stats;
|
|
2323
|
+
try {
|
|
2324
|
+
stats = await fs.stat(cwd);
|
|
2325
|
+
}
|
|
2326
|
+
catch {
|
|
2327
|
+
throw RequestError.invalidParams({ cwd }, `\`cwd\` does not exist on the machine running the agent: ${cwd}`);
|
|
2328
|
+
}
|
|
2329
|
+
if (!stats.isDirectory()) {
|
|
2330
|
+
throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
1407
2333
|
async createSession(params, creationOpts = {}) {
|
|
2334
|
+
// Validate `cwd` up front. The ACP spec requires an absolute path, and the
|
|
2335
|
+
// directory must actually exist on the machine running the agent. Without
|
|
2336
|
+
// this check a session is created against a missing directory and the
|
|
2337
|
+
// failure only surfaces later as a confusing "native binary failed to
|
|
2338
|
+
// launch" error from the SDK (see issue #749).
|
|
2339
|
+
await this.validateCwd(params.cwd);
|
|
1408
2340
|
// We want to create a new session id unless it is resume,
|
|
1409
2341
|
// but not resume + forkSession.
|
|
1410
2342
|
let sessionId;
|
|
@@ -1470,13 +2402,17 @@ export class ClaudeAcpAgent {
|
|
|
1470
2402
|
// Extract options from _meta if provided
|
|
1471
2403
|
const sessionMeta = params._meta;
|
|
1472
2404
|
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
1473
|
-
// Configure thinking
|
|
1474
|
-
const
|
|
1475
|
-
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
|
|
1476
|
-
: undefined;
|
|
2405
|
+
// Configure thinking behavior from environment variable
|
|
2406
|
+
const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
|
|
1477
2407
|
// Parse model configuration from environment (e.g. Bedrock model overrides)
|
|
1478
2408
|
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
|
|
1479
|
-
//
|
|
2409
|
+
// Elicitation modes the connected client advertised. We only forward
|
|
2410
|
+
// elicitations (and only re-enable AskUserQuestion) for modes the client
|
|
2411
|
+
// can actually render.
|
|
2412
|
+
const elicitationSupport = {
|
|
2413
|
+
form: !!this.clientCapabilities?.elicitation?.form,
|
|
2414
|
+
url: !!this.clientCapabilities?.elicitation?.url,
|
|
2415
|
+
};
|
|
1480
2416
|
const disallowedTools = ["AskUserQuestion", "WebFetch", "WebSearch"];
|
|
1481
2417
|
// Resolve which built-in tools to expose.
|
|
1482
2418
|
// Explicit tools array from _meta.claudeCode.options takes precedence.
|
|
@@ -1492,7 +2428,7 @@ export class ClaudeAcpAgent {
|
|
|
1492
2428
|
const options = {
|
|
1493
2429
|
systemPrompt,
|
|
1494
2430
|
settingSources: ["user", "project", "local"],
|
|
1495
|
-
...(
|
|
2431
|
+
...(thinking !== undefined && { thinking }),
|
|
1496
2432
|
...userProvidedOptions,
|
|
1497
2433
|
// CLAUDE_MODEL_CONFIG env var is a fallback for model
|
|
1498
2434
|
// configuration (e.g. Bedrock model ID overrides). When the caller
|
|
@@ -1521,6 +2457,13 @@ export class ClaudeAcpAgent {
|
|
|
1521
2457
|
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
1522
2458
|
permissionMode,
|
|
1523
2459
|
canUseTool: this.canUseTool(sessionId),
|
|
2460
|
+
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
|
2461
|
+
// when the client advertised support, so non-supporting clients keep the
|
|
2462
|
+
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
|
|
2463
|
+
// canUseTool, not here.)
|
|
2464
|
+
...(elicitationSupport.form || elicitationSupport.url
|
|
2465
|
+
? { onElicitation: this.handleMcpElicitation(sessionId, elicitationSupport) }
|
|
2466
|
+
: {}),
|
|
1524
2467
|
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
|
|
1525
2468
|
extraArgs: {
|
|
1526
2469
|
...userProvidedOptions?.extraArgs,
|
|
@@ -1675,7 +2618,17 @@ export class ClaudeAcpAgent {
|
|
|
1675
2618
|
currentModeId: effectiveMode,
|
|
1676
2619
|
availableModes,
|
|
1677
2620
|
};
|
|
1678
|
-
const
|
|
2621
|
+
const agents = await discoverCustomAgents(q);
|
|
2622
|
+
// Only adopt the requested agent as the selected value if it's one we
|
|
2623
|
+
// actually surface in the picker. A built-in (filtered out above) or
|
|
2624
|
+
// otherwise-unknown name would leave the config option's `currentValue`
|
|
2625
|
+
// pointing at an entry not in its own `options` list, which clients render
|
|
2626
|
+
// as a blank/invalid selection.
|
|
2627
|
+
const requestedAgent = userProvidedOptions?.agent;
|
|
2628
|
+
const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
|
|
2629
|
+
? requestedAgent
|
|
2630
|
+
: DEFAULT_AGENT_ID;
|
|
2631
|
+
const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel, agents, currentAgent);
|
|
1679
2632
|
// Apply the initial effort level to the SDK so it matches the UI default
|
|
1680
2633
|
const initialEffort = configOptions.find((o) => o.id === "effort");
|
|
1681
2634
|
if (initialEffort &&
|
|
@@ -1702,17 +2655,17 @@ export class ClaudeAcpAgent {
|
|
|
1702
2655
|
models,
|
|
1703
2656
|
modelInfos: allowedModels,
|
|
1704
2657
|
configOptions,
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
nextPendingOrder: 0,
|
|
2658
|
+
agents,
|
|
2659
|
+
currentAgent,
|
|
1708
2660
|
abortController,
|
|
1709
2661
|
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
1710
|
-
contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
|
2662
|
+
contextWindowSize: inferContextWindowFromModel(models.currentModelId, currentModelInfo?.displayName, currentModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
|
|
1711
2663
|
taskState,
|
|
2664
|
+
toolUseCache: {},
|
|
2665
|
+
messageIdToUuid: new Map(),
|
|
1712
2666
|
};
|
|
1713
2667
|
return {
|
|
1714
2668
|
sessionId,
|
|
1715
|
-
models,
|
|
1716
2669
|
modes,
|
|
1717
2670
|
configOptions,
|
|
1718
2671
|
};
|
|
@@ -1850,7 +2803,39 @@ function buildAvailableModes(modelInfo) {
|
|
|
1850
2803
|
function toSdkEffortLevel(value) {
|
|
1851
2804
|
return value === undefined || value === "default" ? null : value;
|
|
1852
2805
|
}
|
|
1853
|
-
|
|
2806
|
+
// `supportedAgents()` always returns Claude Code's built-in subagents — the
|
|
2807
|
+
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
|
|
2808
|
+
// has configured none of their own. Those aren't meaningful *main-thread*
|
|
2809
|
+
// personas, so we filter them out and only surface the Agent picker when the
|
|
2810
|
+
// user (or a plugin/project) has configured custom agents. Update this set if
|
|
2811
|
+
// the SDK's built-in roster changes.
|
|
2812
|
+
export const BUILTIN_AGENT_NAMES = new Set([
|
|
2813
|
+
"claude",
|
|
2814
|
+
"general-purpose",
|
|
2815
|
+
"Explore",
|
|
2816
|
+
"Plan",
|
|
2817
|
+
"statusline-setup",
|
|
2818
|
+
]);
|
|
2819
|
+
// Value of the synthetic "Default" entry in the agent picker, which maps to the
|
|
2820
|
+
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
|
|
2821
|
+
// reserved sentinel: a custom agent named exactly this would collide with it
|
|
2822
|
+
// (two options sharing the value, selection silently routing to `null`), so we
|
|
2823
|
+
// exclude that name from discovery.
|
|
2824
|
+
export const DEFAULT_AGENT_ID = "default";
|
|
2825
|
+
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
|
2826
|
+
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
|
2827
|
+
* list if discovery fails so a flaky control request never blocks session
|
|
2828
|
+
* creation. */
|
|
2829
|
+
export async function discoverCustomAgents(q) {
|
|
2830
|
+
try {
|
|
2831
|
+
const agents = await q.supportedAgents();
|
|
2832
|
+
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
|
|
2833
|
+
}
|
|
2834
|
+
catch {
|
|
2835
|
+
return [];
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID) {
|
|
1854
2839
|
const options = [
|
|
1855
2840
|
{
|
|
1856
2841
|
id: "mode",
|
|
@@ -1907,6 +2892,27 @@ function buildConfigOptions(modes, models, modelInfos, currentEffortLevel) {
|
|
|
1907
2892
|
options: effortOptions,
|
|
1908
2893
|
});
|
|
1909
2894
|
}
|
|
2895
|
+
// Only surface the Agent picker when there's a real choice — i.e. the user
|
|
2896
|
+
// has configured at least one custom agent (built-ins are filtered out in
|
|
2897
|
+
// discoverCustomAgents). With none configured, "Default" would be the only
|
|
2898
|
+
// entry, so we omit the option entirely.
|
|
2899
|
+
if (agents.length > 0) {
|
|
2900
|
+
options.push({
|
|
2901
|
+
id: "agent",
|
|
2902
|
+
name: "Agent",
|
|
2903
|
+
description: "Main-thread agent persona",
|
|
2904
|
+
type: "select",
|
|
2905
|
+
currentValue: currentAgent,
|
|
2906
|
+
options: [
|
|
2907
|
+
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
|
|
2908
|
+
...agents.map((a) => ({
|
|
2909
|
+
value: a.name,
|
|
2910
|
+
name: a.name,
|
|
2911
|
+
description: a.description || undefined,
|
|
2912
|
+
})),
|
|
2913
|
+
],
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
1910
2916
|
return options;
|
|
1911
2917
|
}
|
|
1912
2918
|
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|
|
@@ -1951,11 +2957,16 @@ function tokenizeModelPreference(model) {
|
|
|
1951
2957
|
function scoreModelMatch(model, tokens, contextHint) {
|
|
1952
2958
|
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
|
|
1953
2959
|
let score = 0;
|
|
2960
|
+
let nonHintMatched = false;
|
|
1954
2961
|
for (const token of tokens) {
|
|
1955
2962
|
if (haystack.includes(token)) {
|
|
2963
|
+
if (token !== contextHint)
|
|
2964
|
+
nonHintMatched = true;
|
|
1956
2965
|
score += token === contextHint ? 3 : 1;
|
|
1957
2966
|
}
|
|
1958
2967
|
}
|
|
2968
|
+
if (contextHint && !nonHintMatched)
|
|
2969
|
+
return 0;
|
|
1959
2970
|
return score;
|
|
1960
2971
|
}
|
|
1961
2972
|
function resolveModelPreference(models, preference) {
|
|
@@ -2048,6 +3059,20 @@ function applyAvailableModelsAllowlist(sdkModels, allowlist) {
|
|
|
2048
3059
|
}
|
|
2049
3060
|
seen.add(trimmed);
|
|
2050
3061
|
}
|
|
3062
|
+
// The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
|
|
3063
|
+
// allowlist, the same way Default is. Per the model-config docs it adds an
|
|
3064
|
+
// entry "without replacing the built-in aliases" and "appears at the bottom of
|
|
3065
|
+
// the /model picker", so we append it last and skip the allowlist filter; this
|
|
3066
|
+
// keeps a slim alias allowlist from hiding the custom model row.
|
|
3067
|
+
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
|
3068
|
+
const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
|
|
3069
|
+
if (customModelOption && !seen.has(customModelOption)) {
|
|
3070
|
+
const customModel = sdkModels.find((m) => m.value === customModelOption);
|
|
3071
|
+
if (customModel) {
|
|
3072
|
+
result.push(customModel);
|
|
3073
|
+
seen.add(customModel.value);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
2051
3076
|
return result;
|
|
2052
3077
|
}
|
|
2053
3078
|
async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
|
|
@@ -2223,6 +3248,42 @@ export function promptToClaude(prompt) {
|
|
|
2223
3248
|
parent_tool_use_id: null,
|
|
2224
3249
|
};
|
|
2225
3250
|
}
|
|
3251
|
+
/**
|
|
3252
|
+
* Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
|
|
3253
|
+
* transcript message (replay) so chunk grouping is identical in both views.
|
|
3254
|
+
*
|
|
3255
|
+
* Assistant turns are keyed by the Anthropic API message id (`message.id`),
|
|
3256
|
+
* which is identical at `message_start`, on the consolidated assistant message,
|
|
3257
|
+
* and in the persisted transcript — unlike the per-`stream_event` uuid, which is
|
|
3258
|
+
* unique per event and never persisted. User messages have no API id, but they
|
|
3259
|
+
* are never streamed, so their (stable) SDK uuid is used instead. ACP message
|
|
3260
|
+
* ids are opaque strings, so no particular format is required.
|
|
3261
|
+
*/
|
|
3262
|
+
export function messageIdForGrouping(message) {
|
|
3263
|
+
if (message.type === "assistant") {
|
|
3264
|
+
const inner = message.message;
|
|
3265
|
+
const apiId = inner && typeof inner === "object" && "id" in inner
|
|
3266
|
+
? inner.id
|
|
3267
|
+
: undefined;
|
|
3268
|
+
if (typeof apiId === "string" && apiId.length > 0) {
|
|
3269
|
+
return apiId;
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
3275
|
+
* Stamps an ACP `messageId` onto a session update, but only on the message/
|
|
3276
|
+
* thought chunk variants that carry one — tool_call/plan/etc. updates never do.
|
|
3277
|
+
* No-op when `messageId` is falsy, so callers can pass it through unconditionally.
|
|
3278
|
+
*/
|
|
3279
|
+
function applyMessageId(update, messageId) {
|
|
3280
|
+
if (messageId &&
|
|
3281
|
+
(update.sessionUpdate === "agent_message_chunk" ||
|
|
3282
|
+
update.sessionUpdate === "user_message_chunk" ||
|
|
3283
|
+
update.sessionUpdate === "agent_thought_chunk")) {
|
|
3284
|
+
update.messageId = messageId;
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
2226
3287
|
/**
|
|
2227
3288
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
2228
3289
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -2239,6 +3300,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2239
3300
|
text: content,
|
|
2240
3301
|
},
|
|
2241
3302
|
};
|
|
3303
|
+
applyMessageId(update, options?.messageId);
|
|
2242
3304
|
if (options?.parentToolUseId) {
|
|
2243
3305
|
update._meta = {
|
|
2244
3306
|
...update._meta,
|
|
@@ -2278,13 +3340,17 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2278
3340
|
break;
|
|
2279
3341
|
case "thinking":
|
|
2280
3342
|
case "thinking_delta":
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
3343
|
+
// Recent models default `thinking.display` to "omitted", which streams
|
|
3344
|
+
// signature-only thinking blocks whose text is empty.
|
|
3345
|
+
if (chunk.thinking.length > 0) {
|
|
3346
|
+
update = {
|
|
3347
|
+
sessionUpdate: "agent_thought_chunk",
|
|
3348
|
+
content: {
|
|
3349
|
+
type: "text",
|
|
3350
|
+
text: chunk.thinking,
|
|
3351
|
+
},
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
2288
3354
|
break;
|
|
2289
3355
|
case "tool_use":
|
|
2290
3356
|
case "server_tool_use":
|
|
@@ -2311,40 +3377,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2311
3377
|
else {
|
|
2312
3378
|
// Only register hooks on first encounter to avoid double-firing
|
|
2313
3379
|
if (registerHooks && !alreadyCached) {
|
|
3380
|
+
// Capture the tool name in the closure rather than re-reading the
|
|
3381
|
+
// cache when the hook fires. The cache entry is pruned at
|
|
3382
|
+
// tool_result time, and a PostToolUse hook can fire after that, so
|
|
3383
|
+
// closing over the name keeps the diff working without depending on
|
|
3384
|
+
// (or pinning) the cache entry's lifetime.
|
|
3385
|
+
const toolName = chunk.name;
|
|
2314
3386
|
registerHookCallback(chunk.id, {
|
|
2315
3387
|
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
toolResponse,
|
|
2333
|
-
toolName: toolUse.name,
|
|
2334
|
-
},
|
|
3388
|
+
// Both `Edit` and `Write` produce a structuredPatch in their
|
|
3389
|
+
// PostToolUse tool_response. For Edit the diff replaces the
|
|
3390
|
+
// optimistic content built at tool_use time. For Write the
|
|
3391
|
+
// optimistic content (built from `input.content` alone with
|
|
3392
|
+
// `oldText: null`) shows "creation" semantics regardless of
|
|
3393
|
+
// whether the file existed; the structuredPatch from the
|
|
3394
|
+
// hook lets us emit the real diff for `type: "update"`. The
|
|
3395
|
+
// helper returns `{}` if the response shape isn't usable.
|
|
3396
|
+
const editDiff = toolName === "Edit" || toolName === "Write"
|
|
3397
|
+
? toolUpdateFromDiffToolResponse(toolResponse)
|
|
3398
|
+
: {};
|
|
3399
|
+
const update = {
|
|
3400
|
+
_meta: {
|
|
3401
|
+
claudeCode: {
|
|
3402
|
+
toolResponse,
|
|
3403
|
+
toolName,
|
|
2335
3404
|
},
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
}
|
|
2345
|
-
else {
|
|
2346
|
-
logger.error(`[claude-agent-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`);
|
|
2347
|
-
}
|
|
3405
|
+
},
|
|
3406
|
+
toolCallId: toolUseId,
|
|
3407
|
+
sessionUpdate: "tool_call_update",
|
|
3408
|
+
...editDiff,
|
|
3409
|
+
};
|
|
3410
|
+
await client.sessionUpdate({
|
|
3411
|
+
sessionId,
|
|
3412
|
+
update,
|
|
3413
|
+
});
|
|
2348
3414
|
},
|
|
2349
3415
|
});
|
|
2350
3416
|
}
|
|
@@ -2467,6 +3533,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2467
3533
|
...toolUpdate,
|
|
2468
3534
|
};
|
|
2469
3535
|
}
|
|
3536
|
+
// The tool_use is fully resolved now — drop it so a long session doesn't
|
|
3537
|
+
// retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
|
|
3538
|
+
// over the tool name and no longer reads the cache, so pruning here is
|
|
3539
|
+
// safe regardless of hook/result ordering.
|
|
3540
|
+
delete toolUseCache[chunk.tool_use_id];
|
|
2470
3541
|
break;
|
|
2471
3542
|
}
|
|
2472
3543
|
case "document":
|
|
@@ -2479,6 +3550,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2479
3550
|
case "compaction":
|
|
2480
3551
|
case "compaction_delta":
|
|
2481
3552
|
case "advisor_tool_result":
|
|
3553
|
+
case "mid_conv_system":
|
|
3554
|
+
case "fallback":
|
|
2482
3555
|
break;
|
|
2483
3556
|
default:
|
|
2484
3557
|
unreachable(chunk, logger);
|
|
@@ -2494,6 +3567,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2494
3567
|
},
|
|
2495
3568
|
};
|
|
2496
3569
|
}
|
|
3570
|
+
applyMessageId(update, options?.messageId);
|
|
2497
3571
|
output.push({ sessionId, update });
|
|
2498
3572
|
}
|
|
2499
3573
|
}
|
|
@@ -2508,6 +3582,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
2508
3582
|
parentToolUseId: message.parent_tool_use_id,
|
|
2509
3583
|
cwd: options?.cwd,
|
|
2510
3584
|
taskState: options?.taskState,
|
|
3585
|
+
messageId: options?.messageId,
|
|
2511
3586
|
});
|
|
2512
3587
|
case "content_block_delta":
|
|
2513
3588
|
return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
|
|
@@ -2515,6 +3590,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
2515
3590
|
parentToolUseId: message.parent_tool_use_id,
|
|
2516
3591
|
cwd: options?.cwd,
|
|
2517
3592
|
taskState: options?.taskState,
|
|
3593
|
+
messageId: options?.messageId,
|
|
2518
3594
|
});
|
|
2519
3595
|
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|
|
2520
3596
|
// `BetaRawMessageStreamEvent` union doesn't include even though the
|
|
@@ -2531,15 +3607,63 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
2531
3607
|
return [];
|
|
2532
3608
|
}
|
|
2533
3609
|
}
|
|
3610
|
+
/** Run a `session/prompt` while honoring `$/cancel_request` for it. ACP clients
|
|
3611
|
+
* normally stop a turn with the `session/cancel` notification, but `signal`
|
|
3612
|
+
* (the prompt request's abort signal) also fires when the client sends the
|
|
3613
|
+
* generic `$/cancel_request` for this prompt — the protocol's complementary
|
|
3614
|
+
* cancellation fallback. Route that to the same `agent.cancel` path so a client
|
|
3615
|
+
* using only the generic mechanism still stops the turn (and the prompt
|
|
3616
|
+
* resolves "cancelled" instead of running to completion).
|
|
3617
|
+
*
|
|
3618
|
+
* The listener is scoped to this call: once the prompt settles it is removed,
|
|
3619
|
+
* so a later teardown-time abort of the (per-request) signal can't cancel a
|
|
3620
|
+
* subsequent turn. `signal` also aborts on connection close, in which case
|
|
3621
|
+
* cancelling the in-flight turn is the desired behavior anyway. */
|
|
3622
|
+
export async function runPromptWithCancellation(agent, params, signal) {
|
|
3623
|
+
const onAbort = () => {
|
|
3624
|
+
// Fire-and-forget: nothing awaits this listener, so swallow (and log) any
|
|
3625
|
+
// rejection rather than surfacing it as an unhandled rejection.
|
|
3626
|
+
agent.cancel({ sessionId: params.sessionId }).catch((error) => {
|
|
3627
|
+
agent.logger.error(`Failed to cancel prompt via $/cancel_request: ${error}`);
|
|
3628
|
+
});
|
|
3629
|
+
};
|
|
3630
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3631
|
+
try {
|
|
3632
|
+
return await agent.prompt(params);
|
|
3633
|
+
}
|
|
3634
|
+
finally {
|
|
3635
|
+
signal.removeEventListener("abort", onAbort);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
2534
3638
|
export function runAcp() {
|
|
2535
3639
|
const input = nodeToWebWritable(process.stdout);
|
|
2536
3640
|
const output = nodeToWebReadable(process.stdin);
|
|
2537
3641
|
const stream = ndJsonStream(input, output);
|
|
3642
|
+
// `connect(...)` returns a connection-scoped peer handle (`connection.client`)
|
|
3643
|
+
// that stays valid for the whole connection, so the agent captures it once.
|
|
3644
|
+
// Handlers close over `agent`, which is assigned synchronously right after
|
|
3645
|
+
// `connect()` returns — before the connection processes any inbound message.
|
|
3646
|
+
// It cannot be `const`: its value depends on `connection.client`, which does
|
|
3647
|
+
// not exist until `connect()` has been called.
|
|
3648
|
+
// eslint-disable-next-line prefer-const
|
|
2538
3649
|
let agent;
|
|
2539
|
-
const connection =
|
|
2540
|
-
agent
|
|
2541
|
-
|
|
2542
|
-
|
|
3650
|
+
const connection = acpAgent({ name: "claude-code-acp" })
|
|
3651
|
+
.onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
|
|
3652
|
+
.onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
|
|
3653
|
+
.onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params))
|
|
3654
|
+
.onRequest(methods.agent.session.fork, (ctx) => agent.unstable_forkSession(ctx.params))
|
|
3655
|
+
.onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
|
|
3656
|
+
.onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
|
|
3657
|
+
.onRequest(methods.agent.session.resume, (ctx) => agent.resumeSession(ctx.params))
|
|
3658
|
+
.onRequest(methods.agent.session.close, (ctx) => agent.closeSession(ctx.params))
|
|
3659
|
+
.onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
|
|
3660
|
+
.onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
|
|
3661
|
+
.onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
|
|
3662
|
+
.onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
|
|
3663
|
+
.onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
|
|
3664
|
+
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
|
3665
|
+
.connect(stream);
|
|
3666
|
+
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
|
2543
3667
|
return { connection, agent };
|
|
2544
3668
|
}
|
|
2545
3669
|
function commonPrefixLength(a, b) {
|
|
@@ -2549,16 +3673,60 @@ function commonPrefixLength(a, b) {
|
|
|
2549
3673
|
}
|
|
2550
3674
|
return i;
|
|
2551
3675
|
}
|
|
2552
|
-
/** Best-effort first guess of a model's context window
|
|
2553
|
-
*
|
|
3676
|
+
/** Best-effort first guess of a model's context window, used only as a
|
|
3677
|
+
* fallback when the SDK's authoritative `getContextUsage` is unavailable (and
|
|
3678
|
+
* until a `result` message arrives with the `modelUsage` value).
|
|
3679
|
+
*
|
|
2554
3680
|
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
|
2555
3681
|
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
|
2556
|
-
* matching things like "10m" or embedded substrings.
|
|
2557
|
-
|
|
2558
|
-
|
|
3682
|
+
* matching things like "10m" or embedded substrings. Semantic aliases like
|
|
3683
|
+
* `default` carry no such token in the ID, but the SDK's human-facing
|
|
3684
|
+
* `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
|
|
3685
|
+
* pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
|
|
3686
|
+
* `ModelInfo` exposes no structured context-window field, so this text scan is
|
|
3687
|
+
* the only pre-`result` signal available. A miss falls back to the default
|
|
3688
|
+
* window and is corrected by `result.modelUsage` within one turn. */
|
|
3689
|
+
function inferContextWindowFromModel(...texts) {
|
|
3690
|
+
if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
|
|
2559
3691
|
return 1_000_000;
|
|
2560
3692
|
return null;
|
|
2561
3693
|
}
|
|
3694
|
+
/** Fetch the SDK's authoritative context-window occupancy via the
|
|
3695
|
+
* `getContextUsage` control request. Unlike the per-message API usage numbers
|
|
3696
|
+
* (which only count message tokens), this `totalTokens` includes the system
|
|
3697
|
+
* prompt, tool schemas, MCP tools, and memory-file overhead — the real
|
|
3698
|
+
* occupancy the user sees. Returns `null` on any control-request failure.
|
|
3699
|
+
*
|
|
3700
|
+
* Note: we deliberately do NOT use this response's window fields for `size`.
|
|
3701
|
+
* They have been observed to under-report extended (1M) context windows, so
|
|
3702
|
+
* the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
|
|
3703
|
+
* which handle the 1M variants correctly. */
|
|
3704
|
+
async function fetchContextUsedTokens(query, logger) {
|
|
3705
|
+
try {
|
|
3706
|
+
const usage = await query.getContextUsage();
|
|
3707
|
+
return usage.totalTokens;
|
|
3708
|
+
}
|
|
3709
|
+
catch (error) {
|
|
3710
|
+
logger.error("Failed to fetch context usage from SDK:", error);
|
|
3711
|
+
return null;
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
/** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
|
|
3715
|
+
* option. The `maxThinkingTokens` option it used to feed is deprecated and
|
|
3716
|
+
* reduced to on/off on current models, so map the value to explicit thinking
|
|
3717
|
+
* config instead: unset → `undefined` (SDK default, adaptive on models that
|
|
3718
|
+
* support it); `0` → disabled; a positive integer → a fixed token budget.
|
|
3719
|
+
* Anything else is ignored with a warning. */
|
|
3720
|
+
function resolveThinkingConfig(raw, logger) {
|
|
3721
|
+
if (raw === undefined)
|
|
3722
|
+
return undefined;
|
|
3723
|
+
const parsed = Number.parseInt(raw, 10);
|
|
3724
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
3725
|
+
logger.error(`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${raw}'.`);
|
|
3726
|
+
return undefined;
|
|
3727
|
+
}
|
|
3728
|
+
return parsed === 0 ? { type: "disabled" } : { type: "enabled", budgetTokens: parsed };
|
|
3729
|
+
}
|
|
2562
3730
|
function parseModelConfig(raw) {
|
|
2563
3731
|
if (!raw)
|
|
2564
3732
|
return undefined;
|