claude-code-acp-ts 0.38.2-beta.1 → 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.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 LOCAL_COMMAND_TAG_PATTERN = /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
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
- return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
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
- this.backgroundTerminals = {};
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 { value: message, done } = await session.query.next();
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
- // Send used:0 immediately so the client doesn't keep showing
502
- // the stale pre-compaction context size until the next turn.
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
- // This is a deliberate approximation: we don't know the exact
505
- // post-compaction token count (only the SDK's next API call
506
- // reveals that). But used:0 is directionally correct — context
507
- // just dropped dramatically — and the real value replaces it
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: 0,
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
- case "elicitation_complete":
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 "mirror_error":
600
- case "permission_denied":
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, this.toolUseCache, this.client, this.logger)) {
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, this.toolUseCache, this.client, this.logger, {
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 (typeof message.message.content === "string" &&
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, this.toolUseCache, this.client, this.logger, {
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
- const content = message.type === "assistant"
849
- ? // Handled by stream events above
850
- message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
851
- : message.message.content;
852
- for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger, {
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 unstable_deleteSession(params) {
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 tokens from environment variable
1474
- const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
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
- // Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
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
- ...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
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
- const toolUse = toolUseCache[toolUseId];
2317
- if (toolUse) {
2318
- // Both `Edit` and `Write` produce a structuredPatch in their
2319
- // PostToolUse tool_response. For Edit the diff replaces the
2320
- // optimistic content built at tool_use time. For Write the
2321
- // optimistic content (built from `input.content` alone with
2322
- // `oldText: null`) shows "creation" semantics regardless of
2323
- // whether the file existed; the structuredPatch from the
2324
- // hook lets us emit the real diff for `type: "update"`. The
2325
- // helper returns `{}` if the response shape isn't usable.
2326
- const editDiff = toolUse.name === "Edit" || toolUse.name === "Write"
2327
- ? toolUpdateFromDiffToolResponse(toolResponse)
2328
- : {};
2329
- const update = {
2330
- _meta: {
2331
- claudeCode: {
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
- toolCallId: toolUseId,
2337
- sessionUpdate: "tool_call_update",
2338
- ...editDiff,
2339
- };
2340
- await client.sessionUpdate({
2341
- sessionId,
2342
- update,
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
- * until a `result` message arrives with the authoritative `modelUsage` value.
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;