@lucascouts/claude-agent-acp-plus 0.3.0 → 0.5.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
@@ -8,7 +8,6 @@ import * as path from "node:path";
8
8
  import { promisify } from "node:util";
9
9
  import packageJson from "../package.json" with { type: "json" };
10
10
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
11
- import { askUserQuestionFallbackEnabled, handleAskUserQuestionViaPermission, } from "./ask-user-question-fallback.js";
12
11
  import { agentName } from "./agent-name.js";
13
12
  import { filterDeprecatedModels } from "./model-deprecation.js";
14
13
  import { SettingsManager } from "./settings.js";
@@ -63,6 +62,87 @@ const QUERY_RECREATE_INIT_TIMEOUT_MS = 30_000;
63
62
  * completed/stalled without the host turn resolving (issue #825). */
64
63
  const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
65
64
  "(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
65
+ /** Custom (extension) request method a client uses to steer the turn that is
66
+ * currently running: the message is injected into the in-flight turn rather
67
+ * than queued as a separate `session/prompt`. Named `_session/steering` per the
68
+ * agreed ACP steering wire protocol; advertised to clients via the top-level
69
+ * `InitializeResponse._meta.steering.supported`. */
70
+ const STEER_METHOD = "_session/steering";
71
+ /** How urgently the SDK delivers a steered message relative to the running
72
+ * turn — an internal Claude implementation detail, not part of the wire
73
+ * contract. `now` pre-empts the current generation and handles the message
74
+ * immediately (interrupting a single-shot response, or slotting in between a
75
+ * multi-step turn's tool calls). Maps to `SDKUserMessage.priority`; injected
76
+ * steering always uses `now` so the running turn adapts as soon as possible. */
77
+ const STEER_PRIORITY = "now";
78
+ /** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
79
+ * content blocks are handed to `promptToClaude`, which tolerates unknown block
80
+ * types — but `sessionId` and a non-empty `prompt` array are required. */
81
+ function parseSteerRequest(params) {
82
+ if (!params || typeof params !== "object") {
83
+ throw RequestError.invalidParams(undefined, "steer params must be an object");
84
+ }
85
+ const { sessionId, prompt, _meta } = params;
86
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
87
+ throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
88
+ }
89
+ if (!Array.isArray(prompt) || prompt.length === 0) {
90
+ throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
91
+ }
92
+ const steering = _meta && typeof _meta === "object" ? _meta.steering : undefined;
93
+ const idleBehavior = steering && typeof steering === "object"
94
+ ? steering.idleBehavior
95
+ : undefined;
96
+ if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
97
+ throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
98
+ }
99
+ return {
100
+ sessionId,
101
+ prompt: prompt,
102
+ _meta: _meta,
103
+ };
104
+ }
105
+ /** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
106
+ * model did on its own (a task-notification followup, a peer/coordinator/
107
+ * observer message it handled) rather than the user's prompt. Absent,
108
+ * `human`, and `channel` origins are the user's own turn (this adapter's
109
+ * prompts arrive as the ACP channel on some CLI configurations — ALL
110
+ * channel servers are treated as user, so a foreign channel integration's
111
+ * autonomously-handled result is misclassified as the user's; accepted,
112
+ * see below), and `auto-continuation` continues the user's turn, so its
113
+ * result is the turn's real terminal.
114
+ *
115
+ * Deliberately fail-OPEN: an unknown future kind defaults to the user
116
+ * lane. Misrouting a USER result into the autonomous lane hangs the
117
+ * prompt un-detectably (the result is skipped, its trailing idle absorbed
118
+ * as owed, so the #825 detector can't fire); misrouting an autonomous
119
+ * result into the user lane is the bounded misattribution class this set
120
+ * exists to reduce. */
121
+ const AUTONOMOUS_RESULT_ORIGINS = new Set([
122
+ "task-notification",
123
+ "peer",
124
+ "coordinator",
125
+ "observer",
126
+ "observer-activity",
127
+ ]);
128
+ /** Whether this turn's terminal result arrived but its settlement is being
129
+ * held for background subagents it spawned (see Turn.deferredSettle). The
130
+ * single spelling of the hold predicate, shared by the consumer's settle
131
+ * lanes and cancel(). */
132
+ function isHeldOpen(turn) {
133
+ return turn != null && turn.deferredSettle !== undefined && !turn.settled;
134
+ }
135
+ /** Disarm the force-cancel backstop (see Session.forceCancelTimer). Every
136
+ * path that settles the active turn must run this so a timer can never fire
137
+ * on an already-settled turn — and must leave the field undefined, or the
138
+ * arm site's !forceCancelTimer guard would refuse to arm the backstop for
139
+ * the NEXT turn's cancel. */
140
+ function disarmForceCancel(session) {
141
+ if (session.forceCancelTimer) {
142
+ clearTimeout(session.forceCancelTimer);
143
+ session.forceCancelTimer = undefined;
144
+ }
145
+ }
66
146
  /** Compute a stable fingerprint of the session-defining params so we can
67
147
  * detect when a loadSession/resumeSession call requires tearing down and
68
148
  * recreating the underlying Query process. MCP servers are sorted by name
@@ -71,6 +151,95 @@ function computeSessionFingerprint(params) {
71
151
  const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
72
152
  return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
73
153
  }
154
+ /**
155
+ * The single provider ID this agent exposes via `providers/*`. Claude Code has
156
+ * one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
157
+ * is exactly one configurable provider.
158
+ */
159
+ const PROVIDER_ID = "main";
160
+ /**
161
+ * Protocols the `main` provider can be configured with. These mirror the
162
+ * env-var mappings understood by {@link createEnvForProvider}.
163
+ */
164
+ const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
165
+ const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
166
+ function supportsSubagentTranscript(capabilities) {
167
+ return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
168
+ }
169
+ function parentToolUseIdOf(message) {
170
+ if (!("parent_tool_use_id" in message))
171
+ return null;
172
+ return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null;
173
+ }
174
+ function stripSubagentTextAndThinking(content) {
175
+ if (!Array.isArray(content))
176
+ return content;
177
+ return content.filter((item) => !item ||
178
+ typeof item !== "object" ||
179
+ !("type" in item) ||
180
+ (item.type !== "text" && item.type !== "thinking"));
181
+ }
182
+ /**
183
+ * Advance the lexer state across the fragment appended since the last delta:
184
+ * just enough JSON awareness (string/escape, nesting depth) to spot commas
185
+ * that sit at the top level of the input object — everything before such a
186
+ * comma is a set of complete fields. Returns true once the input object's
187
+ * closing brace arrives.
188
+ */
189
+ function scanStreamedToolInput(state) {
190
+ let complete = false;
191
+ for (let index = state.scannedTo; index < state.partialJson.length; index++) {
192
+ const character = state.partialJson[index];
193
+ if (state.inString) {
194
+ if (state.escaped) {
195
+ state.escaped = false;
196
+ }
197
+ else if (character === "\\") {
198
+ state.escaped = true;
199
+ }
200
+ else if (character === '"') {
201
+ state.inString = false;
202
+ }
203
+ continue;
204
+ }
205
+ if (character === '"') {
206
+ state.inString = true;
207
+ }
208
+ else if (character === "{") {
209
+ state.objectDepth++;
210
+ }
211
+ else if (character === "}") {
212
+ state.objectDepth--;
213
+ if (state.objectDepth === 0) {
214
+ complete = true;
215
+ }
216
+ }
217
+ else if (character === "[") {
218
+ state.arrayDepth++;
219
+ }
220
+ else if (character === "]") {
221
+ state.arrayDepth--;
222
+ }
223
+ else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
224
+ state.lastTopLevelComma = index;
225
+ }
226
+ }
227
+ state.scannedTo = state.partialJson.length;
228
+ return complete;
229
+ }
230
+ /** Parse the complete top-level fields before a top-level comma by closing the
231
+ * object at that boundary. */
232
+ function recoveredToolInput(prefix) {
233
+ try {
234
+ const value = JSON.parse(prefix + "}");
235
+ return value !== null && typeof value === "object" && !Array.isArray(value)
236
+ ? value
237
+ : undefined;
238
+ }
239
+ catch {
240
+ return undefined;
241
+ }
242
+ }
74
243
  export async function claudeCliPath() {
75
244
  if (process.env.CLAUDE_CODE_EXECUTABLE) {
76
245
  return process.env.CLAUDE_CODE_EXECUTABLE;
@@ -203,6 +372,36 @@ export function stripLocalCommandMetadata(content) {
203
372
  export function isLocalCommandMetadata(content) {
204
373
  return stripLocalCommandMetadata(content) === null;
205
374
  }
375
+ /**
376
+ * True for the synthetic assistant message the CLI injects into the transcript
377
+ * when a turn fails authentication (e.g. "Not logged in · Please run /login",
378
+ * "Session expired. Please run /login to sign in again."). The `/login`
379
+ * instruction is Claude Code TUI-specific and meaningless to ACP clients
380
+ * (issue #863). The live prompt loop suppresses the text and fails the turn
381
+ * with `authRequired` so the client can run its own auth flow; replay must
382
+ * skip it too — both for parity with what the client saw live and because the
383
+ * message stays in the transcript forever, so it would resurface on every
384
+ * session/load even after the user has logged back in.
385
+ *
386
+ * Takes the API message (`message.message`), which replay only knows as
387
+ * `unknown`. The persisted record's structured `error: "authentication_failed"`
388
+ * marker is stripped by `getSessionMessages`, so the synthetic model + text is
389
+ * all both paths have to match on.
390
+ */
391
+ export function isSyntheticLoginMessage(apiMessage) {
392
+ if (!apiMessage || typeof apiMessage !== "object") {
393
+ return false;
394
+ }
395
+ const { model, content } = apiMessage;
396
+ if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
397
+ return false;
398
+ }
399
+ const block = content[0];
400
+ return (!!block &&
401
+ block.type === "text" &&
402
+ typeof block.text === "string" &&
403
+ block.text.includes("Please run /login"));
404
+ }
206
405
  const PERMISSION_MODE_ALIASES = {
207
406
  auto: "auto",
208
407
  default: "default",
@@ -316,6 +515,10 @@ export class ClaudeAcpAgent {
316
515
  clientCapabilities;
317
516
  logger;
318
517
  gatewayAuthRequest;
518
+ /** Client-managed LLM routing set via `providers/set`. Process-scoped and
519
+ * never persisted to disk (see the Configurable LLM Providers RFD). When
520
+ * set, it takes precedence over {@link gatewayAuthRequest}. */
521
+ providerConfig;
319
522
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
320
523
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
321
524
  * tests can shrink it. */
@@ -441,6 +644,10 @@ export class ClaudeAcpAgent {
441
644
  auth: {
442
645
  logout: {},
443
646
  },
647
+ // Client-managed LLM routing via `providers/list`, `providers/set`, and
648
+ // `providers/disable`. Advertised unconditionally; there is no client
649
+ // capability prerequisite for the provider methods.
650
+ providers: {},
444
651
  loadSession: true,
445
652
  sessionCapabilities: {
446
653
  additionalDirectories: {},
@@ -460,6 +667,14 @@ export class ClaudeAcpAgent {
460
667
  ...terminalAuthMethods,
461
668
  ...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
462
669
  ],
670
+ // Top-level `_meta` (sibling of `agentCapabilities`), per the existing ACP
671
+ // steering extension contract: advertises the `_session/steering` request
672
+ // so clients know they may inject a follow-up into a running turn.
673
+ _meta: {
674
+ steering: {
675
+ supported: true,
676
+ },
677
+ },
463
678
  };
464
679
  }
465
680
  async newSession(params) {
@@ -564,11 +779,100 @@ export class ClaudeAcpAgent {
564
779
  }
565
780
  throw new Error("Method not implemented.");
566
781
  }
782
+ /**
783
+ * `providers/list` — returns the single client-configurable custom gateway
784
+ * provider (`main`). `current` carries only non-secret routing (never headers,
785
+ * which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
786
+ * display, and is `null` when the provider is not configured/disabled. The
787
+ * provider is optional (`required: false`): while disabled/unconfigured the
788
+ * agent falls back to its own default routing (normal Claude login).
789
+ */
790
+ async unstable_listProviders(_params) {
791
+ const config = this.resolveProviderConfig();
792
+ const provider = {
793
+ providerId: PROVIDER_ID,
794
+ supported: SUPPORTED_PROTOCOLS,
795
+ required: false,
796
+ current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
797
+ };
798
+ return { providers: [provider] };
799
+ }
800
+ /**
801
+ * `providers/set` — replace the full configuration for the `main` provider.
802
+ * Rejects unknown IDs, unsupported protocols, and empty/invalid base URLs with
803
+ * `invalid_params`. Config is process-scoped and applies to sessions created or
804
+ * loaded after this call.
805
+ */
806
+ async unstable_setProvider(params) {
807
+ if (params.providerId !== PROVIDER_ID) {
808
+ throw RequestError.invalidParams({ providerId: params.providerId }, `Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`);
809
+ }
810
+ if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
811
+ throw RequestError.invalidParams({ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS }, `Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`);
812
+ }
813
+ if (!isValidBaseUrl(params.baseUrl)) {
814
+ throw RequestError.invalidParams({ baseUrl: params.baseUrl }, "baseUrl must be a non-empty absolute http(s) URL.");
815
+ }
816
+ const config = {
817
+ apiType: params.apiType,
818
+ baseUrl: params.baseUrl,
819
+ headers: params.headers ?? {},
820
+ };
821
+ // Vertex requires project + region, which the standard payload cannot
822
+ // carry, so they arrive via `_meta.claudeCode.vertex`.
823
+ if (params.apiType === "vertex") {
824
+ const vertex = params._meta?.claudeCode?.vertex;
825
+ if (!vertex ||
826
+ typeof vertex.projectId !== "string" ||
827
+ vertex.projectId.trim() === "" ||
828
+ typeof vertex.region !== "string" ||
829
+ vertex.region.trim() === "") {
830
+ throw RequestError.invalidParams(undefined, "vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.");
831
+ }
832
+ config.vertex = { projectId: vertex.projectId, region: vertex.region };
833
+ }
834
+ this.providerConfig = config;
835
+ return {};
836
+ }
837
+ /**
838
+ * `providers/disable` — disabling the `main` provider clears any client-managed
839
+ * routing (both a `providers/set` config and the legacy gateway auth request),
840
+ * so the agent reverts to its own default routing and `providers/list` reports
841
+ * `current: null`. Disabling any other (unknown) ID is treated as a successful
842
+ * no-op per the RFD's idempotency rule.
843
+ */
844
+ async unstable_disableProvider(params) {
845
+ if (params.providerId === PROVIDER_ID) {
846
+ this.providerConfig = undefined;
847
+ this.gatewayAuthRequest = undefined;
848
+ }
849
+ // Unknown provider: idempotent success.
850
+ return {};
851
+ }
852
+ /**
853
+ * Resolve the effective client-managed routing config. `providers/set` takes
854
+ * precedence; otherwise fall back to the legacy gateway auth request. Returns
855
+ * `null` when neither is configured.
856
+ */
857
+ resolveProviderConfig() {
858
+ if (this.providerConfig) {
859
+ return this.providerConfig;
860
+ }
861
+ return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
862
+ }
567
863
  async logout(_params) {
568
- // Clear in-memory gateway credentials supplied via `authenticate`. The
569
- // gateway method never touches the on-disk credential store, so dropping
570
- // this reference is the whole logout for that path.
864
+ // Clear in-memory gateway credentials supplied via `authenticate` and any
865
+ // provider routing set via `providers/set`. Neither touches the on-disk
866
+ // credential store, so dropping these references is the whole logout for
867
+ // those paths.
571
868
  this.gatewayAuthRequest = undefined;
869
+ this.providerConfig = undefined;
870
+ // Learned context windows are per-account state too: 1M-context
871
+ // entitlement is gated per org/tier, and an OAuth re-login is invisible to
872
+ // the env-derived provider cache key, so windows learned under the old
873
+ // login must not seed sessions under the next. Worst case of clearing is
874
+ // re-learning on each model's next turn.
875
+ contextWindowCache.clear();
572
876
  // For the Claude/Console login methods the credentials live in the native
573
877
  // CLI's store (keychain or config dir), which only the binary can clear.
574
878
  // `claude auth logout` is non-interactive and idempotent.
@@ -719,6 +1023,71 @@ export class ClaudeAcpAgent {
719
1023
  this.ensureConsumer(session, params.sessionId);
720
1024
  return response;
721
1025
  }
1026
+ /** Steer the session per the ACP steering wire protocol: inject a follow-up
1027
+ * message into the turn that is currently running. If that turn already
1028
+ * settled, the established default starts a new detached turn; Hosts may opt
1029
+ * into the host-owned `promptRequired` fallback through request `_meta`.
1030
+ *
1031
+ * When a turn is in flight this injects (returns `injected`): unlike
1032
+ * `prompt()`, it does NOT create a Turn or enqueue on `turnQueue`; it pushes
1033
+ * an `SDKUserMessage` onto the same streaming input, which the SDK routes
1034
+ * into the in-flight turn. The injected message's echo carries a uuid that
1035
+ * matches no queued turn, so the consumer drops it as an unrelated replay
1036
+ * without promoting/settling anything. It is delivered at {@link
1037
+ * STEER_PRIORITY} (`now`) so it pre-empts the current generation (interrupting
1038
+ * a single-shot response, or slotting in between a multi-step turn's tool
1039
+ * calls). The steered message's own output streams via `session/update`, not
1040
+ * this response.
1041
+ *
1042
+ * When the session is idle, the opt-in path returns `promptRequired` WITHOUT
1043
+ * calling `prompt()`, pushing SDK input, or mutating `turnQueue`: the content
1044
+ * stays Host-owned so the Host can submit it through a standard
1045
+ * `session/prompt`. Without the opt-in, the existing detached `prompt()` and
1046
+ * `startedNewTurn` result are preserved for compatibility. */
1047
+ async steer(params) {
1048
+ const sessionId = params.sessionId;
1049
+ const session = this.sessions[sessionId];
1050
+ if (!session) {
1051
+ throw new Error("Session not found");
1052
+ }
1053
+ if (session.queryClosed) {
1054
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1055
+ }
1056
+ // "A turn is running" = the queue holds an unsettled turn. This covers both
1057
+ // the activated turn and one just submitted but not yet echoed/activated,
1058
+ // which is exactly the window in which steering is meaningful. This check
1059
+ // and the active-path push below stay in one synchronous section so the
1060
+ // turn cannot settle in the gap between deciding to inject and enqueueing.
1061
+ const turnInFlight = (session.turnQueue ?? []).some((turn) => !turn.settled);
1062
+ if (!turnInFlight) {
1063
+ const promptRequest = {
1064
+ sessionId,
1065
+ prompt: params.prompt,
1066
+ };
1067
+ if (params._meta?.steering?.idleBehavior === "promptRequired") {
1068
+ // The opt-in path leaves the content untouched so the Host can retry via
1069
+ // a normal session/prompt whose lifecycle owns the continuation result.
1070
+ return { outcome: "promptRequired", reason: "noRunningTurn" };
1071
+ }
1072
+ // Preserve the established default for Hosts that do not opt in. This is
1073
+ // intentionally detached for compatibility with the existing contract.
1074
+ this.prompt(promptRequest).catch((error) => {
1075
+ this.logger.error(`Session ${sessionId}: steered new turn failed: ${error}`);
1076
+ });
1077
+ return { outcome: "startedNewTurn" };
1078
+ }
1079
+ const promptRequest = {
1080
+ sessionId,
1081
+ prompt: params.prompt,
1082
+ };
1083
+ const userMessage = promptToClaude(promptRequest);
1084
+ userMessage.uuid = randomUUID();
1085
+ // Deliver into the running turn rather than queuing behind it as a fresh
1086
+ // prompt would.
1087
+ userMessage.priority = STEER_PRIORITY;
1088
+ session.input.push(userMessage);
1089
+ return { outcome: "injected" };
1090
+ }
722
1091
  /** Lazily start the per-session consumer that drains the SDK query stream for
723
1092
  * the session's whole life. Idempotent: only the first `prompt()` starts it. */
724
1093
  ensureConsumer(session, sessionId) {
@@ -778,23 +1147,34 @@ export class ClaudeAcpAgent {
778
1147
  // gateways that don't carry a stable/matching id across the stream and the
779
1148
  // consolidated message. Reset after each consolidated message consumes it.
780
1149
  const streamedBlocks = [];
1150
+ // Tool-use blocks start streaming before their JSON input. Keep the
1151
+ // partial input per parent message and block index so completed top-level
1152
+ // fields can refine the pending tool call while it streams. Entries are
1153
+ // dropped at block/message boundaries; the whole map is swept when a turn
1154
+ // settles, since an interrupted subagent stream (keyed by a
1155
+ // parent_tool_use_id that never recurs) has no boundary event of its own.
1156
+ const streamedToolInputs = new Map();
781
1157
  // Stop reason accumulated for the active turn (result subtype, refusal,
782
1158
  // max_tokens, …). Reset per turn; read when the turn settles at idle.
783
1159
  let stopReason = "end_turn";
784
- // How many trailing `session_state_changed: idle` messages are already
785
- // accounted for: every user-turn result that terminates a turn (settle,
786
- // reject, or orphan skip) is followed by one, as is a cancelled turn
787
- // settled by the next turn's echo hand-off. The idle handler absorbs owed
788
- // idles; an idle that arrives when NONE is owed while the active turn is
789
- // still unsettled means the SDK ended the turn without ever emitting its
790
- // result, so the turn will never settle on its own (issue #825).
791
- // Stream-level debt, deliberately NOT reset per turn: a lagged idle can
792
- // arrive after the next turn has already activated (issue #773), and the
793
- // debt is what attributes it to the turn that owed it. Over-counting (an
794
- // idle the SDK never emits, e.g. CLI binaries without session-state
795
- // events — issue #497) is benign: the counter just absorbs one future
796
- // idle, and detection degrades to the status quo rather than misfiring.
797
- let owedTrailingIdles = 0;
1160
+ /** The consumer's single send chokepoint: every `sessionUpdate` in this
1161
+ * loop goes through here (never `this.client.sessionUpdate` directly) so
1162
+ * answer-delivery tracking is a property of sending, not something each
1163
+ * emission site must remember. A top-level `agent_message_chunk` marks
1164
+ * the stretch's answer as delivered; subagent-attributed chunks are
1165
+ * recognizable by the `parentToolUseId` meta that toAcpNotifications
1166
+ * stamps from `parent_tool_use_id`, and never reach the top-level feed
1167
+ * as the turn's answer. */
1168
+ const sendUpdate = async (notification) => {
1169
+ const { update } = notification;
1170
+ if (update.sessionUpdate === "agent_message_chunk") {
1171
+ const claudeMeta = update._meta?.claudeCode;
1172
+ if (!claudeMeta?.parentToolUseId) {
1173
+ session.emittedAssistantText = true;
1174
+ }
1175
+ }
1176
+ await this.client.sessionUpdate(notification);
1177
+ };
798
1178
  const resetTurnScratch = () => {
799
1179
  lastAssistantTotalUsage = null;
800
1180
  lastAssistantUsage = null;
@@ -830,6 +1210,31 @@ export class ClaudeAcpAgent {
830
1210
  session.activeTurn = turn;
831
1211
  session.cancelled = false;
832
1212
  session.pendingOrphanResults = 0;
1213
+ session.orphanCommands?.clear();
1214
+ // Two-phase sweep of registry entries the level signal ended (see
1215
+ // the endedPerLevel field doc): armed at the first activation,
1216
+ // deleted at the second — the same activation-time self-heal as the
1217
+ // orphan lanes, and the growth bound for leaked entries whose settle
1218
+ // bookends never arrive. The one-activation grace lets a corrective
1219
+ // inclusive level rescue a live async agent that a racing payload
1220
+ // absent-marked (deletion is irreversible: levels never ADD entries).
1221
+ // Local-only commands don't advance the clock: two quick /context
1222
+ // calls would otherwise burn the whole grace in seconds of wall time
1223
+ // while the corrective level is still in flight, and they interact
1224
+ // with no tasks — a later real turn still bounds growth.
1225
+ if (!turn.isLocalOnlyCommand) {
1226
+ for (const [taskId, record] of session.liveBackgroundTasks) {
1227
+ if (!record.endedPerLevel) {
1228
+ continue;
1229
+ }
1230
+ if (record.endedPerLevel === "sweep-armed") {
1231
+ session.liveBackgroundTasks.delete(taskId);
1232
+ }
1233
+ else {
1234
+ record.endedPerLevel = "sweep-armed";
1235
+ }
1236
+ }
1237
+ }
833
1238
  resetTurnScratch();
834
1239
  };
835
1240
  /** Ensure there is an active turn before a user-turn result that carries no
@@ -849,18 +1254,165 @@ export class ClaudeAcpAgent {
849
1254
  * result), so we skip those and only promote once the count is drained. */
850
1255
  const ensureActiveTurn = () => {
851
1256
  if (session.activeTurn) {
852
- return;
853
- }
854
- const head = (session.turnQueue ?? []).find((t) => !t.settled);
855
- if (!head) {
856
- return;
1257
+ if (!isHeldOpen(session.activeTurn)) {
1258
+ return;
1259
+ }
1260
+ // A held turn (Turn.deferredSettle) already produced its result, so
1261
+ // this incoming user-turn result cannot be its — it belongs to the
1262
+ // next queued command (an echo-less one, e.g. `/context` sent while
1263
+ // the hold drains; a normal prompt's echo would have handed the held
1264
+ // turn off before its result). Settle the held turn with its
1265
+ // recorded outcome — the user moving on outranks the hold, same
1266
+ // contract as the echo hand-off — and fall through to promote the
1267
+ // queue head, which this result belongs to. Without this, the head
1268
+ // would never be promoted (echo-less turns have no other promotion
1269
+ // path) and its prompt would hang, while this result's outcome
1270
+ // overwrote the held turn's. Orphan lanes below are necessarily
1271
+ // empty while a turn is held: orphans are seeded by cancel(), which
1272
+ // inline-settles a held turn, and activation cleared older ones.
1273
+ // settleActive also closes the held turn's delivery stretch, so the
1274
+ // promoted command's own delivery decision is not judged against the
1275
+ // held turn's followup text (issue #453) — the caller snapshots the
1276
+ // flag AFTER this runs.
1277
+ settleActive(session.activeTurn.deferredSettle);
857
1278
  }
1279
+ // Orphan accounting runs BEFORE the head check: an orphan's echo-less
1280
+ // result can arrive with an EMPTY queue (the common post-cancel
1281
+ // timeline — the active turn settled at the interrupt's idle and the
1282
+ // user hasn't typed yet), and it must still be consumed here. Skipping
1283
+ // the bookkeeping when there is nothing to promote would leave a
1284
+ // phantom entry/count that swallows the next live echo-less result
1285
+ // (e.g. /compact) instead.
858
1286
  if ((session.pendingOrphanResults ?? 0) > 0) {
859
1287
  session.pendingOrphanResults--;
860
1288
  return;
861
1289
  }
1290
+ // msg_lifecycle_v1 lane. Attribute this echo-less result using the
1291
+ // entries' states — turns run sequentially and frames arrive in stream
1292
+ // order, so at any result: every "zombie" is from an already-dead turn
1293
+ // whose own result already passed before the frame that created the
1294
+ // newest entry (or never existed), every "started" entry was dispatched
1295
+ // into THE turn that emitted this result (an older turn's entries got
1296
+ // their terminal frames before a newer turn's "started" frames), and a
1297
+ // "pending" entry was not dispatched before it. One result therefore
1298
+ // covers ALL started and zombie entries at once (N coalesced commands
1299
+ // share ONE result); their outstanding terminal frames then no-op on
1300
+ // the missing entries. NOTE this ordering argument is asserted from
1301
+ // observed CLI behavior, not a documented wire contract — if a dead
1302
+ // turn's late result could lag past the NEXT turn's dispatch frames,
1303
+ // deleting a zombie and a started entry on one result would
1304
+ // double-consume it. The unexpected-transition logging in the frame
1305
+ // handler is the tripwire for that class of drift.
1306
+ if (session.orphanCommands?.size) {
1307
+ let consumedOrphanResult = false;
1308
+ let oldestPending;
1309
+ for (const [uuid, state] of session.orphanCommands) {
1310
+ if (state === "started" || state === "zombie") {
1311
+ consumedOrphanResult = true;
1312
+ session.orphanCommands.delete(uuid);
1313
+ }
1314
+ else {
1315
+ oldestPending ??= uuid;
1316
+ }
1317
+ }
1318
+ if (consumedOrphanResult) {
1319
+ return;
1320
+ }
1321
+ if (oldestPending !== undefined) {
1322
+ // No dispatch was seen before this result, so it is very likely a
1323
+ // live turn's — but a lost "started" frame would mean it IS the
1324
+ // orphan's (dup-over-loss: prefer one wrong skip over
1325
+ // misattributing a dead turn's outcome to a live prompt). Grant
1326
+ // each pending entry exactly one skip, like the count lane did.
1327
+ session.orphanCommands.delete(oldestPending);
1328
+ return;
1329
+ }
1330
+ }
1331
+ const head = firstUnsettledQueuedTurn();
1332
+ if (!head) {
1333
+ return;
1334
+ }
862
1335
  activateTurn(head);
863
1336
  };
1337
+ /** Result-time bookkeeping that must run whether or not the result can be
1338
+ * attributed to a turn. (1) Latch `commandResultSeen` on every queued
1339
+ * turn whose command is known dispatched with no terminal frame yet —
1340
+ * the emitting turn is the one it was dispatched (possibly folded) into,
1341
+ * so its result has now passed; a later cancel() must not seed an orphan
1342
+ * entry that waits for it (see Turn.commandResultSeen). (2) When a turn
1343
+ * is ACTIVE, the result is attributed to it and never reaches
1344
+ * ensureActiveTurn — but it still covers the map's started entries
1345
+ * (commands folded into the active turn share its result) and zombies
1346
+ * (their late results have already passed or never existed), so drain
1347
+ * them here or they would zombify/linger and swallow a later live
1348
+ * echo-less result. */
1349
+ const recordResultForOrphanCommands = () => {
1350
+ for (const turn of session.turnQueue ?? []) {
1351
+ if (!turn.settled && turn.commandStarted && !turn.commandFinished) {
1352
+ turn.commandResultSeen = true;
1353
+ }
1354
+ }
1355
+ if (session.activeTurn && session.orphanCommands?.size) {
1356
+ for (const [uuid, state] of session.orphanCommands) {
1357
+ if (state === "started" || state === "zombie") {
1358
+ session.orphanCommands.delete(uuid);
1359
+ }
1360
+ }
1361
+ }
1362
+ };
1363
+ /** The unsettled in-flight turn owning this prompt uuid, if any. */
1364
+ const findUnsettledTurn = (uuid) => (session.turnQueue ?? []).find((t) => t.promptUuid === uuid && !t.settled);
1365
+ /** The first queued turn still awaiting its outcome, if any — the single
1366
+ * spelling of "a prompt is pending" shared by the head promotion and
1367
+ * the autonomous stretch-close guard. */
1368
+ const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
1369
+ /** Whether any background subagent this turn spawned is still live —
1370
+ * while true, the turn's settlement stays deferred so the subagent's
1371
+ * output and permission requests land inside it (see
1372
+ * Turn.deferredSettle). */
1373
+ const turnAwaitingSubagents = (turn) => {
1374
+ if (!turn.spawnedTaskIds?.size) {
1375
+ return false;
1376
+ }
1377
+ for (const taskId of turn.spawnedTaskIds) {
1378
+ const record = session.liveBackgroundTasks.get(taskId);
1379
+ // The isSubagent read is defense in depth for the shells-never-defer
1380
+ // contract: spawnedTaskIds only ever holds subagent ids today, but a
1381
+ // future add site must not silently let a long-lived shell hold a
1382
+ // prompt open. endedPerLevel entries are kept for attribution only —
1383
+ // the level signal says the task is gone (or its bookends were
1384
+ // lost), so a hold must not wait on them.
1385
+ if (record?.isSubagent && !record.endedPerLevel) {
1386
+ return true;
1387
+ }
1388
+ }
1389
+ return false;
1390
+ };
1391
+ /** Settle the active turn's stored deferred outcome once none of its
1392
+ * spawned subagents is live. The single drain rule shared by the
1393
+ * followup-result and idle settle sites, so the two lanes can't drift. */
1394
+ const settleDeferredIfDrained = () => {
1395
+ const turn = session.activeTurn;
1396
+ if (isHeldOpen(turn) && !turnAwaitingSubagents(turn)) {
1397
+ settleActive(turn.deferredSettle);
1398
+ }
1399
+ };
1400
+ /** Settle the active turn with `outcome` now — unless subagents it
1401
+ * spawned are still live, in which case store the outcome and hold the
1402
+ * turn open (see Turn.deferredSettle). Every result-time settle of a
1403
+ * turn that can have spawned subagents must route through here: a site
1404
+ * calling settleActive directly bypasses the hold and re-opens the
1405
+ * out-of-turn permission deadlock (issue #866) through its lane. */
1406
+ const settleOrDefer = (outcome) => {
1407
+ if (session.activeTurn &&
1408
+ !session.activeTurn.settled &&
1409
+ turnAwaitingSubagents(session.activeTurn)) {
1410
+ session.activeTurn.deferredSettle = outcome;
1411
+ }
1412
+ else {
1413
+ settleActive(outcome);
1414
+ }
1415
+ };
864
1416
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
865
1417
  * backstop (the turn is over), and drop it from the queue. */
866
1418
  const settleActive = (result) => {
@@ -868,22 +1420,34 @@ export class ClaudeAcpAgent {
868
1420
  if (!turn || turn.settled) {
869
1421
  return;
870
1422
  }
1423
+ // Captured before the settled flip below (isHeldOpen tests !settled).
1424
+ const wasHeld = isHeldOpen(turn);
871
1425
  turn.settled = true;
872
- if (session.forceCancelTimer) {
873
- clearTimeout(session.forceCancelTimer);
874
- session.forceCancelTimer = undefined;
875
- }
1426
+ disarmForceCancel(session);
876
1427
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
877
1428
  session.activeTurn = null;
1429
+ streamedToolInputs.clear();
1430
+ if (wasHeld) {
1431
+ // Settling a held turn is its delivery-stretch boundary: the turn's
1432
+ // answer finished long ago, so text streamed since the last boundary
1433
+ // is normally its followups' — left latched it would suppress a
1434
+ // following replayed turn's issue-#453 result-text fallback (the
1435
+ // common post-hold sequence). Known trade: at the echo hand-off an
1436
+ // incoming turn's pre-echo deltas share this one boolean, so a
1437
+ // STREAMING replay on a usage-omitting backend could re-emit its
1438
+ // answer — the flag cannot attribute text to a turn before its
1439
+ // echo, and the suppression direction is the common one, so the
1440
+ // clear wins. Every held-settle lane inherits this: the drain
1441
+ // settle, both hand-offs, and stream-done; cancel()'s inline mirror
1442
+ // carries its own copy.
1443
+ session.emittedAssistantText = false;
1444
+ }
878
1445
  turn.resolve(result);
879
1446
  };
880
1447
  /** Reject the active turn (auth required, error result, …) without tearing
881
1448
  * down the consumer: the stream continues to idle and later turns proceed. */
882
1449
  const failActive = (error) => {
883
- if (session.forceCancelTimer) {
884
- clearTimeout(session.forceCancelTimer);
885
- session.forceCancelTimer = undefined;
886
- }
1450
+ disarmForceCancel(session);
887
1451
  const turn = session.activeTurn;
888
1452
  if (!turn || turn.settled) {
889
1453
  return;
@@ -891,14 +1455,17 @@ export class ClaudeAcpAgent {
891
1455
  turn.settled = true;
892
1456
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
893
1457
  session.activeTurn = null;
1458
+ streamedToolInputs.clear();
1459
+ // A failed turn's stretch is over, and some failure lanes (the issue
1460
+ // #825 idle-fail) never see the result whose `finally` would close it —
1461
+ // start the next stretch clean, or its stale delivery record would
1462
+ // suppress the next turn's issue-#453 result-text fallback.
1463
+ session.emittedAssistantText = false;
894
1464
  turn.reject(error);
895
1465
  };
896
1466
  /** Reject every in-flight turn — used when the stream dies. */
897
1467
  const failAllTurns = (error) => {
898
- if (session.forceCancelTimer) {
899
- clearTimeout(session.forceCancelTimer);
900
- session.forceCancelTimer = undefined;
901
- }
1468
+ disarmForceCancel(session);
902
1469
  const turns = session.activeTurn
903
1470
  ? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
904
1471
  : [...(session.turnQueue ?? [])];
@@ -906,8 +1473,18 @@ export class ClaudeAcpAgent {
906
1473
  session.turnQueue = [];
907
1474
  for (const turn of turns) {
908
1475
  if (!turn.settled) {
1476
+ const wasHeld = isHeldOpen(turn);
909
1477
  turn.settled = true;
910
- turn.reject(error);
1478
+ if (wasHeld) {
1479
+ // A held turn's answer already streamed and its outcome is
1480
+ // recorded — a stream death during the post-answer hold is a
1481
+ // background failure, not the turn's. Resolve with the real
1482
+ // outcome, mirroring the stream-done path.
1483
+ turn.resolve(turn.deferredSettle);
1484
+ }
1485
+ else {
1486
+ turn.reject(error);
1487
+ }
911
1488
  }
912
1489
  }
913
1490
  };
@@ -961,9 +1538,45 @@ export class ClaudeAcpAgent {
961
1538
  // turn being abandoned. Stale counts self-heal: activation resets
962
1539
  // them (see activateTurn).
963
1540
  if (session.activeTurn && !session.activeTurn.settled) {
964
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
1541
+ // Seed by what the frames already told us, mirroring cancel()'s
1542
+ // queued-turn sweep — the consumer may have drained the wedged
1543
+ // turn's result and/or terminal frame before the backstop fired,
1544
+ // and an entry seeded for a result or frame that is already
1545
+ // spent would never drain (it would swallow an unrelated later
1546
+ // echo-less result instead).
1547
+ const active = session.activeTurn;
1548
+ if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
1549
+ // Finished SDK-side; any result already passed. Nothing to
1550
+ // track.
1551
+ }
1552
+ else if (active.commandFinished === "cancelled") {
1553
+ // Aborted after dispatch: its late result may still come —
1554
+ // unless it already did.
1555
+ if (!active.commandResultSeen) {
1556
+ this.trackOrphanCommand(session, active.promptUuid, "zombie");
1557
+ }
1558
+ }
1559
+ else if (active.commandResultSeen) {
1560
+ // Its result was already consumed (dropped at the cancelled
1561
+ // guard); only the terminal frame is outstanding, which no-ops
1562
+ // with no entry. Nothing to track.
1563
+ }
1564
+ else {
1565
+ // The wedged turn WAS dispatched (it's active), so track it
1566
+ // "started": its late result (if the SDK recovers) is skipped
1567
+ // echo-less, and its terminal frame — or that skip plus
1568
+ // activation's clear when the frame is lost to the wedge — is
1569
+ // what drains it.
1570
+ this.trackOrphanCommand(session, active.promptUuid, "started");
1571
+ }
965
1572
  }
966
1573
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1574
+ // The cancelled turn's result may never come (that's why the
1575
+ // backstop fired) — close its delivery stretch here so partial
1576
+ // streamed text can't suppress the next turn's issue-#453 fallback.
1577
+ // If a late orphan result does arrive, its `finally` clears again;
1578
+ // FIFO ordering means no live turn's text can have streamed yet.
1579
+ session.emittedAssistantText = false;
967
1580
  // If the session is being torn down — or this consumer was
968
1581
  // superseded by a lazy query recreate — abandon the in-flight
969
1582
  // next() (swallowing any later rejection so it can't surface as
@@ -999,11 +1612,15 @@ export class ClaudeAcpAgent {
999
1612
  // turn's real outcome.
1000
1613
  //
1001
1614
  // Settle the turn that was in flight so its prompt() doesn't hang:
1002
- // cancelled if a cancel is pending, otherwise the accumulated outcome.
1003
- settleActive({
1004
- stopReason: session.cancelled ? "cancelled" : stopReason,
1005
- usage: sessionUsage(session),
1006
- });
1615
+ // cancelled if a cancel is pending, otherwise the outcome a
1616
+ // deferred turn already recorded (see Turn.deferredSettle) or the
1617
+ // accumulated scratch outcome. The scratch currently still equals
1618
+ // a deferred turn's stored outcome (followup results never mutate
1619
+ // it), but the stored one is the authoritative source.
1620
+ const inFlight = session.activeTurn;
1621
+ settleActive(session.cancelled
1622
+ ? { stopReason: "cancelled", usage: sessionUsage(session) }
1623
+ : (inFlight?.deferredSettle ?? { stopReason, usage: sessionUsage(session) }));
1007
1624
  // Queued turns the SDK never started never ran, so reject them rather
1008
1625
  // than reporting a success (end_turn) — or a misleading "cancelled" —
1009
1626
  // for a prompt that produced no output. (A cancel already settled the
@@ -1030,19 +1647,116 @@ export class ClaudeAcpAgent {
1030
1647
  message: message,
1031
1648
  });
1032
1649
  }
1650
+ // CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
1651
+ // uuid-stamped queued command (queued/started/completed/cancelled/
1652
+ // discarded) as `command_lifecycle` frames — 2-3 per prompt, since
1653
+ // prompt() stamps a uuid on every message. The frame is @internal and
1654
+ // absent from the SDKMessage union, so handle it BEFORE the exhaustive
1655
+ // switch: it must not reach `unreachable`'s error log, and a `case`
1656
+ // for it wouldn't typecheck. It feeds only the orphan accounting (see
1657
+ // Session.orphanCommands); turn settlement stays driven by
1658
+ // echoes/results/idle. (Raw-mode emission above still forwards these
1659
+ // frames.)
1660
+ if (message.type === "command_lifecycle") {
1661
+ const frame = message;
1662
+ switch (frame.state) {
1663
+ case "started": {
1664
+ // Remember dispatch on the live turn so a cancel() that orphans
1665
+ // it seeds the right state (see Turn.commandStarted)...
1666
+ const queued = findUnsettledTurn(frame.command_uuid);
1667
+ if (queued) {
1668
+ queued.commandStarted = true;
1669
+ }
1670
+ // ...and promote an already-orphaned command: once dispatched,
1671
+ // a bare `cancelled` no longer means "dropped without running".
1672
+ const state = session.orphanCommands?.get(frame.command_uuid);
1673
+ if (state === "pending") {
1674
+ session.orphanCommands.set(frame.command_uuid, "started");
1675
+ }
1676
+ else if (state === "zombie") {
1677
+ // "started" after the command's terminal frame: the ordering
1678
+ // the whole lane rests on has been violated (frames are
1679
+ // per-uuid FIFO). Surface it — a silent drift here degrades
1680
+ // into swallowed or misattributed results.
1681
+ this.logger.error(`Session ${params.sessionId}: command_lifecycle "started" for ${frame.command_uuid} after its terminal frame; orphan accounting may be off for this cancel.`);
1682
+ }
1683
+ break;
1684
+ }
1685
+ case "completed":
1686
+ case "discarded":
1687
+ case "cancelled": {
1688
+ // Terminal frames. Latch the fate on a still-queued turn so a
1689
+ // later cancel() doesn't seed an orphan entry for a command
1690
+ // whose one-and-only terminal frame has already been consumed
1691
+ // (nothing would ever drain that entry).
1692
+ const queued = findUnsettledTurn(frame.command_uuid);
1693
+ if (queued) {
1694
+ queued.commandFinished = frame.state;
1695
+ }
1696
+ if (frame.state === "cancelled") {
1697
+ // Ambiguous by design (dup-over-loss): dropped before
1698
+ // dispatch (no result will ever come — safe to forget) vs
1699
+ // consumed into a turn that was aborted/failed. For the
1700
+ // latter, any result the dead turn managed to emit has
1701
+ // already deleted the entry (see
1702
+ // recordResultForOrphanCommands / ensureActiveTurn), so a
1703
+ // still-"started" entry means no result was seen since
1704
+ // dispatch — it becomes a zombie for the next
1705
+ // echo-less-result skip.
1706
+ const state = session.orphanCommands?.get(frame.command_uuid);
1707
+ if (state === "pending") {
1708
+ session.orphanCommands?.delete(frame.command_uuid);
1709
+ }
1710
+ else if (state === "started") {
1711
+ session.orphanCommands?.set(frame.command_uuid, "zombie");
1712
+ }
1713
+ break;
1714
+ }
1715
+ // Exactly-one-terminal: the command is finished. "completed" is
1716
+ // emitted after any result its turn produced (fresh turn) or the
1717
+ // command folded into another turn whose result is attributed
1718
+ // elsewhere — either way no echo-less result remains to skip.
1719
+ // "discarded" = session ended with it still queued; no result.
1720
+ session.orphanCommands?.delete(frame.command_uuid);
1721
+ break;
1722
+ }
1723
+ default:
1724
+ // "queued" carries no fate information. Anything else is a
1725
+ // state this adapter doesn't know — likely a CLI that grew the
1726
+ // v1 vocabulary. The entry still drains by result coverage or
1727
+ // activation's clear (bounded damage), but log it so the
1728
+ // degradation is visible instead of silent.
1729
+ if (frame.state !== "queued") {
1730
+ this.logger.error(`Session ${params.sessionId}: unknown command_lifecycle state "${frame.state}" for ${frame.command_uuid}; treating as uninformative.`);
1731
+ }
1732
+ break;
1733
+ }
1734
+ continue;
1735
+ }
1033
1736
  switch (message.type) {
1034
1737
  case "system":
1035
1738
  switch (message.subtype) {
1036
1739
  case "init":
1740
+ // Latch the lifecycle capability so cancel() routes orphan
1741
+ // accounting through `orphanCommands` (per-uuid, exact)
1742
+ // instead of the coalescing-blind count. Never unlatch: init
1743
+ // re-emits per turn and the capability can't be lost mid-CLI.
1744
+ if (message.capabilities?.includes("msg_lifecycle_v1")) {
1745
+ session.msgLifecycleV1 = true;
1746
+ }
1037
1747
  // A fresh `system`/init (e.g. after reinitialize) can carry an
1038
1748
  // updated Fast mode state; reconcile it with what we seeded at
1039
1749
  // session creation.
1040
- await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
1750
+ await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
1041
1751
  break;
1042
1752
  case "status": {
1753
+ // These banners count as delivered text (via sendUpdate), so
1754
+ // an echo-less turn that only ever emits them (e.g. `/compact`,
1755
+ // promoted at its own result) doesn't have its result text
1756
+ // re-emitted by the issue-#453 fallback.
1043
1757
  if (message.status === "compacting") {
1044
1758
  compactionInProgress = true;
1045
- await this.client.sessionUpdate({
1759
+ await sendUpdate({
1046
1760
  sessionId: message.session_id,
1047
1761
  update: {
1048
1762
  sessionUpdate: "agent_message_chunk",
@@ -1055,7 +1769,7 @@ export class ClaudeAcpAgent {
1055
1769
  // message carrying `compact_result`, not the `compact_boundary`
1056
1770
  // message (which only fires when there's content to compact).
1057
1771
  compactionInProgress = false;
1058
- await this.client.sessionUpdate({
1772
+ await sendUpdate({
1059
1773
  sessionId: message.session_id,
1060
1774
  update: {
1061
1775
  sessionUpdate: "agent_message_chunk",
@@ -1066,7 +1780,7 @@ export class ClaudeAcpAgent {
1066
1780
  else if (message.compact_result === "failed" && compactionInProgress) {
1067
1781
  compactionInProgress = false;
1068
1782
  const reason = message.compact_error ? `: ${message.compact_error}` : ".";
1069
- await this.client.sessionUpdate({
1783
+ await sendUpdate({
1070
1784
  sessionId: message.session_id,
1071
1785
  update: {
1072
1786
  sessionUpdate: "agent_message_chunk",
@@ -1091,9 +1805,9 @@ export class ClaudeAcpAgent {
1091
1805
  // dropped dramatically) and replaced within seconds by the next
1092
1806
  // result message.
1093
1807
  //
1094
- // `size` keeps coming from session.contextWindowSize (learned
1095
- // from modelUsage / the model heuristic) — getContextUsage's
1096
- // window field under-reports extended 1M windows.
1808
+ // `size` keeps coming from session.contextWindowSize —
1809
+ // compaction frees occupancy, it doesn't change the model's
1810
+ // window.
1097
1811
  //
1098
1812
  // The "Compacting completed." text is emitted from the `status`
1099
1813
  // handler (keyed on `compact_result`), not here, so the failure
@@ -1101,7 +1815,7 @@ export class ClaudeAcpAgent {
1101
1815
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
1102
1816
  lastAssistantUsage = null;
1103
1817
  lastAssistantTotalUsage = usedTokens ?? 0;
1104
- await this.client.sessionUpdate({
1818
+ await sendUpdate({
1105
1819
  sessionId: message.session_id,
1106
1820
  update: {
1107
1821
  sessionUpdate: "usage_update",
@@ -1112,7 +1826,7 @@ export class ClaudeAcpAgent {
1112
1826
  break;
1113
1827
  }
1114
1828
  case "local_command_output": {
1115
- await this.client.sessionUpdate({
1829
+ await sendUpdate({
1116
1830
  sessionId: message.session_id,
1117
1831
  update: {
1118
1832
  sessionUpdate: "agent_message_chunk",
@@ -1122,6 +1836,7 @@ export class ClaudeAcpAgent {
1122
1836
  break;
1123
1837
  }
1124
1838
  case "session_state_changed": {
1839
+ session.lastSessionState = message.state;
1125
1840
  if (message.state === "idle") {
1126
1841
  // A non-cancelled turn normally settled at its terminal
1127
1842
  // `result` already (issue #773), and that result recorded an
@@ -1154,14 +1869,38 @@ export class ClaudeAcpAgent {
1154
1869
  // when the cancel pre-empted the result (wedge/force-cancel).
1155
1870
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
1156
1871
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1872
+ // An interrupt can pre-empt the turn's result entirely
1873
+ // (nothing ran the result-case `finally`), so close the
1874
+ // delivery stretch here: idle is the SDK's authoritative
1875
+ // turn-over signal, and stale partial-text state would
1876
+ // suppress the next turn's issue-#453 fallback.
1877
+ session.emittedAssistantText = false;
1878
+ }
1879
+ else if (isHeldOpen(session.activeTurn)) {
1880
+ // A turn held open for its background subagents (see
1881
+ // Turn.deferredSettle). Idles keep their normal cadence
1882
+ // during the hold — the CLI emits one per processing
1883
+ // cycle (the turn's own trailer, then one per followup),
1884
+ // NOT one final "all drained" signal — so each one
1885
+ // absorbs an outstanding trailer debt, and the turn only
1886
+ // settles once none of its spawned subagents is left
1887
+ // (the followup-result settle usually got there first;
1888
+ // this is the fallback when no followup came). Mid-hold
1889
+ // idles never fall through: a held turn HAS its result,
1890
+ // so reading its idle as "turn abandoned without a
1891
+ // result" (issue #825) would fail a healthy prompt.
1892
+ if (session.owedTrailingIdles > 0) {
1893
+ session.owedTrailingIdles--;
1894
+ }
1895
+ settleDeferredIfDrained();
1157
1896
  }
1158
- else if (owedTrailingIdles > 0) {
1897
+ else if (session.owedTrailingIdles > 0) {
1159
1898
  // Absorb a settled turn's trailing idle. Also covers a
1160
1899
  // cancel that landed between a turn's counted result and
1161
1900
  // this lagged idle (no active turn to settle): the idle
1162
1901
  // still belongs to that settled turn, and skipping the
1163
1902
  // decrement would leak the debt permanently.
1164
- owedTrailingIdles--;
1903
+ session.owedTrailingIdles--;
1165
1904
  }
1166
1905
  else if (!session.cancelled &&
1167
1906
  session.activeTurn &&
@@ -1203,7 +1942,7 @@ export class ClaudeAcpAgent {
1203
1942
  const title = isSynthesis
1204
1943
  ? "Recalled synthesized memory"
1205
1944
  : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
1206
- await this.client.sessionUpdate({
1945
+ await sendUpdate({
1207
1946
  sessionId: message.session_id,
1208
1947
  update: {
1209
1948
  sessionUpdate: "tool_call",
@@ -1227,10 +1966,10 @@ export class ClaudeAcpAgent {
1227
1966
  // Push the full slash-command list after a mid-session change
1228
1967
  // (e.g. skills discovered dynamically as the agent works in a
1229
1968
  // subdirectory). The client should REPLACE its cached command
1230
- // list with this payload: supportedCommands() is captured once
1231
- // at initialize and never reflects mid-session changes, so we
1232
- // forward message.commands directly rather than re-querying.
1233
- await this.client.sessionUpdate({
1969
+ // list with this payload. Forward message.commands directly —
1970
+ // it's authoritative, and re-querying supportedCommands()
1971
+ // would just return the same list with an extra round-trip.
1972
+ await sendUpdate({
1234
1973
  sessionId: message.session_id,
1235
1974
  update: {
1236
1975
  sessionUpdate: "available_commands_update",
@@ -1253,8 +1992,30 @@ export class ClaudeAcpAgent {
1253
1992
  // already emitted as a `tool_call`, so mark it failed with the
1254
1993
  // rejection reason — otherwise the client shows a tool call
1255
1994
  // that silently never resolves.
1995
+ //
1996
+ // The id is the executing call's own, and the frame lands
1997
+ // between its `tool_use` and its `tool_result` (the SDK enqueues
1998
+ // it from inside canUseTool), so the call is normally in flight
1999
+ // here. Not always: the assistant message carrying the tool_use
2000
+ // is dropped by the cancelled-turn guard below, and a denial for
2001
+ // it can still arrive afterwards — the case the `tool_result`
2002
+ // fallback in `toAcpNotifications` gates on `wasEmitted` for.
2003
+ // Drop the update rather than reference a tool call the client
2004
+ // was never given (see `ensureToolCallEmitted`, issue #851).
2005
+ if (!session.emittedToolCalls.has(message.tool_use_id)) {
2006
+ break;
2007
+ }
2008
+ // A denial inside a subagent identifies the subagent by
2009
+ // `agent_id` (as canUseTool does with `agentID`), never by the
2010
+ // Agent/Task call that spawned it. Resolve it the same way so
2011
+ // the update lands in the subagent's transcript alongside the
2012
+ // `tool_call` it resolves, which carries the parent stamped from
2013
+ // `parent_tool_use_id` (see `liveBackgroundTasks`).
2014
+ const parentToolUseId = message.agent_id
2015
+ ? session.liveBackgroundTasks.get(message.agent_id)?.parentToolUseId
2016
+ : undefined;
1256
2017
  const reason = message.decision_reason ?? message.message;
1257
- await this.client.sessionUpdate({
2018
+ await sendUpdate({
1258
2019
  sessionId: message.session_id,
1259
2020
  update: {
1260
2021
  sessionUpdate: "tool_call_update",
@@ -1269,6 +2030,7 @@ export class ClaudeAcpAgent {
1269
2030
  _meta: {
1270
2031
  claudeCode: {
1271
2032
  toolName: message.tool_name,
2033
+ ...(parentToolUseId ? { parentToolUseId } : {}),
1272
2034
  toolResponse: {
1273
2035
  decisionReasonType: message.decision_reason_type,
1274
2036
  decisionReason: message.decision_reason,
@@ -1286,10 +2048,14 @@ export class ClaudeAcpAgent {
1286
2048
  // instead of a silent stop. ACP's agent_message_chunk has no
1287
2049
  // severity field, so fold the level into the text for the more
1288
2050
  // prominent levels ('info' is transcript-only noise — leave plain).
2051
+ // Sending via sendUpdate also marks the notice as this stretch's
2052
+ // delivered text: a hook-blocked turn's result repeats the block
2053
+ // reason with zero output tokens, and the issue-#453 fallback
2054
+ // must not emit it a second time.
1289
2055
  const text = message.level === "info"
1290
2056
  ? message.content
1291
2057
  : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
1292
- await this.client.sessionUpdate({
2058
+ await sendUpdate({
1293
2059
  sessionId: message.session_id,
1294
2060
  update: {
1295
2061
  sessionUpdate: "agent_message_chunk",
@@ -1302,16 +2068,51 @@ export class ClaudeAcpAgent {
1302
2068
  case "hook_progress":
1303
2069
  case "hook_response":
1304
2070
  case "files_persisted":
2071
+ case "task_progress":
2072
+ break;
1305
2073
  case "task_started":
2074
+ // For subagent tasks `task_id` is the subagent's agent id (the
2075
+ // SDK keys its task registry by agent id) and `tool_use_id` is
2076
+ // the Agent/Task tool_use that spawned it — recorded so the
2077
+ // subagent's permission requests, which reach canUseTool with
2078
+ // only `agentID`, can attribute their eagerly-emitted
2079
+ // tool_call to the parent tool call. Non-subagent tasks (e.g.
2080
+ // background Bash) land here too; their task_ids never match
2081
+ // an agentID, so those entries are inert for attribution.
2082
+ //
2083
+ // `isSubagent` marks Task/Agent-tool subagents — the tasks
2084
+ // whose completion wakes the model for a followup, so the
2085
+ // ones worth deferring turn settlement for. A sync subagent
2086
+ // is pruned (terminal task_updated) before its turn's result
2087
+ // can arrive, so registry membership at result time means an
2088
+ // async subagent. Their spawn is also recorded on the active
2089
+ // turn: a turn only ever waits on its own subagents, and a
2090
+ // spawn during a held-open drain window (an agent chain)
2091
+ // extends that turn's hold.
2092
+ session.liveBackgroundTasks.set(message.task_id, {
2093
+ parentToolUseId: message.tool_use_id,
2094
+ isSubagent: !!message.subagent_type,
2095
+ });
2096
+ if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
2097
+ (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
2098
+ }
2099
+ break;
1306
2100
  case "task_notification":
1307
- case "task_progress":
2101
+ // The task settled — no further tool calls can originate
2102
+ // from it, so its registry entry can be dropped.
2103
+ session.liveBackgroundTasks.delete(message.task_id);
2104
+ break;
1308
2105
  case "task_updated":
1309
- case "background_tasks_changed":
1310
- case "control_request_progress":
1311
- // `background_tasks_changed` is a level signal of live background
1312
- // tasks; this adapter drives lifecycle off the task edge bookends.
1313
- // `control_request_progress` only reports on side_question control
1314
- // requests, which this adapter never issues.
2106
+ // terminal-status task_updated patch and a (deduplicated)
2107
+ // task_notification when a task settles, but only the patch is
2108
+ // guaranteed per transition — prune on it too so the registry
2109
+ // can't grow for the session's lifetime if a notification is
2110
+ // skipped.
2111
+ if (message.patch.status === "completed" ||
2112
+ message.patch.status === "failed" ||
2113
+ message.patch.status === "killed") {
2114
+ session.liveBackgroundTasks.delete(message.task_id);
2115
+ }
1315
2116
  break;
1316
2117
  case "worker_shutting_down":
1317
2118
  // A Remote Control worker announced a graceful teardown. This is a
@@ -1363,7 +2164,7 @@ export class ClaudeAcpAgent {
1363
2164
  const outcome = persistent
1364
2165
  ? `The session will continue on ${message.fallback_model}.`
1365
2166
  : `The session stays on ${message.original_model}.`;
1366
- await this.client.sessionUpdate({
2167
+ await sendUpdate({
1367
2168
  sessionId: message.session_id,
1368
2169
  update: {
1369
2170
  sessionUpdate: "agent_message_chunk",
@@ -1397,183 +2198,357 @@ export class ClaudeAcpAgent {
1397
2198
  lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
1398
2199
  }
1399
2200
  break;
2201
+ // `control_request_progress` only reports on side_question
2202
+ // control requests, which this adapter never issues.
2203
+ case "control_request_progress":
2204
+ break;
2205
+ case "background_tasks_changed":
2206
+ // A level signal: the full live background-task set on every
2207
+ // membership change, with REPLACE semantics. Used only to
2208
+ // reconcile `liveBackgroundTasks` — dropping (or, for
2209
+ // subagent entries, unpinning) any entry whose settle
2210
+ // bookend (task_notification / terminal task_updated) was
2211
+ // lost, so a leaked subagent entry can't defer its spawning
2212
+ // turn's settlement forever. Growth of retained
2213
+ // (endedPerLevel) subagent entries is bounded by the
2214
+ // activation-time sweep in activateTurn, not here. It never
2215
+ // ADDS entries (the payload carries no attribution or
2216
+ // subagent marker), so the unspecified ordering vs. the edge
2217
+ // bookends is safe: a level that precedes its task_started
2218
+ // simply no-ops here.
2219
+ if (session.liveBackgroundTasks.size > 0) {
2220
+ const live = new Set(message.tasks.map((t) => t.task_id));
2221
+ for (const [taskId, record] of session.liveBackgroundTasks) {
2222
+ if (live.has(taskId)) {
2223
+ // The level proves the task live in the background
2224
+ // universe (e.g. a foreground agent was backgrounded
2225
+ // after an earlier absent-marking, or that marking was
2226
+ // a racing payload built before the task registered) —
2227
+ // un-end it so a hold waits on it again, and disarm
2228
+ // the activation sweep.
2229
+ record.endedPerLevel = undefined;
2230
+ continue;
2231
+ }
2232
+ if (record.isSubagent) {
2233
+ // The level's universe is BACKGROUND tasks only, so a
2234
+ // live sync (foreground) subagent is legitimately
2235
+ // absent — deleting its entry would strand its
2236
+ // permission attribution (#859). Keep the entry but
2237
+ // stop any hold from waiting on the id: an absent id
2238
+ // can equally be a leaked async entry whose settle
2239
+ // bookends were lost.
2240
+ record.endedPerLevel ??= "ended";
2241
+ }
2242
+ else {
2243
+ session.liveBackgroundTasks.delete(taskId);
2244
+ }
2245
+ }
2246
+ }
2247
+ break;
1400
2248
  default:
1401
2249
  unreachable(message, this.logger);
1402
2250
  break;
1403
2251
  }
1404
2252
  break;
1405
2253
  case "result": {
1406
- // Task-notification followups are autonomous work triggered by a
1407
- // task-notification system message, not by the user's prompt.
1408
- // They should not influence the user-turn lifecycle (stop reason,
1409
- // slash-command output forwarding) but their cost is real.
1410
- const isTaskNotification = message.origin?.kind === "task-notification";
1411
- // Reconcile the Fast mode toggle with the SDK's reported state.
1412
- // Gated to user-driven turns like every other side effect below; a
1413
- // background followup's state lands on the next user turn's result.
1414
- // Runs even when the turn errors or was cancelled.
1415
- if (!isTaskNotification) {
1416
- await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1417
- }
1418
- // A user-turn result needs an active turn so its stop reason is
1419
- // attributed and the turn settles at idle. Local-only commands carry
1420
- // no user-message echo to promote them, so do it here from the head.
1421
- // Promote BEFORE accumulating usage, since activation resets the
1422
- // accumulator — promoting after would discard this result's tokens.
1423
- if (!isTaskNotification) {
1424
- ensureActiveTurn();
1425
- }
1426
- // Every user-turn result terminates a turn (settle, reject, or
1427
- // orphan skip) and the SDK follows it with a trailing
1428
- // `session_state_changed: idle` — record the debt so the idle
1429
- // handler absorbs that idle rather than reading it as a turn the
1430
- // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
1431
- // turn's own result. It is dropped at the `session.cancelled`
1432
- // guard, and either the idle itself settles the turn (consuming
1433
- // the trailer) or the next echo's hand-off does (which records
1434
- // the debt there instead) — counting here too would double it.
1435
- // Results skipped while cancelled with NO active turn — orphaned
1436
- // queued turns the SDK still ran, or a force-cancelled turn's
1437
- // late result after the backstop settled it — get no such settle,
1438
- // so their trailers must be counted here or they'd later be read
1439
- // as the next healthy turn being abandoned and false-fail it.
1440
- if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
1441
- owedTrailingIdles++;
1442
- }
1443
- // Accumulate usage into the user turn's tally. Skip task-notification
1444
- // followups: their cost is real but is reported separately via the
1445
- // usage_update below, and `session.accumulatedUsage` is only reset on
1446
- // turn activation — so folding a task-notification result that lands
1447
- // after the next turn is active (but before it settles) would leak
1448
- // those tokens into that turn's PromptResponse.usage.
1449
- if (!isTaskNotification) {
1450
- session.accumulatedUsage.inputTokens += message.usage.input_tokens;
1451
- session.accumulatedUsage.outputTokens += message.usage.output_tokens;
1452
- session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
1453
- session.accumulatedUsage.cachedWriteTokens +=
1454
- message.usage.cache_creation_input_tokens;
1455
- }
1456
- const matchingModelUsage = lastAssistantModel
1457
- ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
1458
- : null;
1459
- // Only overwrite when we have an authoritative value — a miss
1460
- // (e.g. a turn with no top-level assistant message) would
1461
- // otherwise discard the window learned on a prior turn and
1462
- // leave the next prompt's mid-stream updates reporting 200k.
1463
- if (matchingModelUsage) {
1464
- session.contextWindowSize = matchingModelUsage.contextWindow;
1465
- }
1466
- // Send usage_update notification
1467
- if (lastAssistantTotalUsage !== null) {
1468
- await this.client.sessionUpdate({
1469
- sessionId: params.sessionId,
1470
- update: {
1471
- sessionUpdate: "usage_update",
1472
- used: lastAssistantTotalUsage,
1473
- size: session.contextWindowSize,
1474
- cost: {
1475
- amount: message.total_cost_usd,
1476
- currency: "USD",
1477
- },
1478
- ...(message.origin && {
1479
- _meta: { "_claude/origin": message.origin },
1480
- }),
1481
- },
1482
- });
1483
- }
1484
- if (session.cancelled) {
1485
- if (!isTaskNotification) {
1486
- stopReason = "cancelled";
2254
+ // A result from an autonomous cycle — a task-notification
2255
+ // followup, or a peer/coordinator/observer message the model
2256
+ // handled on its own (see AUTONOMOUS_RESULT_ORIGINS) — is not
2257
+ // the user's prompt's. Autonomous results must never touch the
2258
+ // user-turn lifecycle (stop reason, settles, failActive,
2259
+ // slash-command output forwarding), though their cost is real.
2260
+ const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
2261
+ try {
2262
+ // Reconcile the Fast mode toggle with the SDK's reported state.
2263
+ // Gated to user-driven turns like every other side effect below;
2264
+ // an autonomous cycle's state lands on the next user turn's
2265
+ // result. Runs even when the turn errors or was cancelled.
2266
+ if (!isAutonomousResult) {
2267
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state, message.fast_mode_disabled_reason);
1487
2268
  }
1488
- break;
1489
- }
1490
- // A refusal can arrive on any result subtype (and may even set
1491
- // is_error), so handle it before the subtype switch — otherwise the
1492
- // is_error throw below would surface it as an internal error. The
1493
- // refused assistant message carries no visible content, so surface
1494
- // the classifier's explanation (when available) and report ACP's
1495
- // dedicated `refusal` stop reason.
1496
- if (message.stop_reason === "refusal" && !isTaskNotification) {
1497
- if (lastRefusalExplanation) {
1498
- await this.client.sessionUpdate({
2269
+ // A user-turn result needs an active turn so its stop reason is
2270
+ // attributed and the turn settles at idle. Local-only commands carry
2271
+ // no user-message echo to promote them, so do it here from the head.
2272
+ // Promote BEFORE accumulating usage, since activation resets the
2273
+ // accumulator — promoting after would discard this result's tokens.
2274
+ // The orphan bookkeeping runs first: it covers folded/zombie
2275
+ // commands whose shared or late result this is, even when the
2276
+ // result is the ACTIVE turn's (ensureActiveTurn never looks at
2277
+ // the map in that case).
2278
+ if (!isAutonomousResult) {
2279
+ recordResultForOrphanCommands();
2280
+ ensureActiveTurn();
2281
+ }
2282
+ // A result closes the stretch of output it terminates: snapshot
2283
+ // the delivery record — AFTER ensureActiveTurn, whose held-turn
2284
+ // hand-off closes the held stretch, so an echo-less command
2285
+ // promoted here is judged on its own delivery, not on the held
2286
+ // turn's followup text — and before the handling below can emit
2287
+ // anything of its own; the `finally` then clears it so every
2288
+ // exit from this case (the cancelled-guard and refusal breaks
2289
+ // included) starts the next stretch clean. Clearing up front
2290
+ // instead would let result-time emissions (refusal explanation,
2291
+ // result-text forwarding) taint the next stretch and suppress a
2292
+ // following replayed turn's fallback. Autonomous cycles run
2293
+ // alongside a user turn and must not clear its flag (they exit
2294
+ // through the early break below, which the gated `finally`
2295
+ // leaves alone).
2296
+ const deliveredAssistantText = session.emittedAssistantText;
2297
+ // Every user-turn result terminates a turn (settle, reject, or
2298
+ // orphan skip) and the SDK follows it with a trailing
2299
+ // `session_state_changed: idle` — record the debt so the idle
2300
+ // handler absorbs that idle rather than reading it as a turn the
2301
+ // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
2302
+ // turn's own result. It is dropped at the `session.cancelled`
2303
+ // guard, and either the idle itself settles the turn (consuming
2304
+ // the trailer) or the next echo's hand-off does (which records
2305
+ // the debt there instead) — counting here too would double it.
2306
+ // Results skipped while cancelled with NO active turn — orphaned
2307
+ // queued turns the SDK still ran, or a force-cancelled turn's
2308
+ // late result after the backstop settled it — get no such settle,
2309
+ // so their trailers must be counted here or they'd later be read
2310
+ // as the next healthy turn being abandoned and false-fail it.
2311
+ // Autonomous results (followups, peer/channel/coordinator
2312
+ // cycles) are counted too: each is its own processing cycle with
2313
+ // its own trailing idle, and that idle can lag past the next
2314
+ // prompt's echo — which, un-owed, would be read as the fresh
2315
+ // turn being abandoned (#825 false-fail). That lag was mostly
2316
+ // unreachable when such cycles only ran with no pending turn,
2317
+ // but a held turn settling AT a followup result unblocks the
2318
+ // client at exactly that point, making the race the common
2319
+ // case.
2320
+ // The cancelled-ACTIVE-turn exclusion applies only to that
2321
+ // turn's OWN result — a followup result arriving inside the
2322
+ // cancel window still gets its own trailer and must be counted,
2323
+ // or that idle would later false-fail the next prompt.
2324
+ if (isAutonomousResult || !session.cancelled || !session.activeTurn) {
2325
+ session.owedTrailingIdles++;
2326
+ }
2327
+ // Accumulate usage into the user turn's tally. Skip autonomous
2328
+ // results: their cost is real but is reported separately via the
2329
+ // usage_update below, and `session.accumulatedUsage` is only reset on
2330
+ // turn activation — so folding an autonomous result that lands
2331
+ // after the next turn is active (but before it settles) would leak
2332
+ // those tokens into that turn's PromptResponse.usage.
2333
+ if (!isAutonomousResult) {
2334
+ session.accumulatedUsage.inputTokens += message.usage.input_tokens;
2335
+ session.accumulatedUsage.outputTokens += message.usage.output_tokens;
2336
+ session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
2337
+ session.accumulatedUsage.cachedWriteTokens +=
2338
+ message.usage.cache_creation_input_tokens;
2339
+ }
2340
+ const matchingModelUsage = lastAssistantModel
2341
+ ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
2342
+ : null;
2343
+ // Only overwrite when we have an authoritative, sane value. A miss
2344
+ // (e.g. a turn with no top-level assistant message), or a
2345
+ // nonsensical non-positive/NaN window (observed from third-party
2346
+ // backends), would otherwise discard the window learned on a prior
2347
+ // turn and leave the next prompt's mid-stream updates reporting a
2348
+ // wrong size. `cacheContextWindow` applies the same `> 0` guard, so
2349
+ // a bad value never reaches the cross-session cache either.
2350
+ if (matchingModelUsage &&
2351
+ typeof matchingModelUsage.usage.contextWindow === "number" &&
2352
+ matchingModelUsage.usage.contextWindow > 0) {
2353
+ session.contextWindowSize = matchingModelUsage.usage.contextWindow;
2354
+ session.contextWindowAuthoritative = true;
2355
+ // Authoritative: fold it into the cross-session cache keyed on
2356
+ // (this session's provider, the resolved model id —
2357
+ // matchingModelUsage.key, e.g. "claude-sonnet-5[1m]") so a later
2358
+ // session/new or switch on the same provider that resolves to
2359
+ // this model seeds the correct window synchronously, with no
2360
+ // getContextUsage IPC.
2361
+ cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, matchingModelUsage.key), matchingModelUsage.usage.contextWindow);
2362
+ // Also cache under the assistant message's own (bare) spelling.
2363
+ // Seed-time reads fall back to a picker value / verbatim live id
2364
+ // when a row carries no resolvedModel (the synthesized
2365
+ // out-of-allowlist resume row sets it undefined on purpose), and
2366
+ // those spellings match `.model` from the assistant message, not
2367
+ // the decorated modelUsage key — without this entry such rows
2368
+ // could never hit the cache.
2369
+ if (lastAssistantModel && lastAssistantModel !== matchingModelUsage.key) {
2370
+ cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, lastAssistantModel), matchingModelUsage.usage.contextWindow);
2371
+ }
2372
+ }
2373
+ // Send usage_update notification
2374
+ if (lastAssistantTotalUsage !== null) {
2375
+ await sendUpdate({
1499
2376
  sessionId: params.sessionId,
1500
2377
  update: {
1501
- sessionUpdate: "agent_message_chunk",
1502
- content: { type: "text", text: lastRefusalExplanation },
2378
+ sessionUpdate: "usage_update",
2379
+ used: lastAssistantTotalUsage,
2380
+ size: session.contextWindowSize,
2381
+ cost: {
2382
+ amount: message.total_cost_usd,
2383
+ currency: "USD",
2384
+ },
2385
+ ...(message.origin && {
2386
+ _meta: { "_claude/origin": message.origin },
2387
+ }),
1503
2388
  },
1504
2389
  });
1505
2390
  }
1506
- stopReason = "refusal";
1507
- settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
1508
- break;
1509
- }
1510
- switch (message.subtype) {
1511
- case "success": {
1512
- if (message.result.includes("Please run /login")) {
1513
- failActive(RequestError.authRequired());
1514
- break;
1515
- }
1516
- if (message.stop_reason === "max_tokens") {
1517
- if (!isTaskNotification) {
1518
- stopReason = "max_tokens";
1519
- }
1520
- break;
2391
+ if (session.cancelled) {
2392
+ if (!isAutonomousResult) {
2393
+ stopReason = "cancelled";
1521
2394
  }
1522
- if (message.is_error) {
1523
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
1524
- break;
2395
+ break;
2396
+ }
2397
+ // A held turn (see Turn.deferredSettle) settles at its
2398
+ // followup's terminal result: this is the earliest point at which
2399
+ // the promised summary has fully streamed — the trailing idle
2400
+ // would work too, but a client should not wait out another idle
2401
+ // round-trip for a response whose content is already complete.
2402
+ // (While the turn still awaits another of its subagents —
2403
+ // parallel spawns — the helper holds; the next notification's
2404
+ // followup settles it instead. Other autonomous origins — peer/
2405
+ // channel/coordinator cycles — reach here too: settling a
2406
+ // drained hold at their results is as good as the idle
2407
+ // fallback.) Then stop: everything below is user-turn
2408
+ // lifecycle, and an autonomous outcome must never touch it —
2409
+ // its is_error or "Please run /login" text would otherwise
2410
+ // failActive a live turn (the held one, or the user's next
2411
+ // prompt) whose own result recorded a different outcome.
2412
+ if (isAutonomousResult) {
2413
+ settleDeferredIfDrained();
2414
+ // With no turn in flight OR QUEUED (also after the settle
2415
+ // above), the stretch holds only autonomous prose — close
2416
+ // it, so a replayed next prompt isn't silently suppressed by
2417
+ // the issue-#453 delivery check. A live turn's flag may
2418
+ // guard the USER's already-streamed text — and so may a
2419
+ // QUEUED turn's: with mid-message echo lag its deltas stream
2420
+ // before the echo activates it (activeTurn still null), and
2421
+ // clearing then would re-emit that answer via the fallback,
2422
+ // the duplicate direction the flag's doc forbids.
2423
+ if (!session.activeTurn && !firstUnsettledQueuedTurn()) {
2424
+ session.emittedAssistantText = false;
1525
2425
  }
1526
- // For local-only commands (no model invocation), the result
1527
- // text is the command output — forward it to the client.
1528
- // Task-notification followups never originate from a user
1529
- // slash command, so skip the forwarding for them.
1530
- if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
1531
- for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
1532
- await this.client.sessionUpdate(notification);
1533
- }
2426
+ break;
2427
+ }
2428
+ // A refusal can arrive on any result subtype (and may even set
2429
+ // is_error), so handle it before the subtype switch — otherwise the
2430
+ // is_error throw below would surface it as an internal error. The
2431
+ // refused assistant message carries no visible content, so surface
2432
+ // the classifier's explanation (when available) and report ACP's
2433
+ // dedicated `refusal` stop reason.
2434
+ if (message.stop_reason === "refusal") {
2435
+ if (lastRefusalExplanation) {
2436
+ await sendUpdate({
2437
+ sessionId: params.sessionId,
2438
+ update: {
2439
+ sessionUpdate: "agent_message_chunk",
2440
+ content: { type: "text", text: lastRefusalExplanation },
2441
+ },
2442
+ });
1534
2443
  }
2444
+ stopReason = "refusal";
2445
+ // Through the deferral gate, not settleActive: a refusal can
2446
+ // land on a turn whose spawned subagents are still live, and
2447
+ // settling it out from under them would strand their output
2448
+ // and permission requests out-of-turn (issue #866's deadlock,
2449
+ // through the refusal lane).
2450
+ settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
1535
2451
  break;
1536
2452
  }
1537
- case "error_during_execution": {
1538
- if (message.stop_reason === "max_tokens") {
1539
- if (!isTaskNotification) {
2453
+ switch (message.subtype) {
2454
+ case "success": {
2455
+ if (message.result.includes("Please run /login")) {
2456
+ failActive(RequestError.authRequired());
2457
+ break;
2458
+ }
2459
+ if (message.stop_reason === "max_tokens") {
1540
2460
  stopReason = "max_tokens";
2461
+ break;
2462
+ }
2463
+ if (message.is_error) {
2464
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2465
+ break;
2466
+ }
2467
+ // The result text is forwarded in two cases. Local-only
2468
+ // commands (no model invocation): the result IS the command
2469
+ // output. Otherwise the result is normally a trailing copy of
2470
+ // text that already streamed — but a cache-replayed turn
2471
+ // generates no tokens, and some CLIs then skip streaming
2472
+ // entirely and answer on the `result` alone: no `stream_event`
2473
+ // deltas, no consolidated `assistant` message (issue #453).
2474
+ // Forward it rather than end the turn silently:
2475
+ // `deliveredAssistantText` covers whatever already reached the
2476
+ // client (a turn that showed its answer cannot emit it twice),
2477
+ // and the output-token check keeps the fallback to the
2478
+ // replayed turns it was reported for. `?? 0`: typed non-null,
2479
+ // but third-party backends have been observed omitting usage
2480
+ // token fields (see snapshotFromUsage), and the replay lane
2481
+ // was reported from exactly such a backend — treat a missing
2482
+ // count as the replay signature rather than silently disabling
2483
+ // the fallback there. (Autonomous results never get here —
2484
+ // they exit at the early break above — so no background
2485
+ // prose can be injected into the feed.)
2486
+ if (session.activeTurn?.isLocalOnlyCommand ||
2487
+ (!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
2488
+ for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
2489
+ await sendUpdate(notification);
2490
+ }
1541
2491
  }
1542
2492
  break;
1543
2493
  }
1544
- if (message.is_error) {
1545
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1546
- break;
1547
- }
1548
- if (!isTaskNotification) {
2494
+ case "error_during_execution": {
2495
+ if (message.stop_reason === "max_tokens") {
2496
+ stopReason = "max_tokens";
2497
+ break;
2498
+ }
2499
+ if (message.is_error) {
2500
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2501
+ break;
2502
+ }
1549
2503
  stopReason = "end_turn";
1550
- }
1551
- break;
1552
- }
1553
- case "error_max_budget_usd":
1554
- case "error_max_turns":
1555
- case "error_max_structured_output_retries":
1556
- if (message.is_error) {
1557
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1558
2504
  break;
1559
2505
  }
1560
- if (!isTaskNotification) {
2506
+ case "error_max_budget_usd":
2507
+ case "error_max_turns":
2508
+ case "error_max_structured_output_retries":
2509
+ if (message.is_error) {
2510
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2511
+ break;
2512
+ }
1561
2513
  stopReason = "max_turn_requests";
1562
- }
1563
- break;
1564
- default:
1565
- unreachable(message, this.logger);
1566
- break;
2514
+ break;
2515
+ default:
2516
+ unreachable(message, this.logger);
2517
+ break;
2518
+ }
2519
+ // Settle the user turn at its terminal result so the client unlocks
2520
+ // as soon as the answer is done, rather than waiting for the SDK's
2521
+ // trailing `idle` (which can lag while background work runs — issue
2522
+ // #773). The consumer keeps draining afterward (absorbing idle and
2523
+ // forwarding any background output).
2524
+ //
2525
+ // One exception: while background subagents this turn spawned are
2526
+ // still live, settling now would strand their remaining work
2527
+ // outside any turn — ACP allows out-of-turn session/update, but
2528
+ // many clients stop consuming at the prompt response, and a
2529
+ // subagent's permission request would block on an RPC nobody
2530
+ // answers (issues #864/#866). Hold the turn open instead: store
2531
+ // the outcome and settle with it once the subagents are done —
2532
+ // at their followup's terminal result (see the deferred-settle
2533
+ // block above the subtype switch) or at an idle with none of
2534
+ // them left — so the subagents' streamed output, their
2535
+ // permission requests, and the model's promised summary all land
2536
+ // inside the turn. `session/cancel` and the next prompt's echo
2537
+ // hand-off still settle a deferred turn early, so a long-running
2538
+ // subagent never holds the prompt hostage.
2539
+ //
2540
+ // is_error/auth already settled via failActive (activeTurn is null
2541
+ // then, so both branches no-op); cancellation is left to the
2542
+ // idle/abort path. settleActive is idempotent, so a duplicate
2543
+ // idle is a no-op.
2544
+ if (!session.cancelled) {
2545
+ settleOrDefer({ stopReason, usage: sessionUsage(session) });
2546
+ }
1567
2547
  }
1568
- // Settle the user turn at its terminal result so the client unlocks
1569
- // as soon as the answer is done, rather than waiting for the SDK's
1570
- // trailing `idle` (which can lag while background work runs — issue
1571
- // #773). The consumer keeps draining afterward (absorbing idle and
1572
- // forwarding any background output). is_error/auth already settled
1573
- // via failActive; cancellation is left to the idle/abort path.
1574
- // settleActive is idempotent, so a duplicate idle is a no-op.
1575
- if (!isTaskNotification && !session.cancelled) {
1576
- settleActive({ stopReason, usage: sessionUsage(session) });
2548
+ finally {
2549
+ if (!isAutonomousResult) {
2550
+ session.emittedAssistantText = false;
2551
+ }
1577
2552
  }
1578
2553
  break;
1579
2554
  }
@@ -1636,12 +2611,16 @@ export class ClaudeAcpAgent {
1636
2611
  const model = message.event.message.model;
1637
2612
  if (model && model !== "<synthetic>") {
1638
2613
  lastAssistantModel = model;
1639
- // Only upgrade from the default — once a `result` has given
1640
- // us an authoritative window, trust it over the heuristic.
1641
- // Model switches invalidate the cached window via
1642
- // `syncSessionConfigState`, which resets us back to the
1643
- // default so this branch runs again for the new model.
1644
- if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
2614
+ // Only upgrade from the heuristic default — once we have an
2615
+ // authoritative window (cache-seeded at session creation or
2616
+ // on a model switch, read from the resumed session on
2617
+ // session/load, confirmed by each `result`), trust it over
2618
+ // the heuristic. The flag, not the value, is the sentinel: an
2619
+ // authoritative window can legitimately equal
2620
+ // DEFAULT_CONTEXT_WINDOW (e.g. a backend serving a 200k lane
2621
+ // under a "[1m]"-spelled id) and must not be clobbered.
2622
+ if (!session.contextWindowAuthoritative &&
2623
+ session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
1645
2624
  const inferred = inferContextWindowFromModel(model);
1646
2625
  if (inferred !== null) {
1647
2626
  session.contextWindowSize = inferred;
@@ -1666,7 +2645,7 @@ export class ClaudeAcpAgent {
1666
2645
  const nextUsage = totalTokens(lastAssistantUsage);
1667
2646
  if (nextUsage !== lastAssistantTotalUsage) {
1668
2647
  lastAssistantTotalUsage = nextUsage;
1669
- await this.client.sessionUpdate({
2648
+ await sendUpdate({
1670
2649
  sessionId: params.sessionId,
1671
2650
  update: {
1672
2651
  sessionUpdate: "usage_update",
@@ -1682,8 +2661,11 @@ export class ClaudeAcpAgent {
1682
2661
  taskState: session.taskState,
1683
2662
  emittedToolCalls: session.emittedToolCalls,
1684
2663
  messageId: currentStreamMessageId,
2664
+ streamedToolInputs,
1685
2665
  })) {
1686
- await this.client.sessionUpdate(notification);
2666
+ // sendUpdate records delivery; a subagent stream's chunks carry
2667
+ // the stamped parentToolUseId meta and are excluded there.
2668
+ await sendUpdate(notification);
1687
2669
  }
1688
2670
  break;
1689
2671
  }
@@ -1704,7 +2686,7 @@ export class ClaudeAcpAgent {
1704
2686
  // is still promoted — activateTurn() clears the flag. The turn's own
1705
2687
  // echo is then dropped from the feed (the client already shows it).
1706
2688
  if (message.type === "user" && "uuid" in message && message.uuid) {
1707
- const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
2689
+ const queued = findUnsettledTurn(message.uuid);
1708
2690
  if (queued) {
1709
2691
  // Only (re)activate if this isn't already the active turn — a
1710
2692
  // turn promoted early (e.g. by a result that preceded its echo)
@@ -1722,15 +2704,38 @@ export class ClaudeAcpAgent {
1722
2704
  // debt so that lagged idle is absorbed rather than read
1723
2705
  // as the freshly-activated turn ending without a result
1724
2706
  // (which would false-fail a healthy turn — issue #825).
1725
- owedTrailingIdles++;
2707
+ // Counted for a DEFERRED turn too, even though its own
2708
+ // result already recorded a debt that may still be
2709
+ // outstanding: the interrupt can produce a trailer of
2710
+ // its own, and over-counting is benign (absorbs one
2711
+ // future idle) while under-counting risks the false
2712
+ // fail this debt exists to prevent.
2713
+ session.owedTrailingIdles++;
1726
2714
  // Before activateTurn resets the accumulator, so the
1727
2715
  // usage still belongs to the cancelled turn.
1728
2716
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1729
2717
  }
2718
+ else if (isHeldOpen(session.activeTurn)) {
2719
+ // A turn held open for its background subagents (see
2720
+ // Turn.deferredSettle) hands off with the real outcome
2721
+ // its result recorded, not a guessed end_turn — the
2722
+ // user moving on must not block behind a long-running
2723
+ // subagent, but it must not rewrite the stop reason
2724
+ // either. Its trailing-idle debt stands and is absorbed
2725
+ // when the drain idle eventually arrives.
2726
+ settleActive(session.activeTurn.deferredSettle);
2727
+ }
1730
2728
  else {
1731
2729
  settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
1732
2730
  }
1733
2731
  }
2732
+ // Unlike the no-result teardown lanes, this hand-off must
2733
+ // NOT clear emittedAssistantText for a NON-held previous
2734
+ // turn (a held one's settleActive above closes its own
2735
+ // stretch): the echo can land
2736
+ // mid-message, so deltas already streamed belong to the turn
2737
+ // being activated — clearing would forget them and let its
2738
+ // result re-emit the answer.
1734
2739
  activateTurn(queued);
1735
2740
  }
1736
2741
  break;
@@ -1783,7 +2788,7 @@ export class ClaudeAcpAgent {
1783
2788
  taskState: session.taskState,
1784
2789
  messageId: messageIdForGrouping(message),
1785
2790
  })) {
1786
- await this.client.sessionUpdate(notification);
2791
+ await sendUpdate(notification);
1787
2792
  }
1788
2793
  }
1789
2794
  else {
@@ -1807,12 +2812,7 @@ export class ClaudeAcpAgent {
1807
2812
  if (message.message.role === "system") {
1808
2813
  break;
1809
2814
  }
1810
- if (message.type === "assistant" &&
1811
- message.message.model === "<synthetic>" &&
1812
- Array.isArray(message.message.content) &&
1813
- message.message.content.length === 1 &&
1814
- message.message.content[0].type === "text" &&
1815
- message.message.content[0].text.includes("Please run /login")) {
2815
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
1816
2816
  failActive(RequestError.authRequired());
1817
2817
  break;
1818
2818
  }
@@ -1878,11 +2878,13 @@ export class ClaudeAcpAgent {
1878
2878
  // the record stays bounded to the in-flight message.
1879
2879
  streamedBlocks.length = 0;
1880
2880
  }
1881
- else if (message.type === "assistant") {
1882
- // Subagent assistant message (`parent_tool_use_id !== null`). It is
1883
- // never streamed live and its text/thinking is internal to the tool
1884
- // call — keep dropping it so subagent prose doesn't leak into the
1885
- // top-level feed.
2881
+ else if (message.type === "assistant" &&
2882
+ !(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
2883
+ // Legacy clients don't understand nested transcripts. Keep the
2884
+ // historical behavior for them: subagent text/thinking remains
2885
+ // internal to the tool call instead of leaking into the top-level
2886
+ // feed. Capable clients opt into the branch above unchanged, with
2887
+ // `parentToolUseId` stamped by toAcpNotifications.
1886
2888
  content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
1887
2889
  }
1888
2890
  else {
@@ -1895,22 +2897,64 @@ export class ClaudeAcpAgent {
1895
2897
  taskState: session.taskState,
1896
2898
  emittedToolCalls: session.emittedToolCalls,
1897
2899
  messageId: messageIdForGrouping(message),
2900
+ toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
2901
+ // On the wire since CLI 2.1.216 but not in SDKUserMessage's
2902
+ // type, hence the cast. Validated by parseToolResultMeta.
2903
+ toolResultMeta: message.type === "user"
2904
+ ? message.tool_result_meta
2905
+ : undefined,
1898
2906
  })) {
1899
- await this.client.sessionUpdate(notification);
2907
+ // sendUpdate records delivery. Subagent text/thinking is
2908
+ // filtered out of `content` above; blocks that do pass through
2909
+ // (e.g. a subagent image) carry the stamped parentToolUseId
2910
+ // meta and are excluded there.
2911
+ await sendUpdate(notification);
1900
2912
  }
1901
2913
  break;
1902
2914
  }
1903
2915
  case "tool_progress": {
1904
- await this.client.sessionUpdate({
2916
+ // Not every beat reports under the id of a tool call the client has
2917
+ // seen: heartbeats derive `<tool_use_id>-heartbeat-<n>`, and the
2918
+ // `agent_api_retry` beats behind `subagentRetry` report under
2919
+ // `agent_<assistant_message_id>`. Forwarding those verbatim leaves the
2920
+ // client resolving an id it has never been told about (the same trap
2921
+ // `ensureToolCallEmitted` documents for #851). The SDK stamps
2922
+ // `parent_tool_use_id` with the executing tool's real id whenever the
2923
+ // beat doesn't carry one of its own, so fall back to it rather than
2924
+ // pattern-matching each synthetic id shape. Beats that do report a real
2925
+ // id (a subagent's `bash_progress`, whose parent is the spawning Agent
2926
+ // call) keep resolving to that id.
2927
+ const toolCallId = session.emittedToolCalls.has(message.tool_use_id)
2928
+ ? message.tool_use_id
2929
+ : message.parent_tool_use_id;
2930
+ // Ids leave `emittedToolCalls` at `tool_result`, so this also stops a
2931
+ // beat that races past completion from reopening a finished call.
2932
+ if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
2933
+ break;
2934
+ }
2935
+ await sendUpdate({
1905
2936
  sessionId: message.session_id,
1906
2937
  update: {
1907
2938
  sessionUpdate: "tool_call_update",
1908
- toolCallId: message.tool_use_id,
2939
+ toolCallId,
1909
2940
  status: "in_progress",
1910
2941
  _meta: {
1911
2942
  claudeCode: {
1912
2943
  toolName: message.tool_name,
1913
- toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
2944
+ toolResponse: {
2945
+ elapsedTimeSeconds: message.elapsed_time_seconds,
2946
+ // For Agent/Task calls: the subagent's type, and — when
2947
+ // the subagent is waiting out an API rate-limit retry —
2948
+ // the SDK's retry counters (attempt, max_retries,
2949
+ // retry_delay_ms, …), forwarded verbatim so clients can
2950
+ // show why a spawn looks stalled.
2951
+ ...(message.subagent_type !== undefined && {
2952
+ subagentType: message.subagent_type,
2953
+ }),
2954
+ ...(message.subagent_retry !== undefined && {
2955
+ subagentRetry: message.subagent_retry,
2956
+ }),
2957
+ },
1914
2958
  },
1915
2959
  },
1916
2960
  },
@@ -1919,7 +2963,7 @@ export class ClaudeAcpAgent {
1919
2963
  }
1920
2964
  case "rate_limit_event": {
1921
2965
  if (lastAssistantTotalUsage !== null) {
1922
- await this.client.sessionUpdate({
2966
+ await sendUpdate({
1923
2967
  sessionId: message.session_id,
1924
2968
  update: {
1925
2969
  sessionUpdate: "usage_update",
@@ -1940,7 +2984,7 @@ export class ClaudeAcpAgent {
1940
2984
  case "conversation_reset":
1941
2985
  break;
1942
2986
  default:
1943
- unreachable(message);
2987
+ unreachable(message, this.logger);
1944
2988
  break;
1945
2989
  }
1946
2990
  }
@@ -1984,6 +3028,30 @@ export class ClaudeAcpAgent {
1984
3028
  }
1985
3029
  }
1986
3030
  }
3031
+ /** Route one orphaned command into the session's orphan-accounting lane:
3032
+ * the per-uuid map on msg_lifecycle_v1 CLIs (drained by the command's own
3033
+ * terminal lifecycle frame and the echo-less-result skip), the plain count
3034
+ * elsewhere (the count lane can't express per-command states, so `state`
3035
+ * only matters on the map lane). Both orphan-producing paths — cancel()'s
3036
+ * queued-turn sweep and the consumer's force-cancel wedge path — must seed
3037
+ * through here so the lane split stays a single mechanism.
3038
+ *
3039
+ * Known window: `msgLifecycleV1` is only learnable from the stream's first
3040
+ * `system`/init (the control-channel initialize carries no capabilities),
3041
+ * so a cancel that beats that drain seeds the COUNT lane on a
3042
+ * lifecycle-capable CLI — where command coalescing can leave the count
3043
+ * stale by N-1 (the pre-map bug, confined to this sub-second window and
3044
+ * still healed by the next activation's reset). Structural until the SDK
3045
+ * exposes capabilities before the stream starts. */
3046
+ trackOrphanCommand(session, uuid, state) {
3047
+ if (session.msgLifecycleV1) {
3048
+ session.orphanCommands ??= new Map();
3049
+ session.orphanCommands.set(uuid, state);
3050
+ }
3051
+ else {
3052
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
3053
+ }
3054
+ }
1987
3055
  async cancel(params) {
1988
3056
  const session = this.sessions[params.sessionId];
1989
3057
  if (!session) {
@@ -2006,11 +3074,18 @@ export class ClaudeAcpAgent {
2006
3074
  return;
2007
3075
  }
2008
3076
  session.cancelled = true;
3077
+ // Capture the orphan-accounting lane before anything can await: the
3078
+ // consumer latches msgLifecycleV1 when it drains the first system/init,
3079
+ // which can happen DURING the awaited interrupt() below — the receipt
3080
+ // reconciliation must act on the same lane the seeding used, or a
3081
+ // count-lane orphan would be left for the map-lane receipt path (which
3082
+ // never decrements the count) to miss.
3083
+ const lifecycleLane = session.msgLifecycleV1 === true;
2009
3084
  // Settle queued turns that haven't started yet (no echo seen) right away —
2010
3085
  // they have no in-flight SDK work to interrupt. The active turn is settled
2011
3086
  // by the consumer when it observes the interrupt's trailing idle (or via the
2012
3087
  // backstop below). Mirrors the old pendingMessages cancellation.
2013
- const orphanedUuids = [];
3088
+ const orphanedTurns = [];
2014
3089
  if (session.turnQueue) {
2015
3090
  for (const turn of session.turnQueue) {
2016
3091
  if (turn !== session.activeTurn && !turn.settled) {
@@ -2018,16 +3093,98 @@ export class ClaudeAcpAgent {
2018
3093
  // Deliberately no `usage`: a queued turn never ran, so the session
2019
3094
  // accumulator (the active turn's tally) is not its spend.
2020
3095
  turn.resolve({ stopReason: "cancelled" });
2021
- orphanedUuids.push(turn.promptUuid);
3096
+ orphanedTurns.push(turn);
2022
3097
  }
2023
3098
  }
2024
3099
  // Each removed queued turn's user message was already pushed to the SDK,
2025
3100
  // which processes input FIFO and will still emit a result for it with no
2026
- // uuid to match. Count those so the consumer skips them (see
3101
+ // uuid to match. Track those so the consumer skips them (see
2027
3102
  // ensureActiveTurn) rather than misattributing them to the head.
2028
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphanedUuids.length;
3103
+ // msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
3104
+ // own terminal lifecycle frame — exact under command coalescing, where
3105
+ // N queued commands fold into ONE turn emitting one result and a plain
3106
+ // count would go stale by N-1 and swallow a later echo-less result.
3107
+ // Older CLIs keep the count and its activation-time self-heal (they
3108
+ // never see lifecycle frames, so commandStarted/commandFinished stay
3109
+ // unset and every turn takes the plain-seed path below).
3110
+ for (const turn of orphanedTurns) {
3111
+ if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
3112
+ // The command already finished SDK-side and its terminal frame was
3113
+ // consumed while the turn sat queued — nothing is left to skip, and
3114
+ // a seeded entry would never drain.
3115
+ continue;
3116
+ }
3117
+ if (turn.commandFinished === "cancelled") {
3118
+ // Terminal frame already consumed. Dispatched-then-aborted: the
3119
+ // dead turn's late result may still come — seed the zombie the
3120
+ // frame handler would have made — unless that result already
3121
+ // passed pre-cancel (commandResultSeen: e.g. the command folded
3122
+ // into the active turn and their shared result was attributed
3123
+ // there), in which case a zombie would be a phantom that swallows
3124
+ // an unrelated later result. Never dispatched: dropped, no result
3125
+ // coming, nothing to track.
3126
+ if (turn.commandStarted && !turn.commandResultSeen) {
3127
+ this.trackOrphanCommand(session, turn.promptUuid, "zombie");
3128
+ }
3129
+ continue;
3130
+ }
3131
+ if (turn.commandStarted && turn.commandResultSeen) {
3132
+ // Dispatched and its turn's result already passed; only its
3133
+ // terminal frame is outstanding, which no-ops with no entry.
3134
+ continue;
3135
+ }
3136
+ this.trackOrphanCommand(session, turn.promptUuid, turn.commandStarted ? "started" : "pending");
3137
+ }
2029
3138
  session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
2030
3139
  }
3140
+ // A deferred active turn (see Turn.deferredSettle) already has its
3141
+ // result — it is only held open for its background subagents, which the
3142
+ // interrupt below tears down. Settle it "cancelled" NOW: during the hold
3143
+ // the session is typically already in state idle (the CLI's trailer
3144
+ // fired at the result), so the interrupt may produce no fresh idle for
3145
+ // the consumer's cancelled-settle path to run on, and the cancel would
3146
+ // otherwise stall until the force-cancel backstop. Any outstanding
3147
+ // trailer debt is absorbed by the idle handler when its idle does come.
3148
+ // The turn's own usage snapshot is reported per the cancelled-usage
3149
+ // contract (issue #844).
3150
+ {
3151
+ const active = session.activeTurn;
3152
+ if (isHeldOpen(active)) {
3153
+ active.settled = true;
3154
+ // Mirror settleActive's invariants (it is consumer-scoped and
3155
+ // unreachable from here): disarm the backstop — none should be
3156
+ // armed for a held turn, but a drift here must not leave a timer
3157
+ // firing on a settled turn — and drop the turn from the queue.
3158
+ disarmForceCancel(session);
3159
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== active);
3160
+ session.activeTurn = null;
3161
+ // Settling a held turn closes its delivery stretch: any streamed
3162
+ // text since the last boundary was its followups', and left latched
3163
+ // it would suppress a following replayed turn's issue-#453 fallback.
3164
+ session.emittedAssistantText = false;
3165
+ // When the interrupt below pre-empts a live cycle — running, or
3166
+ // blocked on a permission request (requires_action, the #866 shape
3167
+ // users cancel out of) — it produces a trailer idle with no counted
3168
+ // result; with the hold's own trailer typically already absorbed,
3169
+ // that idle would be un-owed and could lag past the next prompt's
3170
+ // echo — read as the fresh turn ending without a result (issue #825
3171
+ // false-fail). Pre-count it unless the session sits idle: there the
3172
+ // interrupt emits nothing, and a debt that never drains would mask
3173
+ // one future #825 detection. (lastSessionState is last-CONSUMED, so
3174
+ // both stale reads exist and both are accepted one-cycle windows: a
3175
+ // running transition still in the backlog reads as stale idle and
3176
+ // under-counts — that false-fail additionally needs the trailer to
3177
+ // lag past the next echo — while a cycle already completed into the
3178
+ // backlog reads as stale non-idle and over-counts, masking one
3179
+ // future #825 detection. Undefined — no state event consumed —
3180
+ // pre-counts; that only occurs on CLIs whose missing idle events
3181
+ // also disable the detector the debt could mask.)
3182
+ if (session.lastSessionState !== "idle") {
3183
+ session.owedTrailingIdles++;
3184
+ }
3185
+ active.resolve({ stopReason: "cancelled", usage: active.deferredSettle.usage });
3186
+ }
3187
+ }
2031
3188
  // Arm a backstop before interrupting: if a turn is actively consuming the
2032
3189
  // query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
2033
3190
  // block — issue #680), force the consumer to settle the active turn
@@ -2065,11 +3222,30 @@ export class ClaudeAcpAgent {
2065
3222
  // receipt, so a bare `{}` success from a gateway can't read as "everything
2066
3223
  // was dropped") — keep the count-everything behavior and its
2067
3224
  // activation-time self-heal.
2068
- if (Array.isArray(receipt?.still_queued) && orphanedUuids.length > 0) {
3225
+ if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
2069
3226
  const stillQueued = new Set(receipt.still_queued);
2070
- const dropped = orphanedUuids.filter((uuid) => !stillQueued.has(uuid)).length;
2071
- if (dropped > 0) {
2072
- session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
3227
+ if (lifecycleLane) {
3228
+ // Lifecycle lane: forget dropped orphans by uuid. Only entries still
3229
+ // "pending" — an orphan absent from `still_queued` because it was
3230
+ // DISPATCHED before the interrupt (not dropped) has usually been
3231
+ // promoted to "started" by its lifecycle frame by now, and its own
3232
+ // terminal frame must stay in charge of its fate. (If that frame is
3233
+ // still in the consumer's backlog we mis-forget — the same exposure
3234
+ // the count lane has always had for a dropped-then-run command.)
3235
+ // Mostly redundant with the "cancelled"-frame removal, but a receipt
3236
+ // survives paths where that frame was never emitted.
3237
+ for (const turn of orphanedTurns) {
3238
+ if (!stillQueued.has(turn.promptUuid) &&
3239
+ session.orphanCommands?.get(turn.promptUuid) === "pending") {
3240
+ session.orphanCommands.delete(turn.promptUuid);
3241
+ }
3242
+ }
3243
+ }
3244
+ else {
3245
+ const dropped = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid)).length;
3246
+ if (dropped > 0) {
3247
+ session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
3248
+ }
2073
3249
  }
2074
3250
  }
2075
3251
  }
@@ -2117,10 +3293,7 @@ export class ClaudeAcpAgent {
2117
3293
  // after the floor, and clear the timer so it can't outlive the deleted
2118
3294
  // session (it isn't unref'd and would otherwise keep the event loop alive
2119
3295
  // until it fires).
2120
- if (session.forceCancelTimer) {
2121
- clearTimeout(session.forceCancelTimer);
2122
- session.forceCancelTimer = undefined;
2123
- }
3296
+ disarmForceCancel(session);
2124
3297
  session.cancelController?.abort();
2125
3298
  this.closeQueryStream(session);
2126
3299
  // Abort the SDK abort signal only on explicit destroy. closeQueryStream
@@ -2328,6 +3501,8 @@ export class ClaudeAcpAgent {
2328
3501
  async replaySessionHistory(sessionId) {
2329
3502
  const toolUseCache = {};
2330
3503
  const messages = await getSessionMessages(sessionId);
3504
+ const forwardSubagentText = this.sessions[sessionId]?.forwardSubagentText ??
3505
+ supportsSubagentTranscript(this.clientCapabilities);
2331
3506
  for (const message of messages) {
2332
3507
  // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
2333
3508
  // observe live (resumed/loaded sessions), so rewind/resume can translate
@@ -2338,8 +3513,18 @@ export class ClaudeAcpAgent {
2338
3513
  if (replaySession && replayMessageId && message.uuid) {
2339
3514
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
2340
3515
  }
3516
+ // The live prompt loop converts the synthetic "Please run /login"
3517
+ // assistant message into an authRequired error instead of showing its
3518
+ // TUI-specific text; skip it on replay too (issue #863).
3519
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3520
+ continue;
3521
+ }
2341
3522
  // @ts-expect-error - untyped in SDK but we handle all of these
2342
3523
  let content = message.message.content;
3524
+ const parentToolUseId = parentToolUseIdOf(message);
3525
+ if (message.type === "assistant" && parentToolUseId && !forwardSubagentText) {
3526
+ content = stripSubagentTextAndThinking(content);
3527
+ }
2343
3528
  // @ts-expect-error - untyped in SDK but we handle all of these
2344
3529
  if (message.message.role === "user") {
2345
3530
  content = stripLocalCommandMetadata(content);
@@ -2356,6 +3541,7 @@ export class ClaudeAcpAgent {
2356
3541
  cwd: this.sessions[sessionId]?.cwd,
2357
3542
  taskState: this.sessions[sessionId]?.taskState,
2358
3543
  messageId: replayMessageId,
3544
+ parentToolUseId,
2359
3545
  })) {
2360
3546
  await this.client.sessionUpdate(notification);
2361
3547
  }
@@ -2376,14 +3562,14 @@ export class ClaudeAcpAgent {
2376
3562
  * outcome or a `requestCancelled` rejection). Either way we surface the same
2377
3563
  * "Tool use aborted" the callers already expect, so a cancelled dialog no
2378
3564
  * longer leaves the `await` hanging. */
2379
- async requestPermissionFromClient(params, toolName, signal) {
3565
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
2380
3566
  // The SDK may invoke `canUseTool` (and therefore this permission request)
2381
3567
  // before the assistant message's tool_use block streams to us. Some ACP clients
2382
3568
  // expect the `tool_call` a permission request references to already exist,
2383
3569
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
2384
3570
  // later refines it with a `tool_call_update` rather than emitting a
2385
3571
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
2386
- await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
3572
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
2387
3573
  try {
2388
3574
  return await this.client.requestPermission(params, signal);
2389
3575
  }
@@ -2400,10 +3586,15 @@ export class ClaudeAcpAgent {
2400
3586
  * instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
2401
3587
  * `toolCallNotification` helper as the streamed path so the two are identical.
2402
3588
  * Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
2403
- * skipped so a permission prompt for them never surfaces a stray tool_call. */
2404
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput) {
3589
+ * emitted too: a permission request referencing a tool call the client has
3590
+ * never seen can trip strict clients (issue #851), so the reference must
3591
+ * always resolve. Since the streamed path never completes those calls, they
3592
+ * are resolved at tool_result time instead (see `toAcpNotifications`).
3593
+ * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
3594
+ * that spawned it, matching the streamed path's `_meta`. */
3595
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
2405
3596
  const session = this.sessions[sessionId];
2406
- if (!session || !shouldEmitToolCall(toolName)) {
3597
+ if (!session) {
2407
3598
  return;
2408
3599
  }
2409
3600
  if (session.emittedToolCalls.has(toolCallId)) {
@@ -2411,13 +3602,20 @@ export class ClaudeAcpAgent {
2411
3602
  }
2412
3603
  session.emittedToolCalls.add(toolCallId);
2413
3604
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2414
- await this.client.sessionUpdate({
2415
- sessionId,
2416
- update: toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd),
2417
- });
3605
+ const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
3606
+ if (parentToolUseId) {
3607
+ update._meta = {
3608
+ ...update._meta,
3609
+ claudeCode: {
3610
+ ...(update._meta?.claudeCode || {}),
3611
+ parentToolUseId,
3612
+ },
3613
+ };
3614
+ }
3615
+ await this.client.sessionUpdate({ sessionId, update });
2418
3616
  }
2419
3617
  canUseTool(sessionId) {
2420
- return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
3618
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
2421
3619
  const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
2422
3620
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2423
3621
  const session = this.sessions[sessionId];
@@ -2427,6 +3625,23 @@ export class ClaudeAcpAgent {
2427
3625
  message: "Session not found",
2428
3626
  };
2429
3627
  }
3628
+ // When the tool call originates inside a subagent, attribute the eagerly
3629
+ // emitted tool_call (and the permission request itself) to the Agent/Task
3630
+ // tool call that spawned the subagent, mirroring the streamed subagent
3631
+ // path's `_meta.claudeCode.parentToolUseId` (see `liveBackgroundTasks`).
3632
+ const parentToolUseId = agentID
3633
+ ? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
3634
+ : undefined;
3635
+ if (agentID && !parentToolUseId) {
3636
+ // The attribution rests on an undocumented SDK invariant
3637
+ // (task_started.task_id === canUseTool's agentID for subagent tasks;
3638
+ // verified against the bundled CLI). Should an SDK bump break it — or
3639
+ // the consumer lose the race with task_started — the lookup misses and
3640
+ // the request goes out unattributed; log it so the regression is
3641
+ // observable rather than silent.
3642
+ this.logger.log(`[claude-agent-acp] No parent tool_use recorded for subagent ${agentID}; ` +
3643
+ `sending the ${toolName} permission request unattributed`);
3644
+ }
2430
3645
  // AskUserQuestion is surfaced to us as a normal permission check (the SDK
2431
3646
  // routes it through canUseTool whenever a callback is registered, rather
2432
3647
  // than the interactive dialog). Present it as an ACP form elicitation and
@@ -2434,44 +3649,9 @@ export class ClaudeAcpAgent {
2434
3649
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
2435
3650
  // Like permission requests, the elicitation references this toolUseID, so
2436
3651
  // make sure the tool_call has surfaced to the client before we send it.
2437
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
3652
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
2438
3653
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
2439
3654
  }
2440
- // Fallback for clients WITHOUT `elicitation.form`: route each question
2441
- // through ACP `session/request_permission` dialogs (gated by
2442
- // ACP_ASKUSERQUESTION_FALLBACK). Placed before ExitPlanMode and the
2443
- // bypassPermissions early-allow so a question is always asked, even in
2444
- // bypass mode.
2445
- if (toolName === "AskUserQuestion" && askUserQuestionFallbackEnabled(process.env)) {
2446
- // Emit the tool_call before the first permission request (R3.1).
2447
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
2448
- // Logger has no `debug`; `.error` reaches stderr in all contexts (the CLI
2449
- // entrypoint also remaps console.log/debug -> stderr), so it never
2450
- // corrupts the stdout ndJSON protocol. Used by story 002 validation.
2451
- this.logger.error("AskUserQuestion: routing via permission fallback (client lacks elicitation.form).");
2452
- return handleAskUserQuestionViaPermission(toolInput, async ({ question, options }) => {
2453
- const response = await this.requestPermissionFromClient({
2454
- options,
2455
- sessionId,
2456
- toolCall: {
2457
- toolCallId: toolUseID,
2458
- rawInput: toolInput,
2459
- ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
2460
- // Per-question title so the user sees which question they are
2461
- // answering; placed AFTER the spread so it wins over the
2462
- // tool-derived title.
2463
- title: question.question,
2464
- },
2465
- }, toolName, signal);
2466
- // RequestPermissionResponse has a DOUBLE-nested outcome (see the
2467
- // ExitPlanMode usage below): response.outcome?.outcome is
2468
- // "selected" | "cancelled".
2469
- if (response.outcome?.outcome === "selected") {
2470
- return { outcome: "selected", optionId: response.outcome.optionId };
2471
- }
2472
- return { outcome: "cancelled" };
2473
- }, signal);
2474
- }
2475
3655
  if (toolName === "ExitPlanMode") {
2476
3656
  const optionsAll = [
2477
3657
  { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
@@ -2503,8 +3683,13 @@ export class ClaudeAcpAgent {
2503
3683
  toolCallId: toolUseID,
2504
3684
  rawInput: toolInput,
2505
3685
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3686
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3687
+ // so clients can rely on one shape everywhere.
3688
+ ...(parentToolUseId
3689
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3690
+ : {}),
2506
3691
  },
2507
- }, toolName, signal);
3692
+ }, toolName, signal, parentToolUseId);
2508
3693
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2509
3694
  throw new Error("Tool use aborted");
2510
3695
  }
@@ -2538,7 +3723,14 @@ export class ClaudeAcpAgent {
2538
3723
  };
2539
3724
  }
2540
3725
  }
2541
- if (session.modes.currentModeId === "bypassPermissions") {
3726
+ // In bypass mode the CLI skips permission checks itself; the asks that
3727
+ // still reach canUseTool are the ones it insists on prompting for even
3728
+ // under --dangerously-skip-permissions. Keep auto-allowing those —
3729
+ // bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
3730
+ // user explicitly configured a permissions.ask rule for this tool, and
3731
+ // the SDK's guidance is that hosts running auto-approval must treat such
3732
+ // asks as a human prompt. Fall through to the normal request below.
3733
+ if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
2542
3734
  return {
2543
3735
  behavior: "allow",
2544
3736
  updatedInput: toolInput,
@@ -2562,8 +3754,13 @@ export class ClaudeAcpAgent {
2562
3754
  toolCallId: toolUseID,
2563
3755
  rawInput: toolInput,
2564
3756
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3757
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3758
+ // so clients can rely on one shape everywhere.
3759
+ ...(parentToolUseId
3760
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3761
+ : {}),
2565
3762
  },
2566
- }, toolName, signal);
3763
+ }, toolName, signal, parentToolUseId);
2567
3764
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2568
3765
  throw new Error("Tool use aborted");
2569
3766
  }
@@ -2739,12 +3936,21 @@ export class ClaudeAcpAgent {
2739
3936
  // carries no "1m" token.
2740
3937
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
2741
3938
  if (session.models.currentModelId !== value) {
2742
- // The cached context window was learned for the previous model; reset
2743
- // to the new model's heuristic so mid-stream updates between now and
2744
- // the next `result` reflect the user's selection instead of the old
2745
- // model's window.
2746
- session.contextWindowSize =
2747
- inferContextWindowFromModel(value, newModelInfo?.displayName, newModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW;
3939
+ // Seed the new model's context window WITHOUT any IPC on the switch
3940
+ // path: cached authoritative value if we've already learned it (from a
3941
+ // prior turn's `result.modelUsage`), else the text heuristic, else the
3942
+ // default. We deliberately do NOT call `getContextUsage` here — before
3943
+ // a fresh session's first prompt turn that control request is not
3944
+ // serviced (~15s stall, issues #886/#880), and (because SDK control
3945
+ // requests are serialized over one channel) it would drag the awaited
3946
+ // `setModel` down with it. The authoritative window arrives on the
3947
+ // first `result.modelUsage` for the model and is cached from there;
3948
+ // until then a switched-to alias that has never run a turn shows the
3949
+ // heuristic/default window, which self-corrects on its first response
3950
+ // (matches pre-0.59.0 behavior).
3951
+ const seeded = immediateContextWindow(session.providerCacheKey, value, newModelInfo);
3952
+ session.contextWindowSize = seeded.size;
3953
+ session.contextWindowAuthoritative = seeded.authoritative;
2748
3954
  }
2749
3955
  session.models = { ...session.models, currentModelId: value };
2750
3956
  // Recompute availableModes for the new model and clamp the current
@@ -2782,6 +3988,13 @@ export class ClaudeAcpAgent {
2782
3988
  else {
2783
3989
  session.modes = { ...session.modes, availableModes: newAvailableModes };
2784
3990
  }
3991
+ // `model_not_allowed` described the model we just left, so it must not
3992
+ // follow us onto the new one; the remaining reasons are account- or
3993
+ // environment-scoped and stay true across a switch. Either way the next
3994
+ // init/result report refreshes this.
3995
+ if (session.fastModeDisabledReason === "model_not_allowed") {
3996
+ session.fastModeDisabledReason = undefined;
3997
+ }
2785
3998
  // Rebuild config options since effort levels depend on the selected model
2786
3999
  const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2787
4000
  const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
@@ -2791,6 +4004,7 @@ export class ClaudeAcpAgent {
2791
4004
  // intent) when a supporting model is selected again.
2792
4005
  supported: newModelInfo?.supportsFastMode ?? false,
2793
4006
  enabled: session.fastModeEnabled,
4007
+ disabledReason: session.fastModeDisabledReason,
2794
4008
  },
2795
4009
  // Thinking is model-independent: re-render the retained tri-state
2796
4010
  // intent so the row survives this rebuild (an option not threaded
@@ -2876,12 +4090,13 @@ export class ClaudeAcpAgent {
2876
4090
  }
2877
4091
  }
2878
4092
  /** Replace the Fast mode option in `session.configOptions` so it reflects
2879
- * `enabled`. A no-op when the option isn't present, so callers must confirm
2880
- * the current model surfaces it first. Rebuilds through the single-parameter
2881
- * {@link createFastModeConfigOption} — the one source of the option's shape —
2882
- * so the shape can't drift from what `buildConfigOptions` first emitted. */
4093
+ * `enabled` (and the session's current disabled reason). A no-op when the
4094
+ * option isn't present, so callers must confirm the current model surfaces
4095
+ * it first. Rebuilds through {@link createFastModeConfigOption} — the one
4096
+ * source of the option's shape — so the shape can't drift from what
4097
+ * `buildConfigOptions` first emitted. */
2883
4098
  refreshFastModeOption(session, enabled) {
2884
- const refreshed = createFastModeConfigOption(enabled);
4099
+ const refreshed = createFastModeConfigOption(enabled, session.fastModeDisabledReason);
2885
4100
  session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
2886
4101
  }
2887
4102
  /** Toggle Fast mode for a session: push the SDK flag, record the user's
@@ -3165,8 +4380,15 @@ export class ClaudeAcpAgent {
3165
4380
  * here).
3166
4381
  * - `cooldown`: a transient suspension of an already-enabled fast mode.
3167
4382
  * Leave the toggle as-is rather than flapping it — and never let a stray
3168
- * cooldown spuriously enable a toggle the user has off. */
3169
- async syncFastModeState(sessionId, session, state) {
4383
+ * cooldown spuriously enable a toggle the user has off.
4384
+ *
4385
+ * `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
4386
+ * state. Only explainable reasons are retained (see
4387
+ * {@link normalizeFastModeDisabledReason}), so the comparison below tracks
4388
+ * exactly what the user can see: a routine `sdk_opt_in_required` report on
4389
+ * every turn's result can't churn the option, while a real blocker updates
4390
+ * the description even when the toggle's own value is unchanged. */
4391
+ async syncFastModeState(sessionId, session, state, reason) {
3170
4392
  if (state === undefined) {
3171
4393
  return;
3172
4394
  }
@@ -3177,11 +4399,31 @@ export class ClaudeAcpAgent {
3177
4399
  return;
3178
4400
  }
3179
4401
  const enabled = state === "on";
3180
- if (enabled === session.fastModeEnabled) {
4402
+ // A reason only describes an off state; drop any that rides an `on` report
4403
+ // so it can't decorate the option the next time fast mode goes off.
4404
+ const nextReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
4405
+ if (enabled === session.fastModeEnabled && nextReason === session.fastModeDisabledReason) {
3181
4406
  return;
3182
4407
  }
4408
+ // The user asked for Fast mode and the SDK is telling us it can't serve it.
4409
+ // The description carries the same explanation, but a toggle silently
4410
+ // snapping back is the case worth saying out loud once, at the flip.
4411
+ const explain = session.fastModeEnabled && !enabled && nextReason !== undefined;
3183
4412
  session.fastModeEnabled = enabled;
4413
+ session.fastModeDisabledReason = nextReason;
3184
4414
  this.refreshFastModeOption(session, enabled);
4415
+ if (explain) {
4416
+ await this.client.sessionUpdate({
4417
+ sessionId,
4418
+ update: {
4419
+ sessionUpdate: "agent_message_chunk",
4420
+ content: {
4421
+ type: "text",
4422
+ text: `**Fast mode turned off:** ${FAST_MODE_UNAVAILABLE_EXPLANATIONS[nextReason]}.`,
4423
+ },
4424
+ },
4425
+ });
4426
+ }
3185
4427
  await this.client.sessionUpdate({
3186
4428
  sessionId,
3187
4429
  update: {
@@ -3313,6 +4555,8 @@ export class ClaudeAcpAgent {
3313
4555
  // Extract options from _meta if provided
3314
4556
  const sessionMeta = params._meta;
3315
4557
  const userProvidedOptions = sessionMeta?.claudeCode?.options;
4558
+ const forwardSubagentText = supportsSubagentTranscript(this.clientCapabilities) ||
4559
+ userProvidedOptions?.forwardSubagentText === true;
3316
4560
  // Configure thinking behavior through the same single code path query
3317
4561
  // recreation uses (`recreateSessionQuery`). A fresh session's Thinking
3318
4562
  // intent is untouched (`undefined`), so this resolves to exactly the
@@ -3328,13 +4572,9 @@ export class ClaudeAcpAgent {
3328
4572
  url: !!this.clientCapabilities?.elicitation?.url,
3329
4573
  };
3330
4574
  // AskUserQuestion surfaces as a `permission_ask_user_question` dialog that
3331
- // we render as a form elicitation. Without form-elicitation support we fall
3332
- // back to sequential ACP `session/request_permission` dialogs when the
3333
- // fallback gate is on (the default); set ACP_ASKUSERQUESTION_FALLBACK=0 to
3334
- // keep it disabled, exactly as upstream does.
3335
- const disallowedTools = elicitationSupport.form || askUserQuestionFallbackEnabled(process.env)
3336
- ? []
3337
- : ["AskUserQuestion"];
4575
+ // we render as a form elicitation. Without form-elicitation support there
4576
+ // is no way to present it over ACP, so keep it disabled in that case.
4577
+ const disallowedTools = elicitationSupport.form ? [] : ["AskUserQuestion"];
3338
4578
  // Resolve which built-in tools to expose.
3339
4579
  // Explicit tools array from _meta.claudeCode.options takes precedence.
3340
4580
  // disableBuiltInTools is a legacy shorthand for tools: [] — kept for
@@ -3346,6 +4586,28 @@ export class ClaudeAcpAgent {
3346
4586
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
3347
4587
  // the same Map that the streaming message handler will read from.
3348
4588
  const taskState = new Map();
4589
+ // The exact env the query will be created with. Built (and the provider
4590
+ // cache key derived from it, below) in one place so the key always
4591
+ // describes the backend this query actually talks to: `providers/set`,
4592
+ // `providers/disable`, and `logout` mutate the process-wide provider
4593
+ // config concurrently, so re-resolving it after any of the awaits between
4594
+ // here and the session registration could disagree with the env baked
4595
+ // into the query.
4596
+ const env = {
4597
+ ...process.env,
4598
+ ...userProvidedOptions?.env,
4599
+ // Client-managed LLM routing: `providers/set` config wins, else the
4600
+ // legacy gateway auth request. Baked into the query at creation, so it
4601
+ // only affects sessions started after the change (matching the RFD).
4602
+ ...createEnvForProvider(this.resolveProviderConfig()),
4603
+ // Opt-in to session state events like when the agent is idle
4604
+ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
4605
+ };
4606
+ // Scopes the context-window cache to this query's backend (see
4607
+ // `contextWindowCache`). Derived from the same `env` object handed to the
4608
+ // SDK, so per-session `_meta` env routing and ambient process-env routing
4609
+ // are distinguished exactly as the CLI will see them.
4610
+ const providerCacheKey = providerCacheKeyFor(env);
3349
4611
  const options = {
3350
4612
  systemPrompt,
3351
4613
  settingSources: ["user", "project", "local"],
@@ -3366,16 +4628,11 @@ export class ClaudeAcpAgent {
3366
4628
  ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
3367
4629
  },
3368
4630
  }),
3369
- env: {
3370
- ...process.env,
3371
- ...userProvidedOptions?.env,
3372
- ...createEnvForGateway(this.gatewayAuthRequest),
3373
- // Opt-in to session state events like when the agent is idle
3374
- CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
3375
- },
4631
+ env,
3376
4632
  // Override certain fields that must be controlled by ACP
3377
4633
  cwd: params.cwd,
3378
4634
  includePartialMessages: true,
4635
+ forwardSubagentText,
3379
4636
  mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
3380
4637
  // If we want bypassPermissions to be an option, we have to allow it here.
3381
4638
  // But it doesn't work in root mode, so we only activate it if it will work.
@@ -3414,7 +4671,7 @@ export class ClaudeAcpAgent {
3414
4671
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
3415
4672
  {
3416
4673
  hooks: [
3417
- createPostToolUseHook(this.logger, {
4674
+ createPostToolUseHook({
3418
4675
  onEnterPlanMode: async () => {
3419
4676
  await this.client.sessionUpdate({
3420
4677
  sessionId,
@@ -3536,7 +4793,7 @@ export class ClaudeAcpAgent {
3536
4793
  const allowedModels = Array.isArray(settingsAvailableModels)
3537
4794
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
3538
4795
  : hideDeprecatedModels(initializationResult.models, this.logger);
3539
- const models = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
4796
+ const { modelState: models, resumedContextWindow } = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
3540
4797
  // Gate `auto` (and future model-specific modes) on the resolved model's
3541
4798
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
3542
4799
  // Looked up in the UNfiltered catalog: a session honoring a persisted
@@ -3627,9 +4884,17 @@ export class ClaudeAcpAgent {
3627
4884
  // the resolved model advertises `supportsFastMode`.
3628
4885
  const fastModeEnabled = initializationResult.fast_mode_state !== undefined &&
3629
4886
  fastModeStateEnabled(initializationResult.fast_mode_state);
4887
+ // `fast_mode_disabled_reason` reflects the post-switch model since SDK
4888
+ // 0.3.219 (the initialize response used to answer from the spawn-time
4889
+ // model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
4890
+ // the opt-in — which normalizes away, so only real blockers are retained.
4891
+ const fastModeDisabledReason = fastModeEnabled
4892
+ ? undefined
4893
+ : normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);
3630
4894
  const fastMode = {
3631
4895
  supported: currentModelInfo?.supportsFastMode ?? false,
3632
4896
  enabled: fastModeEnabled,
4897
+ disabledReason: fastModeDisabledReason,
3633
4898
  };
3634
4899
  const configOptions = buildConfigOptions(modes, models,
3635
4900
  // Catalog-based (see `modelInfos` above), matching the model-switch
@@ -3651,9 +4916,40 @@ export class ClaudeAcpAgent {
3651
4916
  typeof initialEffort.currentValue === "string" &&
3652
4917
  initialEffort.currentValue !== "default") {
3653
4918
  await q.applyFlagSettings({
3654
- effortLevel: initialEffort.currentValue,
4919
+ effortLevel: toSdkEffortLevel(initialEffort.currentValue),
3655
4920
  });
3656
4921
  }
4922
+ // Seed the context window WITHOUT any extra IPC on the session/new path.
4923
+ // On session/load, the resumed session's own `getContextUsage` report — a
4924
+ // response `getAvailableModels` already awaited to learn the live model
4925
+ // (resumed sessions ARE serviced pre-turn, unlike fresh ones) — is
4926
+ // authoritative and wins. Otherwise: the cached authoritative window if a
4927
+ // prior turn has learned it for this model (`result.modelUsage`,
4928
+ // cross-session), else the text heuristic, else the default. We
4929
+ // deliberately do NOT issue a getContextUsage call here: on a fresh
4930
+ // session that control request is not serviced until the first prompt
4931
+ // turn runs, so awaiting it — as 0.59.0 did — made session/new take ~15s
4932
+ // (issues #886/#880). The authoritative window arrives on the first
4933
+ // `result.modelUsage` and is cached from there.
4934
+ //
4935
+ // Text inference alone misses aliases that resolve to extended-context
4936
+ // models with no "1m" token anywhere in their id or description (e.g.
4937
+ // `sonnet` → claude-sonnet-5, natively ~1M): those stream
4938
+ // `usage_update.size: 200000` until the first result's modelUsage corrects
4939
+ // it — but the cache means only the FIRST session to ever run a turn on such
4940
+ // a model eats that window, not every fresh session after a process
4941
+ // restart (issue #596; a post-restart session/load is covered by the
4942
+ // resumed report above).
4943
+ //
4944
+ // The inference fallback is deliberately keyed to the catalog entry (the
4945
+ // fork's unfiltered lookup, see `catalogModelInfo` above): a
4946
+ // fallback-resolved sibling's resolvedModel/displayName/description can
4947
+ // describe a different context lane than the verbatim live id (e.g. an
4948
+ // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
4949
+ // the id itself is a trustworthy window signal.
4950
+ const seededWindow = resumedContextWindow !== null
4951
+ ? { size: resumedContextWindow, authoritative: true }
4952
+ : immediateContextWindow(providerCacheKey, models.currentModelId, catalogModelInfo);
3657
4953
  this.sessions[sessionId] = {
3658
4954
  query: q,
3659
4955
  input: input,
@@ -3681,18 +4977,19 @@ export class ClaudeAcpAgent {
3681
4977
  agents,
3682
4978
  currentAgent,
3683
4979
  fastModeEnabled,
4980
+ fastModeDisabledReason,
3684
4981
  abortController,
3685
4982
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
3686
- contextWindowSize:
3687
- // Deliberately keyed to the catalog entry: a fallback-resolved
3688
- // sibling's displayName/description can describe a different context
3689
- // lane than the verbatim live id (e.g. an "opus[1m]" row matched for
3690
- // a bare 200k id), so on the fallback path only the id itself is a
3691
- // trustworthy window signal.
3692
- inferContextWindowFromModel(models.currentModelId, catalogModelInfo?.displayName, catalogModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
4983
+ forwardSubagentText,
4984
+ contextWindowSize: seededWindow.size,
4985
+ contextWindowAuthoritative: seededWindow.authoritative,
4986
+ providerCacheKey,
3693
4987
  taskState,
3694
4988
  toolUseCache: {},
3695
4989
  emittedToolCalls: new Set(),
4990
+ liveBackgroundTasks: new Map(),
4991
+ emittedAssistantText: false,
4992
+ owedTrailingIdles: 0,
3696
4993
  messageIdToUuid: new Map(),
3697
4994
  };
3698
4995
  return {
@@ -3762,27 +5059,76 @@ function snapshotFromUsage(usage) {
3762
5059
  cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
3763
5060
  };
3764
5061
  }
3765
- function createEnvForGateway(request) {
5062
+ /**
5063
+ * Adapt a legacy gateway `authenticate` request into the shared
5064
+ * {@link ProviderConfig} shape. Returns `null` when no gateway request is
5065
+ * present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
5066
+ * otherwise anthropic.
5067
+ */
5068
+ function gatewayRequestToProviderConfig(request) {
3766
5069
  if (!request?._meta) {
5070
+ return null;
5071
+ }
5072
+ return {
5073
+ apiType: request.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
5074
+ baseUrl: request._meta.gateway.baseUrl,
5075
+ headers: request._meta.gateway.headers,
5076
+ };
5077
+ }
5078
+ /**
5079
+ * Map a resolved provider config into the Claude Code env vars that redirect API
5080
+ * traffic and inject headers. Returns an empty object when routing is
5081
+ * unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
5082
+ * skips its normal login/credential checks when a gateway is in use.
5083
+ */
5084
+ function createEnvForProvider(config) {
5085
+ if (!config) {
3767
5086
  return {};
3768
5087
  }
3769
- const customHeaders = Object.entries(request._meta.gateway.headers)
5088
+ const customHeaders = Object.entries(config.headers)
3770
5089
  .map(([key, value]) => `${key}: ${value}`)
3771
5090
  .join("\n");
3772
- if (request.methodId === "gateway-bedrock") {
5091
+ if (config.apiType === "bedrock") {
3773
5092
  return {
3774
5093
  CLAUDE_CODE_USE_BEDROCK: "1",
3775
5094
  AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
3776
- ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
5095
+ ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
5096
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
5097
+ };
5098
+ }
5099
+ if (config.apiType === "vertex") {
5100
+ // `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
5101
+ // validation; fall back to empty strings defensively.
5102
+ return {
5103
+ CLAUDE_CODE_USE_VERTEX: "1",
5104
+ ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
5105
+ ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
5106
+ CLOUD_ML_REGION: config.vertex?.region ?? "",
3777
5107
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3778
5108
  };
3779
5109
  }
3780
5110
  return {
3781
- ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
5111
+ ANTHROPIC_BASE_URL: config.baseUrl,
3782
5112
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3783
5113
  ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
3784
5114
  };
3785
5115
  }
5116
+ /**
5117
+ * Validate a provider base URL: must be a non-empty absolute http(s) URL.
5118
+ */
5119
+ function isValidBaseUrl(baseUrl) {
5120
+ if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
5121
+ return false;
5122
+ }
5123
+ let parsed;
5124
+ try {
5125
+ parsed = new URL(baseUrl);
5126
+ }
5127
+ catch {
5128
+ return false;
5129
+ }
5130
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
5131
+ }
3786
5132
  /**
3787
5133
  * Build the list of permission modes the agent will advertise for the given
3788
5134
  * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
@@ -3832,7 +5178,10 @@ function buildAvailableModes(modelInfo) {
3832
5178
  // and only clears a key when an explicit `null` is sent — see
3833
5179
  // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
3834
5180
  // `"default"` sentinel and `undefined` (effort option absent for the model) to
3835
- // `null` ensures any previously-applied flag is actually cleared.
5181
+ // `null` ensures any previously-applied flag is actually cleared. Typed as
5182
+ // `EffortLevel` (not `Settings["effortLevel"]`): the picker offers whatever
5183
+ // `supportedEffortLevels` reports, which includes the session-scoped `"max"`
5184
+ // that the persisted Settings shape deliberately excludes.
3836
5185
  function toSdkEffortLevel(value) {
3837
5186
  return value === undefined || value === "default" ? null : value;
3838
5187
  }
@@ -3890,17 +5239,58 @@ const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
3890
5239
  export function fastModeStateEnabled(state) {
3891
5240
  return state !== "off";
3892
5241
  }
5242
+ /** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
5243
+ * that a user can act on (or at least wants to know about). Deliberately
5244
+ * partial — the omitted reasons are not worth surfacing:
5245
+ * - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
5246
+ * opt-in), so it describes the default, not a problem.
5247
+ * - `preference`: the user turned Fast mode off themselves.
5248
+ * - `pending`: eligibility is still resolving; the next report supersedes it.
5249
+ * - `unknown`: nothing meaningful to say.
5250
+ * Unknown future reasons fall through the same way (open set — the SDK's docs
5251
+ * say to ignore values you don't handle). */
5252
+ const FAST_MODE_UNAVAILABLE_EXPLANATIONS = {
5253
+ free: "not available on the free plan",
5254
+ extra_usage_disabled: "requires extra usage to be enabled for this account",
5255
+ model_not_allowed: "not available for the selected model",
5256
+ not_first_party: "not available on this API provider",
5257
+ disabled_by_env: "disabled by environment configuration",
5258
+ network_error: "eligibility could not be verified (network error)",
5259
+ };
5260
+ /** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
5261
+ * a reason we have an explanation for, else `undefined`. Keeping only
5262
+ * explainable reasons means state comparisons (see `syncFastModeState`) track
5263
+ * exactly what the user can see, so routine reports like
5264
+ * `sdk_opt_in_required` never churn the config option. */
5265
+ export function normalizeFastModeDisabledReason(reason) {
5266
+ return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
5267
+ }
3893
5268
  /** Build the Fast mode config option as a two-value on/off `select`. Emitted
3894
- * for EVERY Client — the boolean option shape is gone (story 006, R2.1). Only
3895
- * the emitted SHAPE is fixed to a select; boolean VALUES are still honored on
3896
- * set (see {@link resolveFastModeEnabled}). This factory is the single source
3897
- * of the option's shape, re-rendered by `refreshFastModeOption` /
3898
- * `syncFastModeState` so the shape can never desync. */
3899
- export function createFastModeConfigOption(enabled) {
5269
+ * for EVERY Client — the boolean option shape is gone (story 006, R2.1;
5270
+ * retained through the v0.64.0 sync by story 008 R3.4). Only the emitted SHAPE
5271
+ * is fixed to a select; boolean VALUES are still honored on set (see
5272
+ * {@link resolveFastModeEnabled}). This factory is the single source of the
5273
+ * option's shape, re-rendered by `refreshFastModeOption` / `syncFastModeState`
5274
+ * so the shape can never desync.
5275
+ *
5276
+ * `disabledReason` (the SDK's `fast_mode_disabled_reason`, upstream v0.64.0) is
5277
+ * folded into the description while the toggle reads off, so a user whose
5278
+ * account or provider can't serve Fast mode sees why instead of a switch that
5279
+ * silently refuses to stay on. Ignored while enabled: a reason reported
5280
+ * alongside an `on`/`cooldown` state isn't blocking anything right now.
5281
+ *
5282
+ * Upstream's second parameter (`useBooleanOption`) is deliberately absent: the
5283
+ * shape is unconditionally a select, so there is no branch to select. What
5284
+ * guards that is behavioural, not structural — `tests/fast-mode-select-only.
5285
+ * test.ts` proves no argument combination can yield the boolean shape. */
5286
+ export function createFastModeConfigOption(enabled, disabledReason) {
5287
+ const explanation = enabled
5288
+ ? undefined
5289
+ : disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
3900
5290
  return {
3901
5291
  id: FAST_MODE_CONFIG_ID,
3902
5292
  name: "Fast mode",
3903
- description: FAST_MODE_DESCRIPTION,
5293
+ description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
3904
5294
  category: "model_config",
3905
5295
  type: "select",
3906
5296
  currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
@@ -3993,7 +5383,7 @@ thinkingEnabled) {
3993
5383
  // option is always emitted as a two-value on/off select for every Client
3994
5384
  // (R2.1); boolean values remain accepted on set for boolean-era clients.
3995
5385
  if (fastMode?.supported) {
3996
- options.push(createFastModeConfigOption(fastMode.enabled));
5386
+ options.push(createFastModeConfigOption(fastMode.enabled, fastMode.disabledReason));
3997
5387
  }
3998
5388
  // Surface the Thinking toggle whenever the caller supplies its display
3999
5389
  // state. Unlike Fast mode it is model-independent — no `supported` gate —
@@ -4334,17 +5724,26 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
4334
5724
  }
4335
5725
  /** Read the model a resumed session is actually running (via the
4336
5726
  * `getContextUsage` control request — the same source `/context` prints) and
4337
- * map it onto the picker. Best-effort: a control-request failure is logged
4338
- * and returns null so callers keep their current choice; failing the whole
4339
- * session/load over an unreadable report would be worse. */
5727
+ * map it onto the picker, along with the report's authoritative context
5728
+ * window (`rawMaxTokens`). Resumed sessions get this request serviced before
5729
+ * any turn runs in the new process — unlike fresh sessions, where it stalls
5730
+ * until the first prompt turn (issues #886/#880) — so the same response that
5731
+ * restores the live model (issue #845) also seeds the window for free,
5732
+ * covering post-restart reloads of models the text heuristic misses (issue
5733
+ * #596). Best-effort: a control-request failure is logged and returns nulls
5734
+ * so callers keep their current choice; failing the whole session/load over
5735
+ * an unreadable report would be worse. */
4340
5736
  async function readResumedLiveModel(query, models, logger) {
4341
5737
  try {
4342
- const liveModel = (await query.getContextUsage()).model;
4343
- return liveModel ? matchResumedModel(models, liveModel) : null;
5738
+ const usage = await query.getContextUsage();
5739
+ return {
5740
+ model: usage.model ? matchResumedModel(models, usage.model) : null,
5741
+ contextWindow: usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null,
5742
+ };
4344
5743
  }
4345
5744
  catch (error) {
4346
5745
  logger.error("Failed to read the resumed session's live model:", error);
4347
- return null;
5746
+ return { model: null, contextWindow: null };
4348
5747
  }
4349
5748
  }
4350
5749
  async function getAvailableModels(query,
@@ -4360,6 +5759,11 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
4360
5759
  const settings = settingsManager.getSettings();
4361
5760
  let currentModel = models[0];
4362
5761
  let resolvedFromInput;
5762
+ // The context window reported alongside a resumed session's live model.
5763
+ // Only ever non-null on the paths where `currentModel` IS the live model
5764
+ // (no override, or a failed override re-assert), so the window always
5765
+ // describes the model the session actually runs.
5766
+ let resumedContextWindow = null;
4363
5767
  // Model priority (highest to lowest):
4364
5768
  // 1. ANTHROPIC_MODEL environment variable
4365
5769
  // 2. settings.model (user configuration)
@@ -4387,7 +5791,9 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
4387
5791
  // the SDK is already running this model, and pushing a picker alias back
4388
5792
  // (e.g. "opus[1m]") could change the live model rather than describe it.
4389
5793
  if (resolvedFromInput === undefined && isResumedSession) {
4390
- currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
5794
+ const live = await readResumedLiveModel(query, models, logger);
5795
+ currentModel = live.model ?? currentModel;
5796
+ resumedContextWindow = live.contextWindow;
4391
5797
  }
4392
5798
  // Skip the setModel round-trip when we can prove the SDK has already landed
4393
5799
  // on the same model. Two cases qualify:
@@ -4420,16 +5826,25 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
4420
5826
  if (!isResumedSession)
4421
5827
  throw error;
4422
5828
  logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
4423
- currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
5829
+ const live = await readResumedLiveModel(query, models, logger);
5830
+ currentModel = live.model ?? currentModel;
5831
+ resumedContextWindow = live.contextWindow;
4424
5832
  }
4425
5833
  }
4426
5834
  return {
4427
- availableModels: pickerModels.map((model) => ({
4428
- modelId: model.value,
4429
- name: model.displayName,
4430
- description: model.description,
4431
- })),
4432
- currentModelId: currentModel.value,
5835
+ modelState: {
5836
+ // Picker rows come from the DEPRECATION-FILTERED list (`pickerModels`),
5837
+ // not the raw catalog upstream maps here: the filter is visibility-only,
5838
+ // so preference resolution and capability lookups elsewhere keep reading
5839
+ // the unfiltered catalog (R4.3).
5840
+ availableModels: pickerModels.map((model) => ({
5841
+ modelId: model.value,
5842
+ name: model.displayName,
5843
+ description: model.description,
5844
+ })),
5845
+ currentModelId: currentModel.value,
5846
+ },
5847
+ resumedContextWindow,
4433
5848
  };
4434
5849
  }
4435
5850
  function getAvailableSlashCommands(commands) {
@@ -4567,6 +5982,12 @@ export function promptToClaude(prompt) {
4567
5982
  },
4568
5983
  session_id: prompt.sessionId,
4569
5984
  parent_tool_use_id: null,
5985
+ // ACP prompts are the user's own input relayed by the client. Stamp the
5986
+ // provenance explicitly: per the SDK, a host wrapping keyboard input must
5987
+ // send `{kind: "human"}` — an absent `origin` is treated as unattributed
5988
+ // and fails closed at the CLI's strict isHuman() trust gates (e.g. the
5989
+ // ultracode keyword opt-in honors only human-originated turns).
5990
+ origin: { kind: "human" },
4570
5991
  };
4571
5992
  }
4572
5993
  /**
@@ -4614,13 +6035,33 @@ function isTaskTool(toolName) {
4614
6035
  toolName === "TaskList" ||
4615
6036
  toolName === "TaskGet");
4616
6037
  }
4617
- /** Whether a tool's tool_use surfaces to the client as a standalone
6038
+ /** Whether the streamed tool_use path surfaces this tool as a standalone
4618
6039
  * `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
4619
6040
  * suppressed (their plan snapshot is emitted at tool_result time), so neither
4620
- * produces a tool_call. */
6041
+ * produces a streamed tool_call/tool_call_update — which means a
6042
+ * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
6043
+ * resolved explicitly at tool_result time. */
4621
6044
  function shouldEmitToolCall(toolName) {
4622
6045
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
4623
6046
  }
6047
+ /** Build the Claude Code-specific metadata for a tool call. Bash descriptions
6048
+ * are kept out of ACP's standard `title`, which clients may use as the shell
6049
+ * command preview, while still giving clients access to Claude's concise
6050
+ * human-readable title. */
6051
+ function claudeCodeMetaFromToolUse(toolUse) {
6052
+ const description = toolUse.name === "Bash" &&
6053
+ toolUse.input !== null &&
6054
+ typeof toolUse.input === "object" &&
6055
+ "description" in toolUse.input &&
6056
+ typeof toolUse.input.description === "string"
6057
+ ? toolUse.input.description
6058
+ : undefined;
6059
+ return {
6060
+ toolName: toolUse.name,
6061
+ ...(description ? { title: description } : {}),
6062
+ ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
6063
+ };
6064
+ }
4624
6065
  /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
4625
6066
  * notification for a tool_use. Shared by every site that surfaces a tool call:
4626
6067
  * the streamed tool_use path (first encounter → tool_call, later encounter →
@@ -4631,7 +6072,7 @@ function shouldEmitToolCall(toolName) {
4631
6072
  function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
4632
6073
  if (refine) {
4633
6074
  return {
4634
- _meta: { claudeCode: { toolName: toolUse.name } },
6075
+ _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
4635
6076
  toolCallId: toolUse.id,
4636
6077
  sessionUpdate: "tool_call_update",
4637
6078
  rawInput,
@@ -4640,7 +6081,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
4640
6081
  }
4641
6082
  return {
4642
6083
  _meta: {
4643
- claudeCode: { toolName: toolUse.name },
6084
+ claudeCode: claudeCodeMetaFromToolUse(toolUse),
4644
6085
  ...(toolUse.name === "Bash" && supportsTerminalOutput
4645
6086
  ? { terminal_info: { terminal_id: toolUse.id } }
4646
6087
  : {}),
@@ -4652,6 +6093,57 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
4652
6093
  ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
4653
6094
  };
4654
6095
  }
6096
+ /** Refine a pending tool call from the complete top-level fields recovered
6097
+ * from its still-streaming input. Shares `toolInfoFromToolUse` with the
6098
+ * consolidated path but never carries `content`: content built from partial
6099
+ * input is misleading (an Edit missing its `new_string` renders as a pure
6100
+ * deletion) or invalid (a Write diff without `content` lacks the required
6101
+ * `newText`), and the consolidated message supplies it moments later. */
6102
+ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
6103
+ // TodoWrite/Task* never surfaced a tool_call to refine (plan lane).
6104
+ if (!shouldEmitToolCall(toolUse.name)) {
6105
+ return undefined;
6106
+ }
6107
+ const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
6108
+ return {
6109
+ _meta: {
6110
+ claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
6111
+ },
6112
+ toolCallId: toolUse.id,
6113
+ sessionUpdate: "tool_call_update",
6114
+ rawInput: input,
6115
+ title,
6116
+ kind,
6117
+ ...(locations ? { locations } : {}),
6118
+ };
6119
+ }
6120
+ /** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
6121
+ * wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
6122
+ * by-tool_use_id lookup. Each entry explains why an is_error tool_result
6123
+ * carries harness prose instead of the tool's own output — "user-rejected",
6124
+ * "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
6125
+ * on the wire ahead of schema updates, so no enum check). Malformed entries
6126
+ * are skipped rather than failing the message. */
6127
+ function parseToolResultMeta(raw) {
6128
+ if (!Array.isArray(raw)) {
6129
+ return undefined;
6130
+ }
6131
+ let byToolUseId;
6132
+ for (const entry of raw) {
6133
+ if (typeof entry !== "object" || entry === null) {
6134
+ continue;
6135
+ }
6136
+ const { id, non_execution_kind, user_feedback } = entry;
6137
+ if (typeof id !== "string" || typeof non_execution_kind !== "string") {
6138
+ continue;
6139
+ }
6140
+ (byToolUseId ??= new Map()).set(id, {
6141
+ nonExecutionKind: non_execution_kind,
6142
+ ...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
6143
+ });
6144
+ }
6145
+ return byToolUseId;
6146
+ }
4655
6147
  /**
4656
6148
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
4657
6149
  * Only handles text, image, and thinking chunks for now.
@@ -4683,6 +6175,18 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4683
6175
  }
4684
6176
  return [{ sessionId, update }];
4685
6177
  }
6178
+ // `tool_use_result` is message-level and carries no tool_use_id of its own:
6179
+ // it describes "the" tool_result block of the message it rode in on. If
6180
+ // several tool_result blocks were ever batched into one message it couldn't
6181
+ // be attributed, so it is only honored when the message carries exactly one.
6182
+ const toolUseResult = options?.toolUseResult !== undefined &&
6183
+ content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
6184
+ .length === 1
6185
+ ? options.toolUseResult
6186
+ : undefined;
6187
+ // Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
6188
+ // messages need no single-block guard.
6189
+ const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
4686
6190
  const output = [];
4687
6191
  // Only handle the first chunk for streaming; extend as needed for batching
4688
6192
  for (const chunk of content) {
@@ -4824,12 +6328,66 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4824
6328
  case "bash_code_execution_tool_result":
4825
6329
  case "text_editor_code_execution_tool_result":
4826
6330
  case "mcp_tool_result": {
6331
+ const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
4827
6332
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
6333
+ // Why this is_error result carries harness prose instead of tool
6334
+ // output (user-rejected / interrupted / …), when the SDK said so.
6335
+ // Spread into the claudeCode meta of every update emitted below; the
6336
+ // untracked-tool fallback can't carry it (claudeCode metas always
6337
+ // carry `toolName`, which is unknown there).
6338
+ const nonExecution = toolResultMeta?.get(chunk.tool_use_id);
4828
6339
  const toolUse = toolUseCache[chunk.tool_use_id];
4829
6340
  if (!toolUse) {
6341
+ // The permission flow may have surfaced this tool_call even though
6342
+ // its tool_use never reached the cache (e.g. the assistant message
6343
+ // carrying it was dropped by the cancelled-turn guard and a straggler
6344
+ // result landed later). Resolve the surfaced call anyway so it can't
6345
+ // stay pending in the client forever; without the cache entry the
6346
+ // tool name is unknown, so no claudeCode meta is attached.
6347
+ if (wasEmitted) {
6348
+ output.push({
6349
+ sessionId,
6350
+ update: {
6351
+ toolCallId: chunk.tool_use_id,
6352
+ sessionUpdate: "tool_call_update",
6353
+ status: "is_error" in chunk && chunk.is_error
6354
+ ? "failed"
6355
+ : "completed",
6356
+ rawOutput: chunk.content,
6357
+ },
6358
+ });
6359
+ }
4830
6360
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
4831
6361
  break;
4832
6362
  }
6363
+ // A permission request may have surfaced a plan-rendered (TodoWrite) or
6364
+ // suppressed (Task*) tool as a real tool_call so the request referenced
6365
+ // a tool call the client knows about (see `ensureToolCallEmitted`,
6366
+ // issue #851). The branches below never emit a tool_call_update for
6367
+ // those tools, which would leave the surfaced call pending in the
6368
+ // client forever — resolve it here. `wasEmitted` is only ever true for
6369
+ // these tools via the permission flow: the streamed plan/suppressed
6370
+ // branches don't record emissions.
6371
+ if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
6372
+ output.push({
6373
+ sessionId,
6374
+ update: {
6375
+ _meta: {
6376
+ claudeCode: {
6377
+ toolName: toolUse.name,
6378
+ ...(nonExecution ?? {}),
6379
+ ...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
6380
+ },
6381
+ },
6382
+ toolCallId: chunk.tool_use_id,
6383
+ sessionUpdate: "tool_call_update",
6384
+ status: "is_error" in chunk && chunk.is_error
6385
+ ? "failed"
6386
+ : "completed",
6387
+ rawOutput: chunk.content,
6388
+ },
6389
+ });
6390
+ }
4833
6391
  if (isTaskTool(toolUse.name)) {
4834
6392
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
4835
6393
  // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
@@ -4853,7 +6411,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4853
6411
  }
4854
6412
  }
4855
6413
  else if (toolUse.name !== "TodoWrite") {
4856
- const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
6414
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, toolUseResult);
4857
6415
  // When terminal output is supported, send terminal_output as a
4858
6416
  // separate notification to match codex-acp's streaming lifecycle:
4859
6417
  // 1. tool_call → _meta.terminal_info (already sent above)
@@ -4878,6 +6436,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4878
6436
  _meta: {
4879
6437
  claudeCode: {
4880
6438
  toolName: toolUse.name,
6439
+ ...(nonExecution ?? {}),
4881
6440
  },
4882
6441
  ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
4883
6442
  },
@@ -4930,35 +6489,105 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4930
6489
  }
4931
6490
  export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
4932
6491
  const event = message.event;
6492
+ const streamKey = message.parent_tool_use_id ?? "";
6493
+ const streamedToolInputs = options?.streamedToolInputs;
6494
+ const forwardedOptions = {
6495
+ clientCapabilities: options?.clientCapabilities,
6496
+ parentToolUseId: message.parent_tool_use_id,
6497
+ cwd: options?.cwd,
6498
+ taskState: options?.taskState,
6499
+ emittedToolCalls: options?.emittedToolCalls,
6500
+ messageId: options?.messageId,
6501
+ };
4933
6502
  switch (event.type) {
4934
- case "content_block_start":
4935
- return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
4936
- clientCapabilities: options?.clientCapabilities,
4937
- parentToolUseId: message.parent_tool_use_id,
4938
- cwd: options?.cwd,
4939
- taskState: options?.taskState,
4940
- emittedToolCalls: options?.emittedToolCalls,
4941
- messageId: options?.messageId,
4942
- });
4943
- case "content_block_delta":
4944
- return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
4945
- clientCapabilities: options?.clientCapabilities,
4946
- parentToolUseId: message.parent_tool_use_id,
4947
- cwd: options?.cwd,
4948
- taskState: options?.taskState,
4949
- emittedToolCalls: options?.emittedToolCalls,
4950
- messageId: options?.messageId,
4951
- });
6503
+ case "content_block_start": {
6504
+ const block = event.content_block;
6505
+ if (streamedToolInputs &&
6506
+ (block.type === "tool_use" ||
6507
+ block.type === "server_tool_use" ||
6508
+ block.type === "mcp_tool_use")) {
6509
+ let inputsForMessage = streamedToolInputs.get(streamKey);
6510
+ if (!inputsForMessage) {
6511
+ inputsForMessage = new Map();
6512
+ streamedToolInputs.set(streamKey, inputsForMessage);
6513
+ }
6514
+ inputsForMessage.set(event.index, {
6515
+ id: block.id,
6516
+ name: block.name,
6517
+ partialJson: "",
6518
+ scannedTo: 0,
6519
+ inString: false,
6520
+ escaped: false,
6521
+ objectDepth: 0,
6522
+ arrayDepth: 0,
6523
+ lastTopLevelComma: -1,
6524
+ emittedThroughComma: -1,
6525
+ });
6526
+ }
6527
+ return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
6528
+ }
6529
+ case "content_block_delta": {
6530
+ if (event.delta.type === "input_json_delta") {
6531
+ const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
6532
+ if (!streamedInput)
6533
+ return [];
6534
+ streamedInput.partialJson += event.delta.partial_json;
6535
+ if (scanStreamedToolInput(streamedInput)) {
6536
+ // Input complete: the consolidated assistant message replays the
6537
+ // block with its full input and refines the call there; emitting
6538
+ // here too would send a duplicate identical update.
6539
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
6540
+ inputsForMessage?.delete(event.index);
6541
+ if (inputsForMessage?.size === 0)
6542
+ streamedToolInputs?.delete(streamKey);
6543
+ return [];
6544
+ }
6545
+ if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
6546
+ return [];
6547
+ }
6548
+ streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
6549
+ const input = recoveredToolInput(streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma));
6550
+ if (!input)
6551
+ return [];
6552
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
6553
+ const update = streamedInputRefinement(streamedInput, input, supportsTerminalOutput, options?.cwd);
6554
+ if (!update)
6555
+ return [];
6556
+ if (message.parent_tool_use_id) {
6557
+ update._meta = {
6558
+ ...update._meta,
6559
+ claudeCode: {
6560
+ ...(update._meta?.claudeCode || {}),
6561
+ parentToolUseId: message.parent_tool_use_id,
6562
+ },
6563
+ };
6564
+ }
6565
+ applyMessageId(update, options?.messageId);
6566
+ return [{ sessionId, update }];
6567
+ }
6568
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
6569
+ }
4952
6570
  // No content. `ping` is a Messages-API keep-alive event that the SDK's
4953
6571
  // `BetaRawMessageStreamEvent` union doesn't include even though the
4954
6572
  // wire format emits it; the `as never` cast lets us no-op it here
4955
6573
  // instead of letting it fall through to `unreachable`.
4956
6574
  case "ping":
4957
- case "message_start":
4958
6575
  case "message_delta":
6576
+ return [];
6577
+ // A message boundary ends every input stream on this lane: message_stop is
6578
+ // the normal end, and a message_start clears anything a prior message on
6579
+ // the lane left behind (e.g. a stream cut short mid-block).
6580
+ case "message_start":
4959
6581
  case "message_stop":
4960
- case "content_block_stop":
6582
+ streamedToolInputs?.delete(streamKey);
4961
6583
  return [];
6584
+ case "content_block_stop": {
6585
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
6586
+ inputsForMessage?.delete(event.index);
6587
+ if (inputsForMessage?.size === 0)
6588
+ streamedToolInputs?.delete(streamKey);
6589
+ return [];
6590
+ }
4962
6591
  default:
4963
6592
  unreachable(event, logger);
4964
6593
  return [];
@@ -5016,9 +6645,13 @@ export function runAcp() {
5016
6645
  .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
5017
6646
  .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
5018
6647
  .onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
6648
+ .onRequest(methods.agent.providers.list, (ctx) => agent.unstable_listProviders(ctx.params))
6649
+ .onRequest(methods.agent.providers.set, (ctx) => agent.unstable_setProvider(ctx.params))
6650
+ .onRequest(methods.agent.providers.disable, (ctx) => agent.unstable_disableProvider(ctx.params))
5019
6651
  .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
5020
6652
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
5021
6653
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
6654
+ .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
5022
6655
  .connect(stream);
5023
6656
  agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
5024
6657
  return { connection, agent };
@@ -5030,19 +6663,24 @@ function commonPrefixLength(a, b) {
5030
6663
  }
5031
6664
  return i;
5032
6665
  }
5033
- /** Best-effort first guess of a model's context window, used only as a
5034
- * fallback when the SDK's authoritative `getContextUsage` is unavailable (and
5035
- * until a `result` message arrives with the `modelUsage` value).
6666
+ /** Best-effort first guess of a model's context window, used to seed the
6667
+ * window synchronously (via `immediateContextWindow`) until a `result` message
6668
+ * arrives with the authoritative `modelUsage` value.
5036
6669
  *
5037
6670
  * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
5038
6671
  * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
5039
6672
  * matching things like "10m" or embedded substrings. Semantic aliases like
5040
- * `default` carry no such token in the ID, but the SDK's human-facing
5041
- * `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
5042
- * pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
5043
- * `ModelInfo` exposes no structured context-window field, so this text scan is
5044
- * the only pre-`result` signal available. A miss falls back to the default
5045
- * window and is corrected by `result.modelUsage` within one turn. */
6673
+ * `default` carry no such token in the ID, but their `resolvedModel` and the
6674
+ * SDK's human-facing `displayName`/`description` can (e.g.
6675
+ * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
6676
+ * This text scan can't catch every model — some resolve to extended-context
6677
+ * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
6678
+ * ~1M). Such a miss falls back to the default window and is corrected by
6679
+ * `result.modelUsage` (and cached) within one turn. We do NOT consult the
6680
+ * SDK's `getContextUsage` to close that gap: on a fresh session it is not
6681
+ * serviced before the first prompt turn (issues #886/#880, see
6682
+ * `contextWindowCache`; resumed sessions do get it, via
6683
+ * `readResumedLiveModel`). */
5046
6684
  function inferContextWindowFromModel(...texts) {
5047
6685
  if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
5048
6686
  return 1_000_000;
@@ -5052,12 +6690,7 @@ function inferContextWindowFromModel(...texts) {
5052
6690
  * `getContextUsage` control request. Unlike the per-message API usage numbers
5053
6691
  * (which only count message tokens), this `totalTokens` includes the system
5054
6692
  * prompt, tool schemas, MCP tools, and memory-file overhead — the real
5055
- * occupancy the user sees. Returns `null` on any control-request failure.
5056
- *
5057
- * Note: we deliberately do NOT use this response's window fields for `size`.
5058
- * They have been observed to under-report extended (1M) context windows, so
5059
- * the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
5060
- * which handle the 1M variants correctly. */
6693
+ * occupancy the user sees. Returns `null` on any control-request failure. */
5061
6694
  async function fetchContextUsedTokens(query, logger) {
5062
6695
  try {
5063
6696
  const usage = await query.getContextUsage();
@@ -5068,6 +6701,95 @@ async function fetchContextUsedTokens(query, logger) {
5068
6701
  return null;
5069
6702
  }
5070
6703
  }
6704
+ /** Cross-session cache of authoritative context windows, keyed by
6705
+ * `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
6706
+ * The window is a property of (model id, backend): the same resolved model id
6707
+ * (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys)
6708
+ * can name different context lanes behind different base URLs, routing
6709
+ * headers, or credentials, so the key carries both. Caching it module-level
6710
+ * lets a later session/new or switch that resolves to the same (backend,
6711
+ * model) — in this session or any other, within the adapter's lifetime — seed
6712
+ * the correct window synchronously with no IPC. Keying on the resolved id
6713
+ * (rather than the picker value) means aliases that resolve to the same
6714
+ * concrete model share one entry; the result handler additionally writes the
6715
+ * bare assistant-message spelling so seed-time reads that fall back to a
6716
+ * verbatim live id (rows without `resolvedModel`) can hit too.
6717
+ *
6718
+ * Populated authoritatively by each `result.modelUsage` a turn confirms (see
6719
+ * the consumer's result handler). We deliberately never populate it from a
6720
+ * fresh session's `getContextUsage`: before that session's first prompt turn
6721
+ * has run the control request is not serviced (it stalls ~15s, and serializes
6722
+ * ahead of an awaited `setModel` — issues #886/#880, regressed in 0.59.0), so
6723
+ * it can neither beat the first `result` nor be issued cheaply before one.
6724
+ * Resumed sessions are the exception — their report IS serviced pre-turn, and
6725
+ * the session/load path seeds (but does not cache) the window from the same
6726
+ * response that restores the live model, see `readResumedLiveModel`.
6727
+ * Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
6728
+ * windows learned under one login must not seed sessions under the next. */
6729
+ const contextWindowCache = new Map();
6730
+ /** The env vars that determine which LLM backend — and which context lane on
6731
+ * it — a query's API traffic reaches: endpoint selection (base URLs and the
6732
+ * Bedrock/Vertex switches with their project/region), routing/beta headers
6733
+ * (an `anthropic-beta: context-1m-…` header flips the same model id at the
6734
+ * same endpoint between context lanes), and credential identity (extended
6735
+ * context is entitlement-gated per account). Used to derive the
6736
+ * provider-cache key from the exact env a query is created with, so
6737
+ * `providers/set` config, per-session `_meta` env overrides, and ambient
6738
+ * process env are all distinguished exactly as the CLI will see them. */
6739
+ const PROVIDER_ROUTING_ENV_VARS = [
6740
+ "ANTHROPIC_BASE_URL",
6741
+ "ANTHROPIC_BEDROCK_BASE_URL",
6742
+ "ANTHROPIC_VERTEX_BASE_URL",
6743
+ "CLAUDE_CODE_USE_BEDROCK",
6744
+ "CLAUDE_CODE_USE_VERTEX",
6745
+ "ANTHROPIC_VERTEX_PROJECT_ID",
6746
+ "CLOUD_ML_REGION",
6747
+ "AWS_REGION",
6748
+ "ANTHROPIC_CUSTOM_HEADERS",
6749
+ "ANTHROPIC_API_KEY",
6750
+ "ANTHROPIC_AUTH_TOKEN",
6751
+ ];
6752
+ /** Stable identifier for the LLM backend a session's query is created against,
6753
+ * used to scope {@link contextWindowCache} per backend. Positional `\0`-join
6754
+ * of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade
6755
+ * as another and unset vars everywhere yield one stable "default" bucket.
6756
+ * Header/credential values can be secrets; the key only ever lives as an
6757
+ * in-memory Map key and is never logged or surfaced. Over-keying is the safe
6758
+ * side: a var change that didn't really change the backend costs one cache
6759
+ * miss (heuristic seed until the next result), while under-keying would serve
6760
+ * one backend's window for another's. */
6761
+ function providerCacheKeyFor(env) {
6762
+ return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0");
6763
+ }
6764
+ /** Compose the `contextWindowCache` key from a session's provider key and a
6765
+ * model id. `\0`-joined so the model segment can't collide with a provider
6766
+ * segment. */
6767
+ function contextWindowCacheKey(providerCacheKey, modelId) {
6768
+ return `${providerCacheKey}\0${modelId}`;
6769
+ }
6770
+ function cacheContextWindow(modelKey, window) {
6771
+ if (window > 0) {
6772
+ contextWindowCache.set(modelKey, window);
6773
+ }
6774
+ }
6775
+ /** The context window to report *right now* for a model, with NO IPC on the
6776
+ * critical path: the cached authoritative value if we've learned it (from a
6777
+ * prior turn's `result.modelUsage`, this or any session on the same backend),
6778
+ * else the text heuristic over the model row's identity strings, else the
6779
+ * default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`,
6780
+ * the same rule at every seed site — so read keys can't drift from the write
6781
+ * site's spelling. `authoritative` reports whether the value came from the
6782
+ * cache: an authoritative window can legitimately equal
6783
+ * DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */
6784
+ function immediateContextWindow(providerCacheKey, modelId, modelInfo) {
6785
+ const cached = contextWindowCache.get(contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId));
6786
+ if (cached !== undefined)
6787
+ return { size: cached, authoritative: true };
6788
+ return {
6789
+ size: inferContextWindowFromModel(modelId, modelInfo?.resolvedModel, modelInfo?.displayName, modelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
6790
+ authoritative: false,
6791
+ };
6792
+ }
5071
6793
  function parseModelConfig(raw) {
5072
6794
  if (!raw)
5073
6795
  return undefined;
@@ -5093,6 +6815,12 @@ function getMatchingModelUsage(modelUsage, currentModel) {
5093
6815
  }
5094
6816
  }
5095
6817
  if (bestKey) {
5096
- return modelUsage[bestKey];
6818
+ // `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
6819
+ // the same spelling as ModelInfo.resolvedModel — the primary key the
6820
+ // window is cached under. `currentModel` (the assistant message's
6821
+ // `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
6822
+ // handler caches under that spelling too, for seed-time reads that fall
6823
+ // back to a bare id (rows without `resolvedModel`).
6824
+ return { key: bestKey, usage: modelUsage[bestKey] };
5097
6825
  }
5098
6826
  }