claude-code-acp-ts 0.38.2 → 0.44.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 +90 -15
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +654 -106
- package/dist/elicitation.d.ts +80 -0
- package/dist/elicitation.d.ts.map +1 -0
- package/dist/elicitation.js +166 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +16 -0
- package/package.json +13 -13
package/dist/acp-agent.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { AgentSideConnection, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
2
|
import { deleteSession, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
4
5
|
import * as os from "node:os";
|
|
5
6
|
import * as path from "node:path";
|
|
6
7
|
import packageJson from "../package.json" with { type: "json" };
|
|
8
|
+
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, mcpElicitationToCreateRequest, } from "./elicitation.js";
|
|
7
9
|
import { SettingsManager } from "./settings.js";
|
|
8
10
|
import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
9
11
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
@@ -27,6 +29,16 @@ const ZERO_USAGE = Object.freeze({
|
|
|
27
29
|
cache_creation_input_tokens: 0,
|
|
28
30
|
});
|
|
29
31
|
const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
32
|
+
/** Floor after `session/cancel` before the adapter forces the active prompt
|
|
33
|
+
* loop to return "cancelled". `query.interrupt()` normally makes the SDK
|
|
34
|
+
* yield a trailing idle within milliseconds, and the loop returns through its
|
|
35
|
+
* usual path — so this timer is armed and cleared, never fired, on healthy
|
|
36
|
+
* cancels. It only trips when the SDK is genuinely wedged (e.g. a
|
|
37
|
+
* `TaskOutput { block: true }` poll against a hung background task — issue
|
|
38
|
+
* #680) and never yields. The value is deliberately loose: it's an
|
|
39
|
+
* "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
|
|
40
|
+
* pre-empt a slow-but-healthy interrupt. */
|
|
41
|
+
const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
|
|
30
42
|
/** Compute a stable fingerprint of the session-defining params so we can
|
|
31
43
|
* detect when a loadSession/resumeSession call requires tearing down and
|
|
32
44
|
* recreating the underlying Query process. MCP servers are sorted by name
|
|
@@ -92,9 +104,39 @@ const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
|
|
92
104
|
// payload in these XML-like markers that the CLI uses for its own display.
|
|
93
105
|
// The live prompt loop drops them; replay must strip them too or they leak
|
|
94
106
|
// into the UI on session/load.
|
|
95
|
-
const
|
|
107
|
+
const LOCAL_COMMAND_MARKERS = [
|
|
108
|
+
"command-name",
|
|
109
|
+
"command-message",
|
|
110
|
+
"command-args",
|
|
111
|
+
"local-command-stdout",
|
|
112
|
+
"local-command-stderr",
|
|
113
|
+
].map((tag) => ({ open: `<${tag}>`, close: `</${tag}>` }));
|
|
114
|
+
// Single-pass scanner that removes each `<tag>…</tag>` marker (matching the
|
|
115
|
+
// nearest closing tag of the same name, like a lazy regex would).
|
|
96
116
|
function stripMarkerTags(text) {
|
|
97
|
-
|
|
117
|
+
const dead = new Set();
|
|
118
|
+
let result = "";
|
|
119
|
+
let copiedUpTo = 0;
|
|
120
|
+
let i = 0;
|
|
121
|
+
while (i < text.length) {
|
|
122
|
+
if (text[i] === "<") {
|
|
123
|
+
const marker = LOCAL_COMMAND_MARKERS.find((m) => !dead.has(m.open) && text.startsWith(m.open, i));
|
|
124
|
+
if (marker) {
|
|
125
|
+
const end = text.indexOf(marker.close, i + marker.open.length);
|
|
126
|
+
if (end !== -1) {
|
|
127
|
+
result += text.slice(copiedUpTo, i);
|
|
128
|
+
i = copiedUpTo = end + marker.close.length;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// No closing marker remains anywhere ahead, and `indexOf` only ever
|
|
132
|
+
// searches forward from here on, so stop treating this tag as an
|
|
133
|
+
// opener — that avoids rescanning the tail for it on every match.
|
|
134
|
+
dead.add(marker.open);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
return result + text.slice(copiedUpTo);
|
|
98
140
|
}
|
|
99
141
|
/**
|
|
100
142
|
* Return user-message content with local-command marker tags removed, or
|
|
@@ -203,10 +245,12 @@ export function describeAlwaysAllow(suggestions, toolName) {
|
|
|
203
245
|
// Implement the ACP Agent interface
|
|
204
246
|
export class ClaudeAcpAgent {
|
|
205
247
|
constructor(client, logger) {
|
|
206
|
-
|
|
248
|
+
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
249
|
+
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
250
|
+
* tests can shrink it. */
|
|
251
|
+
this.forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
|
|
207
252
|
this.sessions = {};
|
|
208
253
|
this.client = client;
|
|
209
|
-
this.toolUseCache = {};
|
|
210
254
|
this.logger = logger ?? console;
|
|
211
255
|
}
|
|
212
256
|
async initialize(request) {
|
|
@@ -416,6 +460,13 @@ export class ClaudeAcpAgent {
|
|
|
416
460
|
if (!session) {
|
|
417
461
|
throw new Error("Session not found");
|
|
418
462
|
}
|
|
463
|
+
session.cancelled = false;
|
|
464
|
+
session.accumulatedUsage = {
|
|
465
|
+
inputTokens: 0,
|
|
466
|
+
outputTokens: 0,
|
|
467
|
+
cachedReadTokens: 0,
|
|
468
|
+
cachedWriteTokens: 0,
|
|
469
|
+
};
|
|
419
470
|
let lastAssistantTotalUsage = null;
|
|
420
471
|
let lastAssistantUsage = null;
|
|
421
472
|
let lastAssistantModel = null;
|
|
@@ -426,6 +477,45 @@ export class ClaudeAcpAgent {
|
|
|
426
477
|
// forward it to clients as structured `data`, sparing them from
|
|
427
478
|
// pattern-matching on the human-readable message text.
|
|
428
479
|
let lastAssistantError;
|
|
480
|
+
// When a streaming classifier refuses a turn, the assistant message carries
|
|
481
|
+
// stop_reason "refusal" and structured stop_details. We capture the
|
|
482
|
+
// human-readable explanation here so the terminal `result` can surface it
|
|
483
|
+
// to the user (the refused assistant message itself usually has no content)
|
|
484
|
+
// and report ACP's dedicated `refusal` stop reason.
|
|
485
|
+
let lastRefusalExplanation = null;
|
|
486
|
+
// Tracks whether we're inside a compaction. The SDK emits the terminal
|
|
487
|
+
// `status` (compact_result success/failed) twice for a single failed
|
|
488
|
+
// compaction, and the two messages are indistinguishable — so we report the
|
|
489
|
+
// outcome only while a compaction is in progress, then clear this. A fresh
|
|
490
|
+
// `compacting` status sets it again, so every distinct compaction (e.g.
|
|
491
|
+
// repeated auto-compactions in a long turn) is still shown.
|
|
492
|
+
let compactionInProgress = false;
|
|
493
|
+
// Holds the Anthropic API message id of the assistant message currently
|
|
494
|
+
// being streamed, captured from `message_start` so every streamed chunk can
|
|
495
|
+
// be tagged with it. We use the API message id rather than the
|
|
496
|
+
// per-`stream_event` uuid because the same id is also present on the
|
|
497
|
+
// consolidated assistant message and in the persisted transcript — so a turn
|
|
498
|
+
// keeps the same ACP `messageId` whether it is streamed live or replayed
|
|
499
|
+
// from history. The per-event uuid is unique per event and never persisted.
|
|
500
|
+
// A single value suffices because every streaming partial arrives with
|
|
501
|
+
// `parent_tool_use_id === null` (subagent work is folded into tool-result
|
|
502
|
+
// messages, never surfaced as partial streams).
|
|
503
|
+
let currentStreamMessageId;
|
|
504
|
+
// Per-message-id record of which assistant content actually streamed live via
|
|
505
|
+
// `stream_event` deltas, split by block type. The `assistant` case below
|
|
506
|
+
// normally drops `text`/`thinking` blocks on the assumption they already
|
|
507
|
+
// reached the client as `agent_message_chunk`/`agent_thought_chunk`. That
|
|
508
|
+
// breaks behind Anthropic-protocol gateways that return a turn as a single
|
|
509
|
+
// non-streamed block (common with OpenAI-compatible proxies): no
|
|
510
|
+
// `content_block_delta` fires, so the assembled block is the only copy the
|
|
511
|
+
// client will ever see. We keep the filter per (id, block type) for content
|
|
512
|
+
// that did stream, and fall back to forwarding what did not. Split by type so
|
|
513
|
+
// a gateway that streams text but not thinking (or vice versa) doesn't lose
|
|
514
|
+
// the un-streamed block. Only top-level (`parent_tool_use_id === null`)
|
|
515
|
+
// streams are recorded — subagent text is never streamed and must stay
|
|
516
|
+
// filtered, as it is internal to the tool call.
|
|
517
|
+
const streamedTextIds = new Set();
|
|
518
|
+
const streamedThinkingIds = new Set();
|
|
429
519
|
const userMessage = promptToClaude(params);
|
|
430
520
|
const promptUuid = randomUUID();
|
|
431
521
|
userMessage.uuid = promptUuid;
|
|
@@ -447,26 +537,30 @@ export class ClaudeAcpAgent {
|
|
|
447
537
|
else {
|
|
448
538
|
session.input.push(userMessage);
|
|
449
539
|
}
|
|
450
|
-
// Reset session state only when this prompt is about to execute (not
|
|
451
|
-
// while queued). Previously this ran before the queue check, which
|
|
452
|
-
// created a race: a cancel()+new-prompt sequence would reset
|
|
453
|
-
// `session.cancelled` to false before the in-flight prompt's while-loop
|
|
454
|
-
// could observe the cancelled flag, causing it to return `end_turn` with
|
|
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,
|
|
462
|
-
};
|
|
463
540
|
session.promptRunning = true;
|
|
464
541
|
let handedOff = false;
|
|
465
542
|
let errored = false;
|
|
466
543
|
let stopReason = "end_turn";
|
|
544
|
+
// Wake-up channel so cancel() can force this loop to return "cancelled"
|
|
545
|
+
// even when query.next() is wedged and never yields again (issue #680).
|
|
546
|
+
const cancelController = new AbortController();
|
|
547
|
+
session.cancelController = cancelController;
|
|
548
|
+
const cancelled = new Promise((resolve) => {
|
|
549
|
+
cancelController.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
550
|
+
});
|
|
467
551
|
try {
|
|
468
552
|
while (true) {
|
|
469
|
-
const
|
|
553
|
+
const nextMessage = session.query.next();
|
|
554
|
+
const next = await Promise.race([nextMessage, cancelled]);
|
|
555
|
+
if (cancelController.signal.aborted) {
|
|
556
|
+
// The SDK never yielded after interrupt() (e.g. a wedged TaskOutput
|
|
557
|
+
// block). Abandon the in-flight next() — swallowing any later
|
|
558
|
+
// rejection so it can't surface as an unhandled rejection — and
|
|
559
|
+
// honor the cancel per the ACP contract.
|
|
560
|
+
void nextMessage.catch(() => { });
|
|
561
|
+
return { stopReason: "cancelled" };
|
|
562
|
+
}
|
|
563
|
+
const { value: message, done } = next;
|
|
470
564
|
if (done || !message) {
|
|
471
565
|
if (session.cancelled) {
|
|
472
566
|
return { stopReason: "cancelled" };
|
|
@@ -487,6 +581,7 @@ export class ClaudeAcpAgent {
|
|
|
487
581
|
break;
|
|
488
582
|
case "status": {
|
|
489
583
|
if (message.status === "compacting") {
|
|
584
|
+
compactionInProgress = true;
|
|
490
585
|
await this.client.sessionUpdate({
|
|
491
586
|
sessionId: message.session_id,
|
|
492
587
|
update: {
|
|
@@ -495,37 +590,65 @@ export class ClaudeAcpAgent {
|
|
|
495
590
|
},
|
|
496
591
|
});
|
|
497
592
|
}
|
|
593
|
+
else if (message.compact_result === "success" && compactionInProgress) {
|
|
594
|
+
// The SDK signals manual `/compact` completion with a status
|
|
595
|
+
// message carrying `compact_result`, not the `compact_boundary`
|
|
596
|
+
// message (which only fires when there's content to compact).
|
|
597
|
+
compactionInProgress = false;
|
|
598
|
+
await this.client.sessionUpdate({
|
|
599
|
+
sessionId: message.session_id,
|
|
600
|
+
update: {
|
|
601
|
+
sessionUpdate: "agent_message_chunk",
|
|
602
|
+
content: { type: "text", text: "\n\nCompacting completed." },
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
else if (message.compact_result === "failed" && compactionInProgress) {
|
|
607
|
+
compactionInProgress = false;
|
|
608
|
+
const reason = message.compact_error ? `: ${message.compact_error}` : ".";
|
|
609
|
+
await this.client.sessionUpdate({
|
|
610
|
+
sessionId: message.session_id,
|
|
611
|
+
update: {
|
|
612
|
+
sessionUpdate: "agent_message_chunk",
|
|
613
|
+
content: { type: "text", text: `\n\nCompacting failed${reason}` },
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
}
|
|
498
617
|
break;
|
|
499
618
|
}
|
|
500
619
|
case "compact_boundary": {
|
|
501
|
-
//
|
|
502
|
-
// the stale pre-compaction
|
|
620
|
+
// Refresh the displayed usage immediately so the client doesn't
|
|
621
|
+
// keep showing the stale pre-compaction size (e.g. "944k/1m")
|
|
622
|
+
// right after the user sees "Compacting completed", which is
|
|
623
|
+
// confusing and wrong.
|
|
624
|
+
//
|
|
625
|
+
// Prefer the SDK's authoritative post-compaction `used` via
|
|
626
|
+
// getContextUsage — it reflects the real retained context
|
|
627
|
+
// (system prompt + tools + surviving messages), which the
|
|
628
|
+
// per-message API usage numbers can't give us until the next
|
|
629
|
+
// turn's result. If the control request fails, fall back to the
|
|
630
|
+
// used:0 approximation: directionally correct (context just
|
|
631
|
+
// dropped dramatically) and replaced within seconds by the next
|
|
632
|
+
// result message.
|
|
633
|
+
//
|
|
634
|
+
// `size` keeps coming from session.contextWindowSize (learned
|
|
635
|
+
// from modelUsage / the model heuristic) — getContextUsage's
|
|
636
|
+
// window field under-reports extended 1M windows.
|
|
503
637
|
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
// within seconds when the next result message arrives.
|
|
509
|
-
// The alternative (no update) leaves the client showing e.g.
|
|
510
|
-
// "944k/1m" right after the user sees "Compacting completed",
|
|
511
|
-
// which is confusing and wrong.
|
|
512
|
-
lastAssistantTotalUsage = 0;
|
|
638
|
+
// The "Compacting completed." text is emitted from the `status`
|
|
639
|
+
// handler (keyed on `compact_result`), not here, so the failure
|
|
640
|
+
// path gets a message too.
|
|
641
|
+
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
|
513
642
|
lastAssistantUsage = null;
|
|
643
|
+
lastAssistantTotalUsage = usedTokens ?? 0;
|
|
514
644
|
await this.client.sessionUpdate({
|
|
515
645
|
sessionId: message.session_id,
|
|
516
646
|
update: {
|
|
517
647
|
sessionUpdate: "usage_update",
|
|
518
|
-
used:
|
|
648
|
+
used: lastAssistantTotalUsage,
|
|
519
649
|
size: session.contextWindowSize,
|
|
520
650
|
},
|
|
521
651
|
});
|
|
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
652
|
break;
|
|
530
653
|
}
|
|
531
654
|
case "local_command_output": {
|
|
@@ -584,6 +707,63 @@ export class ClaudeAcpAgent {
|
|
|
584
707
|
});
|
|
585
708
|
break;
|
|
586
709
|
}
|
|
710
|
+
case "commands_changed": {
|
|
711
|
+
// Push the full slash-command list after a mid-session change
|
|
712
|
+
// (e.g. skills discovered dynamically as the agent works in a
|
|
713
|
+
// subdirectory). The client should REPLACE its cached command
|
|
714
|
+
// list with this payload: supportedCommands() is captured once
|
|
715
|
+
// at initialize and never reflects mid-session changes, so we
|
|
716
|
+
// forward message.commands directly rather than re-querying.
|
|
717
|
+
await this.client.sessionUpdate({
|
|
718
|
+
sessionId: message.session_id,
|
|
719
|
+
update: {
|
|
720
|
+
sessionUpdate: "available_commands_update",
|
|
721
|
+
availableCommands: getAvailableSlashCommands(message.commands),
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case "mirror_error": {
|
|
727
|
+
// The SDK failed to persist session history (SessionStore
|
|
728
|
+
// append rejected/timed out after retry) — potential data loss
|
|
729
|
+
// the user should know about rather than a silent gap on
|
|
730
|
+
// resume. Log it and surface a warning in the conversation.
|
|
731
|
+
this.logger.error(`Session ${message.session_id}: failed to persist history: ${message.error}`);
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
case "permission_denied": {
|
|
735
|
+
// A tool call was auto-denied (by a rule, the classifier,
|
|
736
|
+
// dontAsk mode, etc.) before running. The tool_use block was
|
|
737
|
+
// already emitted as a `tool_call`, so mark it failed with the
|
|
738
|
+
// rejection reason — otherwise the client shows a tool call
|
|
739
|
+
// that silently never resolves.
|
|
740
|
+
const reason = message.decision_reason ?? message.message;
|
|
741
|
+
await this.client.sessionUpdate({
|
|
742
|
+
sessionId: message.session_id,
|
|
743
|
+
update: {
|
|
744
|
+
sessionUpdate: "tool_call_update",
|
|
745
|
+
toolCallId: message.tool_use_id,
|
|
746
|
+
status: "failed",
|
|
747
|
+
content: [
|
|
748
|
+
{
|
|
749
|
+
type: "content",
|
|
750
|
+
content: { type: "text", text: `Permission denied: ${reason}` },
|
|
751
|
+
},
|
|
752
|
+
],
|
|
753
|
+
_meta: {
|
|
754
|
+
claudeCode: {
|
|
755
|
+
toolName: message.tool_name,
|
|
756
|
+
toolResponse: {
|
|
757
|
+
decisionReasonType: message.decision_reason_type,
|
|
758
|
+
decisionReason: message.decision_reason,
|
|
759
|
+
message: message.message,
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
587
767
|
case "hook_started":
|
|
588
768
|
case "hook_progress":
|
|
589
769
|
case "hook_response":
|
|
@@ -592,12 +772,28 @@ export class ClaudeAcpAgent {
|
|
|
592
772
|
case "task_notification":
|
|
593
773
|
case "task_progress":
|
|
594
774
|
case "task_updated":
|
|
595
|
-
|
|
775
|
+
break;
|
|
776
|
+
case "elicitation_complete": {
|
|
777
|
+
// A url-mode MCP elicitation finished server-side. Let the client
|
|
778
|
+
// dismiss any UI it opened for it. Only meaningful when the
|
|
779
|
+
// client supports url elicitation; ignore failures otherwise.
|
|
780
|
+
if (this.clientCapabilities?.elicitation?.url) {
|
|
781
|
+
try {
|
|
782
|
+
await this.client.unstable_completeElicitation({
|
|
783
|
+
elicitationId: message.elicitation_id,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
catch (error) {
|
|
787
|
+
this.logger.error(`Failed to complete elicitation: ${error}`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
596
792
|
case "plugin_install":
|
|
597
793
|
case "notification":
|
|
598
794
|
case "api_retry":
|
|
599
|
-
case "
|
|
600
|
-
case "
|
|
795
|
+
case "thinking_tokens":
|
|
796
|
+
case "model_refusal_fallback":
|
|
601
797
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
602
798
|
break;
|
|
603
799
|
default:
|
|
@@ -650,6 +846,25 @@ export class ClaudeAcpAgent {
|
|
|
650
846
|
}
|
|
651
847
|
break;
|
|
652
848
|
}
|
|
849
|
+
// A refusal can arrive on any result subtype (and may even set
|
|
850
|
+
// is_error), so handle it before the subtype switch — otherwise the
|
|
851
|
+
// is_error throw below would surface it as an internal error. The
|
|
852
|
+
// refused assistant message carries no visible content, so surface
|
|
853
|
+
// the classifier's explanation (when available) and report ACP's
|
|
854
|
+
// dedicated `refusal` stop reason.
|
|
855
|
+
if (message.stop_reason === "refusal" && !isTaskNotification) {
|
|
856
|
+
if (lastRefusalExplanation) {
|
|
857
|
+
await this.client.sessionUpdate({
|
|
858
|
+
sessionId: params.sessionId,
|
|
859
|
+
update: {
|
|
860
|
+
sessionUpdate: "agent_message_chunk",
|
|
861
|
+
content: { type: "text", text: lastRefusalExplanation },
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
stopReason = "refusal";
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
653
868
|
switch (message.subtype) {
|
|
654
869
|
case "success": {
|
|
655
870
|
if (message.result.includes("Please run /login")) {
|
|
@@ -669,7 +884,7 @@ export class ClaudeAcpAgent {
|
|
|
669
884
|
// Task-notification followups never originate from a user
|
|
670
885
|
// slash command, so skip the forwarding for them.
|
|
671
886
|
if (isLocalOnlyCommand && !isTaskNotification) {
|
|
672
|
-
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId,
|
|
887
|
+
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
|
|
673
888
|
await this.client.sessionUpdate(notification);
|
|
674
889
|
}
|
|
675
890
|
}
|
|
@@ -707,6 +922,27 @@ export class ClaudeAcpAgent {
|
|
|
707
922
|
break;
|
|
708
923
|
}
|
|
709
924
|
case "stream_event": {
|
|
925
|
+
// `message_start` carries the Anthropic API message id; capture it
|
|
926
|
+
// so the streamed chunks that follow (whose delta events don't carry
|
|
927
|
+
// it) can all be tagged with the same, replay-stable id.
|
|
928
|
+
if (message.event.type === "message_start") {
|
|
929
|
+
currentStreamMessageId = message.event.message.id || undefined;
|
|
930
|
+
}
|
|
931
|
+
// Record that this top-level message id actually streamed text/
|
|
932
|
+
// thinking, so the `assistant` case below knows its assembled blocks
|
|
933
|
+
// are duplicates (filter them) rather than the only copy (forward
|
|
934
|
+
// them). Gated on `parent_tool_use_id === null` so a subagent stream
|
|
935
|
+
// can't attribute its content to the top-level message id.
|
|
936
|
+
if (currentStreamMessageId &&
|
|
937
|
+
message.parent_tool_use_id === null &&
|
|
938
|
+
message.event.type === "content_block_delta") {
|
|
939
|
+
if (message.event.delta.type === "text_delta") {
|
|
940
|
+
streamedTextIds.add(currentStreamMessageId);
|
|
941
|
+
}
|
|
942
|
+
else if (message.event.delta.type === "thinking_delta") {
|
|
943
|
+
streamedThinkingIds.add(currentStreamMessageId);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
710
946
|
if (message.parent_tool_use_id === null &&
|
|
711
947
|
(message.event.type === "message_start" || message.event.type === "message_delta")) {
|
|
712
948
|
if (message.event.type === "message_start") {
|
|
@@ -754,10 +990,11 @@ export class ClaudeAcpAgent {
|
|
|
754
990
|
});
|
|
755
991
|
}
|
|
756
992
|
}
|
|
757
|
-
for (const notification of streamEventToAcpNotifications(message, params.sessionId,
|
|
993
|
+
for (const notification of streamEventToAcpNotifications(message, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
758
994
|
clientCapabilities: this.clientCapabilities,
|
|
759
995
|
cwd: session.cwd,
|
|
760
996
|
taskState: session.taskState,
|
|
997
|
+
messageId: currentStreamMessageId,
|
|
761
998
|
})) {
|
|
762
999
|
await this.client.sessionUpdate(notification);
|
|
763
1000
|
}
|
|
@@ -768,6 +1005,14 @@ export class ClaudeAcpAgent {
|
|
|
768
1005
|
if (session.cancelled) {
|
|
769
1006
|
break;
|
|
770
1007
|
}
|
|
1008
|
+
// Record the ACP messageId -> SDK uuid mapping for this message. The
|
|
1009
|
+
// consolidated message carries both ids, so this is where we learn
|
|
1010
|
+
// the uuid that the SDK's rewind/resume APIs key on for the id we
|
|
1011
|
+
// hand clients. Not read yet (see Session.messageIdToUuid).
|
|
1012
|
+
const mappedMessageId = messageIdForGrouping(message);
|
|
1013
|
+
if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
|
|
1014
|
+
session.messageIdToUuid.set(mappedMessageId, message.uuid);
|
|
1015
|
+
}
|
|
771
1016
|
// Check for prompt replay
|
|
772
1017
|
if (message.type === "user" && "uuid" in message && message.uuid) {
|
|
773
1018
|
if (message.uuid === promptUuid) {
|
|
@@ -800,21 +1045,26 @@ export class ClaudeAcpAgent {
|
|
|
800
1045
|
if (message.error) {
|
|
801
1046
|
lastAssistantError = message.error;
|
|
802
1047
|
}
|
|
1048
|
+
if (message.message.stop_reason === "refusal") {
|
|
1049
|
+
lastRefusalExplanation = message.message.stop_details?.explanation ?? null;
|
|
1050
|
+
}
|
|
803
1051
|
}
|
|
804
1052
|
// Strip <command-*>/<local-command-stdout> markers and render any
|
|
805
1053
|
// remaining prose. Skill bodies and built-in slash commands (e.g.
|
|
806
1054
|
// /usage, /status, /model) arrive wrapped in these tags; pure-marker
|
|
807
1055
|
// payloads (e.g. /compact's malformed output) strip to null and are
|
|
808
1056
|
// skipped. Mirrors the replay path at replaySessionHistory.
|
|
809
|
-
if (
|
|
1057
|
+
if (message.message.role !== "system" &&
|
|
1058
|
+
typeof message.message.content === "string" &&
|
|
810
1059
|
message.message.content.includes("<local-command-stdout>")) {
|
|
811
1060
|
const stripped = stripLocalCommandMetadata(message.message.content);
|
|
812
1061
|
if (typeof stripped === "string") {
|
|
813
|
-
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId,
|
|
1062
|
+
for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
814
1063
|
clientCapabilities: this.clientCapabilities,
|
|
815
1064
|
parentToolUseId: message.parent_tool_use_id,
|
|
816
1065
|
cwd: session.cwd,
|
|
817
1066
|
taskState: session.taskState,
|
|
1067
|
+
messageId: messageIdForGrouping(message),
|
|
818
1068
|
})) {
|
|
819
1069
|
await this.client.sessionUpdate(notification);
|
|
820
1070
|
}
|
|
@@ -837,6 +1087,9 @@ export class ClaudeAcpAgent {
|
|
|
837
1087
|
message.message.content[0].type === "text"))) {
|
|
838
1088
|
break;
|
|
839
1089
|
}
|
|
1090
|
+
if (message.message.role === "system") {
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
840
1093
|
if (message.type === "assistant" &&
|
|
841
1094
|
message.message.model === "<synthetic>" &&
|
|
842
1095
|
Array.isArray(message.message.content) &&
|
|
@@ -845,25 +1098,91 @@ export class ClaudeAcpAgent {
|
|
|
845
1098
|
message.message.content[0].text.includes("Please run /login")) {
|
|
846
1099
|
throw RequestError.authRequired();
|
|
847
1100
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1101
|
+
let content;
|
|
1102
|
+
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
1103
|
+
// Top-level assistant message: drop text/thinking blocks already
|
|
1104
|
+
// streamed live as chunks, and forward (as a fallback) any that were
|
|
1105
|
+
// not, so non-streaming gateways still deliver the final answer.
|
|
1106
|
+
const id = messageIdForGrouping(message);
|
|
1107
|
+
content = message.message.content.filter((item) => {
|
|
1108
|
+
// Non-text blocks (tool_use, etc.) always pass through; their own
|
|
1109
|
+
// dedupe (`toolUseCache`) collapses the streamed/assembled pair.
|
|
1110
|
+
if (item.type !== "text" && item.type !== "thinking") {
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
// Already delivered live as a chunk of this exact type — drop the
|
|
1114
|
+
// duplicate. Checked per type so streaming one (e.g. text) doesn't
|
|
1115
|
+
// suppress an un-streamed block of the other (e.g. thinking).
|
|
1116
|
+
const streamedLive = id !== undefined &&
|
|
1117
|
+
(item.type === "text" ? streamedTextIds : streamedThinkingIds).has(id);
|
|
1118
|
+
if (streamedLive) {
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
// Empty assembled blocks carry nothing (some gateways emit an empty
|
|
1122
|
+
// `thinking` block before the real text) — don't forward stray
|
|
1123
|
+
// empty chunks.
|
|
1124
|
+
const text = item.type === "text" ? item.text : item.thinking;
|
|
1125
|
+
if (text.length === 0) {
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
return true;
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
else if (message.type === "assistant") {
|
|
1132
|
+
// Subagent assistant message (`parent_tool_use_id !== null`). It is
|
|
1133
|
+
// never streamed live and its text/thinking is internal to the tool
|
|
1134
|
+
// call — keep dropping it so subagent prose doesn't leak into the
|
|
1135
|
+
// top-level feed.
|
|
1136
|
+
content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
content = message.message.content;
|
|
1140
|
+
}
|
|
1141
|
+
for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
853
1142
|
clientCapabilities: this.clientCapabilities,
|
|
854
1143
|
parentToolUseId: message.parent_tool_use_id,
|
|
855
1144
|
cwd: session.cwd,
|
|
856
1145
|
taskState: session.taskState,
|
|
1146
|
+
messageId: messageIdForGrouping(message),
|
|
857
1147
|
})) {
|
|
858
1148
|
await this.client.sessionUpdate(notification);
|
|
859
1149
|
}
|
|
860
1150
|
break;
|
|
861
1151
|
}
|
|
862
|
-
case "tool_progress":
|
|
1152
|
+
case "tool_progress": {
|
|
1153
|
+
await this.client.sessionUpdate({
|
|
1154
|
+
sessionId: message.session_id,
|
|
1155
|
+
update: {
|
|
1156
|
+
sessionUpdate: "tool_call_update",
|
|
1157
|
+
toolCallId: message.tool_use_id,
|
|
1158
|
+
status: "in_progress",
|
|
1159
|
+
_meta: {
|
|
1160
|
+
claudeCode: {
|
|
1161
|
+
toolName: message.tool_name,
|
|
1162
|
+
toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
|
|
1163
|
+
},
|
|
1164
|
+
},
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
break;
|
|
1168
|
+
}
|
|
1169
|
+
case "rate_limit_event": {
|
|
1170
|
+
if (lastAssistantTotalUsage !== null) {
|
|
1171
|
+
await this.client.sessionUpdate({
|
|
1172
|
+
sessionId: message.session_id,
|
|
1173
|
+
update: {
|
|
1174
|
+
sessionUpdate: "usage_update",
|
|
1175
|
+
used: lastAssistantTotalUsage,
|
|
1176
|
+
size: session.contextWindowSize,
|
|
1177
|
+
_meta: { "_claude/rateLimit": message.rate_limit_info },
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
863
1183
|
case "tool_use_summary":
|
|
864
1184
|
case "auth_status":
|
|
865
1185
|
case "prompt_suggestion":
|
|
866
|
-
case "rate_limit_event":
|
|
867
1186
|
break;
|
|
868
1187
|
default:
|
|
869
1188
|
unreachable(message);
|
|
@@ -915,6 +1234,16 @@ export class ClaudeAcpAgent {
|
|
|
915
1234
|
throw error;
|
|
916
1235
|
}
|
|
917
1236
|
finally {
|
|
1237
|
+
// The loop is returning — interrupt() succeeded or the prompt finished
|
|
1238
|
+
// — so disarm the force-cancel backstop and release the wake-up channel
|
|
1239
|
+
// (only if we still own it; a handoff installs the next prompt's).
|
|
1240
|
+
if (session.forceCancelTimer) {
|
|
1241
|
+
clearTimeout(session.forceCancelTimer);
|
|
1242
|
+
session.forceCancelTimer = undefined;
|
|
1243
|
+
}
|
|
1244
|
+
if (session.cancelController === cancelController) {
|
|
1245
|
+
session.cancelController = undefined;
|
|
1246
|
+
}
|
|
918
1247
|
if (!handedOff) {
|
|
919
1248
|
session.promptRunning = false;
|
|
920
1249
|
if (errored) {
|
|
@@ -950,6 +1279,27 @@ export class ClaudeAcpAgent {
|
|
|
950
1279
|
pending.resolve(true);
|
|
951
1280
|
}
|
|
952
1281
|
session.pendingMessages.clear();
|
|
1282
|
+
// Arm a backstop before interrupting: if a prompt is actively consuming
|
|
1283
|
+
// the query and interrupt() doesn't make the SDK yield (e.g. a wedged
|
|
1284
|
+
// TaskOutput block — issue #680), force the loop to return "cancelled"
|
|
1285
|
+
// after the floor elapses so the pending session/prompt still resolves per
|
|
1286
|
+
// the ACP cancellation contract instead of hanging forever. The loop's
|
|
1287
|
+
// `finally` clears this timer when interrupt() works and it returns through
|
|
1288
|
+
// the normal idle path, so on healthy cancels it is armed but never fires.
|
|
1289
|
+
//
|
|
1290
|
+
// Arm at most once per turn: the floor is an absolute ceiling from the
|
|
1291
|
+
// first cancel, so a client that re-sends cancel (each call still retries
|
|
1292
|
+
// interrupt() below) can't keep pushing the deadline out.
|
|
1293
|
+
if (session.promptRunning &&
|
|
1294
|
+
session.cancelController &&
|
|
1295
|
+
!session.cancelController.signal.aborted &&
|
|
1296
|
+
!session.forceCancelTimer) {
|
|
1297
|
+
const cancelController = session.cancelController;
|
|
1298
|
+
session.forceCancelTimer = setTimeout(() => {
|
|
1299
|
+
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.`);
|
|
1300
|
+
cancelController.abort();
|
|
1301
|
+
}, this.forceCancelGraceMs);
|
|
1302
|
+
}
|
|
953
1303
|
await session.query.interrupt();
|
|
954
1304
|
}
|
|
955
1305
|
/** Cleanly tear down a session: cancel in-flight work, dispose resources,
|
|
@@ -960,6 +1310,18 @@ export class ClaudeAcpAgent {
|
|
|
960
1310
|
return;
|
|
961
1311
|
}
|
|
962
1312
|
await this.cancel({ sessionId });
|
|
1313
|
+
// cancel() arms the force-cancel floor and interrupts gracefully, but a
|
|
1314
|
+
// wedged prompt loop only wakes when `cancelController` aborts — closing
|
|
1315
|
+
// the query/abortController below doesn't touch it. Since we're tearing the
|
|
1316
|
+
// session down anyway, wake the loop now so the in-flight prompt() resolves
|
|
1317
|
+
// immediately instead of after the floor, and clear the timer so it can't
|
|
1318
|
+
// outlive the deleted session (it isn't unref'd and would otherwise keep
|
|
1319
|
+
// the event loop alive until it fires).
|
|
1320
|
+
if (session.forceCancelTimer) {
|
|
1321
|
+
clearTimeout(session.forceCancelTimer);
|
|
1322
|
+
session.forceCancelTimer = undefined;
|
|
1323
|
+
}
|
|
1324
|
+
session.cancelController?.abort();
|
|
963
1325
|
session.settingsManager.dispose();
|
|
964
1326
|
session.abortController.abort();
|
|
965
1327
|
session.query.close();
|
|
@@ -976,7 +1338,7 @@ export class ClaudeAcpAgent {
|
|
|
976
1338
|
await this.teardownSession(params.sessionId);
|
|
977
1339
|
return {};
|
|
978
1340
|
}
|
|
979
|
-
async
|
|
1341
|
+
async deleteSession(params) {
|
|
980
1342
|
// Tear down any active in-memory state first so the on-disk file isn't
|
|
981
1343
|
// recreated by an outstanding query writing to it.
|
|
982
1344
|
if (this.sessions[params.sessionId]) {
|
|
@@ -985,19 +1347,6 @@ export class ClaudeAcpAgent {
|
|
|
985
1347
|
await deleteSession(params.sessionId);
|
|
986
1348
|
return {};
|
|
987
1349
|
}
|
|
988
|
-
async unstable_setSessionModel(params) {
|
|
989
|
-
const session = this.sessions[params.sessionId];
|
|
990
|
-
if (!session) {
|
|
991
|
-
throw new Error("Session not found");
|
|
992
|
-
}
|
|
993
|
-
// Resolve aliases (e.g. "opus", "opus[1m]") to canonical model IDs so
|
|
994
|
-
// downstream lookups in modelInfos succeed and the effort option isn't
|
|
995
|
-
// silently dropped.
|
|
996
|
-
const resolved = resolveModelPreference(session.modelInfos, params.modelId);
|
|
997
|
-
const modelId = resolved?.value ?? params.modelId;
|
|
998
|
-
await session.query.setModel(modelId);
|
|
999
|
-
await this.updateConfigOption(params.sessionId, "model", modelId);
|
|
1000
|
-
}
|
|
1001
1350
|
async setSessionMode(params) {
|
|
1002
1351
|
if (!this.sessions[params.sessionId]) {
|
|
1003
1352
|
throw new Error("Session not found");
|
|
@@ -1100,6 +1449,15 @@ export class ClaudeAcpAgent {
|
|
|
1100
1449
|
const toolUseCache = {};
|
|
1101
1450
|
const messages = await getSessionMessages(sessionId);
|
|
1102
1451
|
for (const message of messages) {
|
|
1452
|
+
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
|
1453
|
+
// observe live (resumed/loaded sessions), so rewind/resume can translate
|
|
1454
|
+
// a client-supplied id without an extra getSessionMessages read. Not read
|
|
1455
|
+
// yet (see Session.messageIdToUuid).
|
|
1456
|
+
const replayMessageId = messageIdForGrouping(message);
|
|
1457
|
+
const replaySession = this.sessions[sessionId];
|
|
1458
|
+
if (replaySession && replayMessageId && message.uuid) {
|
|
1459
|
+
replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
|
|
1460
|
+
}
|
|
1103
1461
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
1104
1462
|
let content = message.message.content;
|
|
1105
1463
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
@@ -1117,6 +1475,7 @@ export class ClaudeAcpAgent {
|
|
|
1117
1475
|
clientCapabilities: this.clientCapabilities,
|
|
1118
1476
|
cwd: this.sessions[sessionId]?.cwd,
|
|
1119
1477
|
taskState: this.sessions[sessionId]?.taskState,
|
|
1478
|
+
messageId: replayMessageId,
|
|
1120
1479
|
})) {
|
|
1121
1480
|
await this.client.sessionUpdate(notification);
|
|
1122
1481
|
}
|
|
@@ -1141,6 +1500,13 @@ export class ClaudeAcpAgent {
|
|
|
1141
1500
|
message: "Session not found",
|
|
1142
1501
|
};
|
|
1143
1502
|
}
|
|
1503
|
+
// AskUserQuestion is surfaced to us as a normal permission check (the SDK
|
|
1504
|
+
// routes it through canUseTool whenever a callback is registered, rather
|
|
1505
|
+
// than the interactive dialog). Present it as an ACP form elicitation and
|
|
1506
|
+
// feed the answers back as updatedInput for the tool's own call() to read.
|
|
1507
|
+
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
|
1508
|
+
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
|
1509
|
+
}
|
|
1144
1510
|
if (toolName === "ExitPlanMode") {
|
|
1145
1511
|
const optionsAll = [
|
|
1146
1512
|
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
|
@@ -1266,6 +1632,62 @@ export class ClaudeAcpAgent {
|
|
|
1266
1632
|
}
|
|
1267
1633
|
};
|
|
1268
1634
|
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Handle elicitation requests that originate from MCP servers by forwarding
|
|
1637
|
+
* them to the client over ACP. Modes the client did not advertise (or
|
|
1638
|
+
* requests we can't represent) are declined.
|
|
1639
|
+
*/
|
|
1640
|
+
handleMcpElicitation(sessionId, support) {
|
|
1641
|
+
return async (request, { signal }) => {
|
|
1642
|
+
const isUrl = request.mode === "url";
|
|
1643
|
+
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
|
1644
|
+
return { action: "decline" };
|
|
1645
|
+
}
|
|
1646
|
+
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
|
1647
|
+
if (!createRequest) {
|
|
1648
|
+
return { action: "decline" };
|
|
1649
|
+
}
|
|
1650
|
+
try {
|
|
1651
|
+
const response = await this.client.unstable_createElicitation(createRequest);
|
|
1652
|
+
if (signal.aborted) {
|
|
1653
|
+
return { action: "cancel" };
|
|
1654
|
+
}
|
|
1655
|
+
return createElicitationResponseToElicitResult(response);
|
|
1656
|
+
}
|
|
1657
|
+
catch (error) {
|
|
1658
|
+
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
|
1659
|
+
return { action: "decline" };
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Present the built-in AskUserQuestion tool's questions as an ACP form
|
|
1665
|
+
* elicitation and return the answers as the tool's `updatedInput`. Called from
|
|
1666
|
+
* `canUseTool` since that is where the SDK routes the tool's permission check.
|
|
1667
|
+
*/
|
|
1668
|
+
async handleAskUserQuestion(sessionId, toolInput, toolUseID, signal) {
|
|
1669
|
+
const questions = extractAskUserQuestions(toolInput);
|
|
1670
|
+
if (!questions) {
|
|
1671
|
+
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
|
1672
|
+
}
|
|
1673
|
+
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
|
1674
|
+
let response;
|
|
1675
|
+
try {
|
|
1676
|
+
response = await this.client.unstable_createElicitation(createRequest);
|
|
1677
|
+
}
|
|
1678
|
+
catch (error) {
|
|
1679
|
+
this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
|
1680
|
+
return { behavior: "deny", message: "Could not present the question to the user." };
|
|
1681
|
+
}
|
|
1682
|
+
if (signal.aborted) {
|
|
1683
|
+
throw new Error("Tool use aborted");
|
|
1684
|
+
}
|
|
1685
|
+
const outcome = applyAskElicitationResponse(response, toolInput, questions);
|
|
1686
|
+
if (outcome.action === "cancel") {
|
|
1687
|
+
throw new Error("Tool use aborted");
|
|
1688
|
+
}
|
|
1689
|
+
return { behavior: "allow", updatedInput: outcome.updatedInput };
|
|
1690
|
+
}
|
|
1269
1691
|
async sendAvailableCommandsUpdate(sessionId) {
|
|
1270
1692
|
const session = this.sessions[sessionId];
|
|
1271
1693
|
if (!session)
|
|
@@ -1380,7 +1802,6 @@ export class ClaudeAcpAgent {
|
|
|
1380
1802
|
return {
|
|
1381
1803
|
sessionId: params.sessionId,
|
|
1382
1804
|
modes: existingSession.modes,
|
|
1383
|
-
models: existingSession.models,
|
|
1384
1805
|
configOptions: existingSession.configOptions,
|
|
1385
1806
|
};
|
|
1386
1807
|
}
|
|
@@ -1400,11 +1821,37 @@ export class ClaudeAcpAgent {
|
|
|
1400
1821
|
return {
|
|
1401
1822
|
sessionId: response.sessionId,
|
|
1402
1823
|
modes: response.modes,
|
|
1403
|
-
models: response.models,
|
|
1404
1824
|
configOptions: response.configOptions,
|
|
1405
1825
|
};
|
|
1406
1826
|
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Ensures the requested `cwd` is an absolute path that points at an existing
|
|
1829
|
+
* directory before we create a session. Throws an `invalidParams` error with
|
|
1830
|
+
* an actionable message so clients (e.g. Zed) can surface it to the user
|
|
1831
|
+
* instead of failing later with an opaque SDK error.
|
|
1832
|
+
*/
|
|
1833
|
+
async validateCwd(cwd) {
|
|
1834
|
+
if (!path.isAbsolute(cwd)) {
|
|
1835
|
+
throw RequestError.invalidParams({ cwd }, `\`cwd\` must be an absolute path, but received: ${cwd}`);
|
|
1836
|
+
}
|
|
1837
|
+
let stats;
|
|
1838
|
+
try {
|
|
1839
|
+
stats = await fs.stat(cwd);
|
|
1840
|
+
}
|
|
1841
|
+
catch {
|
|
1842
|
+
throw RequestError.invalidParams({ cwd }, `\`cwd\` does not exist on the machine running the agent: ${cwd}`);
|
|
1843
|
+
}
|
|
1844
|
+
if (!stats.isDirectory()) {
|
|
1845
|
+
throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1407
1848
|
async createSession(params, creationOpts = {}) {
|
|
1849
|
+
// Validate `cwd` up front. The ACP spec requires an absolute path, and the
|
|
1850
|
+
// directory must actually exist on the machine running the agent. Without
|
|
1851
|
+
// this check a session is created against a missing directory and the
|
|
1852
|
+
// failure only surfaces later as a confusing "native binary failed to
|
|
1853
|
+
// launch" error from the SDK (see issue #749).
|
|
1854
|
+
await this.validateCwd(params.cwd);
|
|
1408
1855
|
// We want to create a new session id unless it is resume,
|
|
1409
1856
|
// but not resume + forkSession.
|
|
1410
1857
|
let sessionId;
|
|
@@ -1470,13 +1917,17 @@ export class ClaudeAcpAgent {
|
|
|
1470
1917
|
// Extract options from _meta if provided
|
|
1471
1918
|
const sessionMeta = params._meta;
|
|
1472
1919
|
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
1473
|
-
// Configure thinking
|
|
1474
|
-
const
|
|
1475
|
-
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
|
|
1476
|
-
: undefined;
|
|
1920
|
+
// Configure thinking behavior from environment variable
|
|
1921
|
+
const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
|
|
1477
1922
|
// Parse model configuration from environment (e.g. Bedrock model overrides)
|
|
1478
1923
|
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
|
|
1479
|
-
//
|
|
1924
|
+
// Elicitation modes the connected client advertised. We only forward
|
|
1925
|
+
// elicitations (and only re-enable AskUserQuestion) for modes the client
|
|
1926
|
+
// can actually render.
|
|
1927
|
+
const elicitationSupport = {
|
|
1928
|
+
form: !!this.clientCapabilities?.elicitation?.form,
|
|
1929
|
+
url: !!this.clientCapabilities?.elicitation?.url,
|
|
1930
|
+
};
|
|
1480
1931
|
const disallowedTools = ["AskUserQuestion", "WebFetch", "WebSearch"];
|
|
1481
1932
|
// Resolve which built-in tools to expose.
|
|
1482
1933
|
// Explicit tools array from _meta.claudeCode.options takes precedence.
|
|
@@ -1492,7 +1943,7 @@ export class ClaudeAcpAgent {
|
|
|
1492
1943
|
const options = {
|
|
1493
1944
|
systemPrompt,
|
|
1494
1945
|
settingSources: ["user", "project", "local"],
|
|
1495
|
-
...(
|
|
1946
|
+
...(thinking !== undefined && { thinking }),
|
|
1496
1947
|
...userProvidedOptions,
|
|
1497
1948
|
// CLAUDE_MODEL_CONFIG env var is a fallback for model
|
|
1498
1949
|
// configuration (e.g. Bedrock model ID overrides). When the caller
|
|
@@ -1521,6 +1972,13 @@ export class ClaudeAcpAgent {
|
|
|
1521
1972
|
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
1522
1973
|
permissionMode,
|
|
1523
1974
|
canUseTool: this.canUseTool(sessionId),
|
|
1975
|
+
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
|
1976
|
+
// when the client advertised support, so non-supporting clients keep the
|
|
1977
|
+
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
|
|
1978
|
+
// canUseTool, not here.)
|
|
1979
|
+
...(elicitationSupport.form || elicitationSupport.url
|
|
1980
|
+
? { onElicitation: this.handleMcpElicitation(sessionId, elicitationSupport) }
|
|
1981
|
+
: {}),
|
|
1524
1982
|
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
|
|
1525
1983
|
extraArgs: {
|
|
1526
1984
|
...userProvidedOptions?.extraArgs,
|
|
@@ -1709,10 +2167,11 @@ export class ClaudeAcpAgent {
|
|
|
1709
2167
|
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
1710
2168
|
contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
|
1711
2169
|
taskState,
|
|
2170
|
+
toolUseCache: {},
|
|
2171
|
+
messageIdToUuid: new Map(),
|
|
1712
2172
|
};
|
|
1713
2173
|
return {
|
|
1714
2174
|
sessionId,
|
|
1715
|
-
models,
|
|
1716
2175
|
modes,
|
|
1717
2176
|
configOptions,
|
|
1718
2177
|
};
|
|
@@ -1951,11 +2410,16 @@ function tokenizeModelPreference(model) {
|
|
|
1951
2410
|
function scoreModelMatch(model, tokens, contextHint) {
|
|
1952
2411
|
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
|
|
1953
2412
|
let score = 0;
|
|
2413
|
+
let nonHintMatched = false;
|
|
1954
2414
|
for (const token of tokens) {
|
|
1955
2415
|
if (haystack.includes(token)) {
|
|
2416
|
+
if (token !== contextHint)
|
|
2417
|
+
nonHintMatched = true;
|
|
1956
2418
|
score += token === contextHint ? 3 : 1;
|
|
1957
2419
|
}
|
|
1958
2420
|
}
|
|
2421
|
+
if (contextHint && !nonHintMatched)
|
|
2422
|
+
return 0;
|
|
1959
2423
|
return score;
|
|
1960
2424
|
}
|
|
1961
2425
|
function resolveModelPreference(models, preference) {
|
|
@@ -2223,6 +2687,42 @@ export function promptToClaude(prompt) {
|
|
|
2223
2687
|
parent_tool_use_id: null,
|
|
2224
2688
|
};
|
|
2225
2689
|
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
|
|
2692
|
+
* transcript message (replay) so chunk grouping is identical in both views.
|
|
2693
|
+
*
|
|
2694
|
+
* Assistant turns are keyed by the Anthropic API message id (`message.id`),
|
|
2695
|
+
* which is identical at `message_start`, on the consolidated assistant message,
|
|
2696
|
+
* and in the persisted transcript — unlike the per-`stream_event` uuid, which is
|
|
2697
|
+
* unique per event and never persisted. User messages have no API id, but they
|
|
2698
|
+
* are never streamed, so their (stable) SDK uuid is used instead. ACP message
|
|
2699
|
+
* ids are opaque strings, so no particular format is required.
|
|
2700
|
+
*/
|
|
2701
|
+
export function messageIdForGrouping(message) {
|
|
2702
|
+
if (message.type === "assistant") {
|
|
2703
|
+
const inner = message.message;
|
|
2704
|
+
const apiId = inner && typeof inner === "object" && "id" in inner
|
|
2705
|
+
? inner.id
|
|
2706
|
+
: undefined;
|
|
2707
|
+
if (typeof apiId === "string" && apiId.length > 0) {
|
|
2708
|
+
return apiId;
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
|
|
2712
|
+
}
|
|
2713
|
+
/**
|
|
2714
|
+
* Stamps an ACP `messageId` onto a session update, but only on the message/
|
|
2715
|
+
* thought chunk variants that carry one — tool_call/plan/etc. updates never do.
|
|
2716
|
+
* No-op when `messageId` is falsy, so callers can pass it through unconditionally.
|
|
2717
|
+
*/
|
|
2718
|
+
function applyMessageId(update, messageId) {
|
|
2719
|
+
if (messageId &&
|
|
2720
|
+
(update.sessionUpdate === "agent_message_chunk" ||
|
|
2721
|
+
update.sessionUpdate === "user_message_chunk" ||
|
|
2722
|
+
update.sessionUpdate === "agent_thought_chunk")) {
|
|
2723
|
+
update.messageId = messageId;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2226
2726
|
/**
|
|
2227
2727
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
2228
2728
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -2239,6 +2739,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2239
2739
|
text: content,
|
|
2240
2740
|
},
|
|
2241
2741
|
};
|
|
2742
|
+
applyMessageId(update, options?.messageId);
|
|
2242
2743
|
if (options?.parentToolUseId) {
|
|
2243
2744
|
update._meta = {
|
|
2244
2745
|
...update._meta,
|
|
@@ -2311,40 +2812,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2311
2812
|
else {
|
|
2312
2813
|
// Only register hooks on first encounter to avoid double-firing
|
|
2313
2814
|
if (registerHooks && !alreadyCached) {
|
|
2815
|
+
// Capture the tool name in the closure rather than re-reading the
|
|
2816
|
+
// cache when the hook fires. The cache entry is pruned at
|
|
2817
|
+
// tool_result time, and a PostToolUse hook can fire after that, so
|
|
2818
|
+
// closing over the name keeps the diff working without depending on
|
|
2819
|
+
// (or pinning) the cache entry's lifetime.
|
|
2820
|
+
const toolName = chunk.name;
|
|
2314
2821
|
registerHookCallback(chunk.id, {
|
|
2315
2822
|
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
|
-
},
|
|
2823
|
+
// Both `Edit` and `Write` produce a structuredPatch in their
|
|
2824
|
+
// PostToolUse tool_response. For Edit the diff replaces the
|
|
2825
|
+
// optimistic content built at tool_use time. For Write the
|
|
2826
|
+
// optimistic content (built from `input.content` alone with
|
|
2827
|
+
// `oldText: null`) shows "creation" semantics regardless of
|
|
2828
|
+
// whether the file existed; the structuredPatch from the
|
|
2829
|
+
// hook lets us emit the real diff for `type: "update"`. The
|
|
2830
|
+
// helper returns `{}` if the response shape isn't usable.
|
|
2831
|
+
const editDiff = toolName === "Edit" || toolName === "Write"
|
|
2832
|
+
? toolUpdateFromDiffToolResponse(toolResponse)
|
|
2833
|
+
: {};
|
|
2834
|
+
const update = {
|
|
2835
|
+
_meta: {
|
|
2836
|
+
claudeCode: {
|
|
2837
|
+
toolResponse,
|
|
2838
|
+
toolName,
|
|
2335
2839
|
},
|
|
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
|
-
}
|
|
2840
|
+
},
|
|
2841
|
+
toolCallId: toolUseId,
|
|
2842
|
+
sessionUpdate: "tool_call_update",
|
|
2843
|
+
...editDiff,
|
|
2844
|
+
};
|
|
2845
|
+
await client.sessionUpdate({
|
|
2846
|
+
sessionId,
|
|
2847
|
+
update,
|
|
2848
|
+
});
|
|
2348
2849
|
},
|
|
2349
2850
|
});
|
|
2350
2851
|
}
|
|
@@ -2467,6 +2968,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2467
2968
|
...toolUpdate,
|
|
2468
2969
|
};
|
|
2469
2970
|
}
|
|
2971
|
+
// The tool_use is fully resolved now — drop it so a long session doesn't
|
|
2972
|
+
// retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
|
|
2973
|
+
// over the tool name and no longer reads the cache, so pruning here is
|
|
2974
|
+
// safe regardless of hook/result ordering.
|
|
2975
|
+
delete toolUseCache[chunk.tool_use_id];
|
|
2470
2976
|
break;
|
|
2471
2977
|
}
|
|
2472
2978
|
case "document":
|
|
@@ -2479,6 +2985,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2479
2985
|
case "compaction":
|
|
2480
2986
|
case "compaction_delta":
|
|
2481
2987
|
case "advisor_tool_result":
|
|
2988
|
+
case "mid_conv_system":
|
|
2989
|
+
case "fallback":
|
|
2482
2990
|
break;
|
|
2483
2991
|
default:
|
|
2484
2992
|
unreachable(chunk, logger);
|
|
@@ -2494,6 +3002,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
2494
3002
|
},
|
|
2495
3003
|
};
|
|
2496
3004
|
}
|
|
3005
|
+
applyMessageId(update, options?.messageId);
|
|
2497
3006
|
output.push({ sessionId, update });
|
|
2498
3007
|
}
|
|
2499
3008
|
}
|
|
@@ -2508,6 +3017,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
2508
3017
|
parentToolUseId: message.parent_tool_use_id,
|
|
2509
3018
|
cwd: options?.cwd,
|
|
2510
3019
|
taskState: options?.taskState,
|
|
3020
|
+
messageId: options?.messageId,
|
|
2511
3021
|
});
|
|
2512
3022
|
case "content_block_delta":
|
|
2513
3023
|
return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
|
|
@@ -2515,6 +3025,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
2515
3025
|
parentToolUseId: message.parent_tool_use_id,
|
|
2516
3026
|
cwd: options?.cwd,
|
|
2517
3027
|
taskState: options?.taskState,
|
|
3028
|
+
messageId: options?.messageId,
|
|
2518
3029
|
});
|
|
2519
3030
|
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|
|
2520
3031
|
// `BetaRawMessageStreamEvent` union doesn't include even though the
|
|
@@ -2550,7 +3061,8 @@ function commonPrefixLength(a, b) {
|
|
|
2550
3061
|
return i;
|
|
2551
3062
|
}
|
|
2552
3063
|
/** Best-effort first guess of a model's context window from its ID, used only
|
|
2553
|
-
*
|
|
3064
|
+
* as a fallback when the SDK's authoritative `getContextUsage` is unavailable
|
|
3065
|
+
* (and until a `result` message arrives with the `modelUsage` value).
|
|
2554
3066
|
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
|
2555
3067
|
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
|
2556
3068
|
* matching things like "10m" or embedded substrings. */
|
|
@@ -2559,6 +3071,42 @@ function inferContextWindowFromModel(model) {
|
|
|
2559
3071
|
return 1_000_000;
|
|
2560
3072
|
return null;
|
|
2561
3073
|
}
|
|
3074
|
+
/** Fetch the SDK's authoritative context-window occupancy via the
|
|
3075
|
+
* `getContextUsage` control request. Unlike the per-message API usage numbers
|
|
3076
|
+
* (which only count message tokens), this `totalTokens` includes the system
|
|
3077
|
+
* prompt, tool schemas, MCP tools, and memory-file overhead — the real
|
|
3078
|
+
* occupancy the user sees. Returns `null` on any control-request failure.
|
|
3079
|
+
*
|
|
3080
|
+
* Note: we deliberately do NOT use this response's window fields for `size`.
|
|
3081
|
+
* They have been observed to under-report extended (1M) context windows, so
|
|
3082
|
+
* the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
|
|
3083
|
+
* which handle the 1M variants correctly. */
|
|
3084
|
+
async function fetchContextUsedTokens(query, logger) {
|
|
3085
|
+
try {
|
|
3086
|
+
const usage = await query.getContextUsage();
|
|
3087
|
+
return usage.totalTokens;
|
|
3088
|
+
}
|
|
3089
|
+
catch (error) {
|
|
3090
|
+
logger.error("Failed to fetch context usage from SDK:", error);
|
|
3091
|
+
return null;
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
/** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
|
|
3095
|
+
* option. The `maxThinkingTokens` option it used to feed is deprecated and
|
|
3096
|
+
* reduced to on/off on current models, so map the value to explicit thinking
|
|
3097
|
+
* config instead: unset → `undefined` (SDK default, adaptive on models that
|
|
3098
|
+
* support it); `0` → disabled; a positive integer → a fixed token budget.
|
|
3099
|
+
* Anything else is ignored with a warning. */
|
|
3100
|
+
function resolveThinkingConfig(raw, logger) {
|
|
3101
|
+
if (raw === undefined)
|
|
3102
|
+
return undefined;
|
|
3103
|
+
const parsed = Number.parseInt(raw, 10);
|
|
3104
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
3105
|
+
logger.error(`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${raw}'.`);
|
|
3106
|
+
return undefined;
|
|
3107
|
+
}
|
|
3108
|
+
return parsed === 0 ? { type: "disabled" } : { type: "enabled", budgetTokens: parsed };
|
|
3109
|
+
}
|
|
2562
3110
|
function parseModelConfig(raw) {
|
|
2563
3111
|
if (!raw)
|
|
2564
3112
|
return undefined;
|