@lucascouts/claude-agent-acp-plus 0.2.0 → 0.4.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
@@ -63,6 +63,47 @@ const QUERY_RECREATE_INIT_TIMEOUT_MS = 30_000;
63
63
  * completed/stalled without the host turn resolving (issue #825). */
64
64
  const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
65
65
  "(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
66
+ /** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
67
+ * model did on its own (a task-notification followup, a peer/coordinator/
68
+ * observer message it handled) rather than the user's prompt. Absent,
69
+ * `human`, and `channel` origins are the user's own turn (this adapter's
70
+ * prompts arrive as the ACP channel on some CLI configurations — ALL
71
+ * channel servers are treated as user, so a foreign channel integration's
72
+ * autonomously-handled result is misclassified as the user's; accepted,
73
+ * see below), and `auto-continuation` continues the user's turn, so its
74
+ * result is the turn's real terminal.
75
+ *
76
+ * Deliberately fail-OPEN: an unknown future kind defaults to the user
77
+ * lane. Misrouting a USER result into the autonomous lane hangs the
78
+ * prompt un-detectably (the result is skipped, its trailing idle absorbed
79
+ * as owed, so the #825 detector can't fire); misrouting an autonomous
80
+ * result into the user lane is the bounded misattribution class this set
81
+ * exists to reduce. */
82
+ const AUTONOMOUS_RESULT_ORIGINS = new Set([
83
+ "task-notification",
84
+ "peer",
85
+ "coordinator",
86
+ "observer",
87
+ "observer-activity",
88
+ ]);
89
+ /** Whether this turn's terminal result arrived but its settlement is being
90
+ * held for background subagents it spawned (see Turn.deferredSettle). The
91
+ * single spelling of the hold predicate, shared by the consumer's settle
92
+ * lanes and cancel(). */
93
+ function isHeldOpen(turn) {
94
+ return turn != null && turn.deferredSettle !== undefined && !turn.settled;
95
+ }
96
+ /** Disarm the force-cancel backstop (see Session.forceCancelTimer). Every
97
+ * path that settles the active turn must run this so a timer can never fire
98
+ * on an already-settled turn — and must leave the field undefined, or the
99
+ * arm site's !forceCancelTimer guard would refuse to arm the backstop for
100
+ * the NEXT turn's cancel. */
101
+ function disarmForceCancel(session) {
102
+ if (session.forceCancelTimer) {
103
+ clearTimeout(session.forceCancelTimer);
104
+ session.forceCancelTimer = undefined;
105
+ }
106
+ }
66
107
  /** Compute a stable fingerprint of the session-defining params so we can
67
108
  * detect when a loadSession/resumeSession call requires tearing down and
68
109
  * recreating the underlying Query process. MCP servers are sorted by name
@@ -71,6 +112,78 @@ function computeSessionFingerprint(params) {
71
112
  const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
72
113
  return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
73
114
  }
115
+ /**
116
+ * The single provider ID this agent exposes via `providers/*`. Claude Code has
117
+ * one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
118
+ * is exactly one configurable provider.
119
+ */
120
+ const PROVIDER_ID = "main";
121
+ /**
122
+ * Protocols the `main` provider can be configured with. These mirror the
123
+ * env-var mappings understood by {@link createEnvForProvider}.
124
+ */
125
+ const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
126
+ /**
127
+ * Advance the lexer state across the fragment appended since the last delta:
128
+ * just enough JSON awareness (string/escape, nesting depth) to spot commas
129
+ * that sit at the top level of the input object — everything before such a
130
+ * comma is a set of complete fields. Returns true once the input object's
131
+ * closing brace arrives.
132
+ */
133
+ function scanStreamedToolInput(state) {
134
+ let complete = false;
135
+ for (let index = state.scannedTo; index < state.partialJson.length; index++) {
136
+ const character = state.partialJson[index];
137
+ if (state.inString) {
138
+ if (state.escaped) {
139
+ state.escaped = false;
140
+ }
141
+ else if (character === "\\") {
142
+ state.escaped = true;
143
+ }
144
+ else if (character === '"') {
145
+ state.inString = false;
146
+ }
147
+ continue;
148
+ }
149
+ if (character === '"') {
150
+ state.inString = true;
151
+ }
152
+ else if (character === "{") {
153
+ state.objectDepth++;
154
+ }
155
+ else if (character === "}") {
156
+ state.objectDepth--;
157
+ if (state.objectDepth === 0) {
158
+ complete = true;
159
+ }
160
+ }
161
+ else if (character === "[") {
162
+ state.arrayDepth++;
163
+ }
164
+ else if (character === "]") {
165
+ state.arrayDepth--;
166
+ }
167
+ else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
168
+ state.lastTopLevelComma = index;
169
+ }
170
+ }
171
+ state.scannedTo = state.partialJson.length;
172
+ return complete;
173
+ }
174
+ /** Parse the complete top-level fields before a top-level comma by closing the
175
+ * object at that boundary. */
176
+ function recoveredToolInput(prefix) {
177
+ try {
178
+ const value = JSON.parse(prefix + "}");
179
+ return value !== null && typeof value === "object" && !Array.isArray(value)
180
+ ? value
181
+ : undefined;
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ }
74
187
  export async function claudeCliPath() {
75
188
  if (process.env.CLAUDE_CODE_EXECUTABLE) {
76
189
  return process.env.CLAUDE_CODE_EXECUTABLE;
@@ -203,6 +316,36 @@ export function stripLocalCommandMetadata(content) {
203
316
  export function isLocalCommandMetadata(content) {
204
317
  return stripLocalCommandMetadata(content) === null;
205
318
  }
319
+ /**
320
+ * True for the synthetic assistant message the CLI injects into the transcript
321
+ * when a turn fails authentication (e.g. "Not logged in · Please run /login",
322
+ * "Session expired. Please run /login to sign in again."). The `/login`
323
+ * instruction is Claude Code TUI-specific and meaningless to ACP clients
324
+ * (issue #863). The live prompt loop suppresses the text and fails the turn
325
+ * with `authRequired` so the client can run its own auth flow; replay must
326
+ * skip it too — both for parity with what the client saw live and because the
327
+ * message stays in the transcript forever, so it would resurface on every
328
+ * session/load even after the user has logged back in.
329
+ *
330
+ * Takes the API message (`message.message`), which replay only knows as
331
+ * `unknown`. The persisted record's structured `error: "authentication_failed"`
332
+ * marker is stripped by `getSessionMessages`, so the synthetic model + text is
333
+ * all both paths have to match on.
334
+ */
335
+ export function isSyntheticLoginMessage(apiMessage) {
336
+ if (!apiMessage || typeof apiMessage !== "object") {
337
+ return false;
338
+ }
339
+ const { model, content } = apiMessage;
340
+ if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
341
+ return false;
342
+ }
343
+ const block = content[0];
344
+ return (!!block &&
345
+ block.type === "text" &&
346
+ typeof block.text === "string" &&
347
+ block.text.includes("Please run /login"));
348
+ }
206
349
  const PERMISSION_MODE_ALIASES = {
207
350
  auto: "auto",
208
351
  default: "default",
@@ -316,6 +459,10 @@ export class ClaudeAcpAgent {
316
459
  clientCapabilities;
317
460
  logger;
318
461
  gatewayAuthRequest;
462
+ /** Client-managed LLM routing set via `providers/set`. Process-scoped and
463
+ * never persisted to disk (see the Configurable LLM Providers RFD). When
464
+ * set, it takes precedence over {@link gatewayAuthRequest}. */
465
+ providerConfig;
319
466
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
320
467
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
321
468
  * tests can shrink it. */
@@ -441,6 +588,10 @@ export class ClaudeAcpAgent {
441
588
  auth: {
442
589
  logout: {},
443
590
  },
591
+ // Client-managed LLM routing via `providers/list`, `providers/set`, and
592
+ // `providers/disable`. Advertised unconditionally; there is no client
593
+ // capability prerequisite for the provider methods.
594
+ providers: {},
444
595
  loadSession: true,
445
596
  sessionCapabilities: {
446
597
  additionalDirectories: {},
@@ -564,11 +715,94 @@ export class ClaudeAcpAgent {
564
715
  }
565
716
  throw new Error("Method not implemented.");
566
717
  }
718
+ /**
719
+ * `providers/list` — returns the single client-configurable custom gateway
720
+ * provider (`main`). `current` carries only non-secret routing (never headers,
721
+ * which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
722
+ * display, and is `null` when the provider is not configured/disabled. The
723
+ * provider is optional (`required: false`): while disabled/unconfigured the
724
+ * agent falls back to its own default routing (normal Claude login).
725
+ */
726
+ async unstable_listProviders(_params) {
727
+ const config = this.resolveProviderConfig();
728
+ const provider = {
729
+ providerId: PROVIDER_ID,
730
+ supported: SUPPORTED_PROTOCOLS,
731
+ required: false,
732
+ current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
733
+ };
734
+ return { providers: [provider] };
735
+ }
736
+ /**
737
+ * `providers/set` — replace the full configuration for the `main` provider.
738
+ * Rejects unknown IDs, unsupported protocols, and empty/invalid base URLs with
739
+ * `invalid_params`. Config is process-scoped and applies to sessions created or
740
+ * loaded after this call.
741
+ */
742
+ async unstable_setProvider(params) {
743
+ if (params.providerId !== PROVIDER_ID) {
744
+ throw RequestError.invalidParams({ providerId: params.providerId }, `Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`);
745
+ }
746
+ if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
747
+ throw RequestError.invalidParams({ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS }, `Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`);
748
+ }
749
+ if (!isValidBaseUrl(params.baseUrl)) {
750
+ throw RequestError.invalidParams({ baseUrl: params.baseUrl }, "baseUrl must be a non-empty absolute http(s) URL.");
751
+ }
752
+ const config = {
753
+ apiType: params.apiType,
754
+ baseUrl: params.baseUrl,
755
+ headers: params.headers ?? {},
756
+ };
757
+ // Vertex requires project + region, which the standard payload cannot
758
+ // carry, so they arrive via `_meta.claudeCode.vertex`.
759
+ if (params.apiType === "vertex") {
760
+ const vertex = params._meta?.claudeCode?.vertex;
761
+ if (!vertex ||
762
+ typeof vertex.projectId !== "string" ||
763
+ vertex.projectId.trim() === "" ||
764
+ typeof vertex.region !== "string" ||
765
+ vertex.region.trim() === "") {
766
+ throw RequestError.invalidParams(undefined, "vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.");
767
+ }
768
+ config.vertex = { projectId: vertex.projectId, region: vertex.region };
769
+ }
770
+ this.providerConfig = config;
771
+ return {};
772
+ }
773
+ /**
774
+ * `providers/disable` — disabling the `main` provider clears any client-managed
775
+ * routing (both a `providers/set` config and the legacy gateway auth request),
776
+ * so the agent reverts to its own default routing and `providers/list` reports
777
+ * `current: null`. Disabling any other (unknown) ID is treated as a successful
778
+ * no-op per the RFD's idempotency rule.
779
+ */
780
+ async unstable_disableProvider(params) {
781
+ if (params.providerId === PROVIDER_ID) {
782
+ this.providerConfig = undefined;
783
+ this.gatewayAuthRequest = undefined;
784
+ }
785
+ // Unknown provider: idempotent success.
786
+ return {};
787
+ }
788
+ /**
789
+ * Resolve the effective client-managed routing config. `providers/set` takes
790
+ * precedence; otherwise fall back to the legacy gateway auth request. Returns
791
+ * `null` when neither is configured.
792
+ */
793
+ resolveProviderConfig() {
794
+ if (this.providerConfig) {
795
+ return this.providerConfig;
796
+ }
797
+ return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
798
+ }
567
799
  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.
800
+ // Clear in-memory gateway credentials supplied via `authenticate` and any
801
+ // provider routing set via `providers/set`. Neither touches the on-disk
802
+ // credential store, so dropping these references is the whole logout for
803
+ // those paths.
571
804
  this.gatewayAuthRequest = undefined;
805
+ this.providerConfig = undefined;
572
806
  // For the Claude/Console login methods the credentials live in the native
573
807
  // CLI's store (keychain or config dir), which only the binary can clear.
574
808
  // `claude auth logout` is non-interactive and idempotent.
@@ -778,23 +1012,34 @@ export class ClaudeAcpAgent {
778
1012
  // gateways that don't carry a stable/matching id across the stream and the
779
1013
  // consolidated message. Reset after each consolidated message consumes it.
780
1014
  const streamedBlocks = [];
1015
+ // Tool-use blocks start streaming before their JSON input. Keep the
1016
+ // partial input per parent message and block index so completed top-level
1017
+ // fields can refine the pending tool call while it streams. Entries are
1018
+ // dropped at block/message boundaries; the whole map is swept when a turn
1019
+ // settles, since an interrupted subagent stream (keyed by a
1020
+ // parent_tool_use_id that never recurs) has no boundary event of its own.
1021
+ const streamedToolInputs = new Map();
781
1022
  // Stop reason accumulated for the active turn (result subtype, refusal,
782
1023
  // max_tokens, …). Reset per turn; read when the turn settles at idle.
783
1024
  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;
1025
+ /** The consumer's single send chokepoint: every `sessionUpdate` in this
1026
+ * loop goes through here (never `this.client.sessionUpdate` directly) so
1027
+ * answer-delivery tracking is a property of sending, not something each
1028
+ * emission site must remember. A top-level `agent_message_chunk` marks
1029
+ * the stretch's answer as delivered; subagent-attributed chunks are
1030
+ * recognizable by the `parentToolUseId` meta that toAcpNotifications
1031
+ * stamps from `parent_tool_use_id`, and never reach the top-level feed
1032
+ * as the turn's answer. */
1033
+ const sendUpdate = async (notification) => {
1034
+ const { update } = notification;
1035
+ if (update.sessionUpdate === "agent_message_chunk") {
1036
+ const claudeMeta = update._meta?.claudeCode;
1037
+ if (!claudeMeta?.parentToolUseId) {
1038
+ session.emittedAssistantText = true;
1039
+ }
1040
+ }
1041
+ await this.client.sessionUpdate(notification);
1042
+ };
798
1043
  const resetTurnScratch = () => {
799
1044
  lastAssistantTotalUsage = null;
800
1045
  lastAssistantUsage = null;
@@ -830,6 +1075,31 @@ export class ClaudeAcpAgent {
830
1075
  session.activeTurn = turn;
831
1076
  session.cancelled = false;
832
1077
  session.pendingOrphanResults = 0;
1078
+ session.orphanCommands?.clear();
1079
+ // Two-phase sweep of registry entries the level signal ended (see
1080
+ // the endedPerLevel field doc): armed at the first activation,
1081
+ // deleted at the second — the same activation-time self-heal as the
1082
+ // orphan lanes, and the growth bound for leaked entries whose settle
1083
+ // bookends never arrive. The one-activation grace lets a corrective
1084
+ // inclusive level rescue a live async agent that a racing payload
1085
+ // absent-marked (deletion is irreversible: levels never ADD entries).
1086
+ // Local-only commands don't advance the clock: two quick /context
1087
+ // calls would otherwise burn the whole grace in seconds of wall time
1088
+ // while the corrective level is still in flight, and they interact
1089
+ // with no tasks — a later real turn still bounds growth.
1090
+ if (!turn.isLocalOnlyCommand) {
1091
+ for (const [taskId, record] of session.liveBackgroundTasks) {
1092
+ if (!record.endedPerLevel) {
1093
+ continue;
1094
+ }
1095
+ if (record.endedPerLevel === "sweep-armed") {
1096
+ session.liveBackgroundTasks.delete(taskId);
1097
+ }
1098
+ else {
1099
+ record.endedPerLevel = "sweep-armed";
1100
+ }
1101
+ }
1102
+ }
833
1103
  resetTurnScratch();
834
1104
  };
835
1105
  /** Ensure there is an active turn before a user-turn result that carries no
@@ -849,18 +1119,165 @@ export class ClaudeAcpAgent {
849
1119
  * result), so we skip those and only promote once the count is drained. */
850
1120
  const ensureActiveTurn = () => {
851
1121
  if (session.activeTurn) {
852
- return;
853
- }
854
- const head = (session.turnQueue ?? []).find((t) => !t.settled);
855
- if (!head) {
856
- return;
1122
+ if (!isHeldOpen(session.activeTurn)) {
1123
+ return;
1124
+ }
1125
+ // A held turn (Turn.deferredSettle) already produced its result, so
1126
+ // this incoming user-turn result cannot be its — it belongs to the
1127
+ // next queued command (an echo-less one, e.g. `/context` sent while
1128
+ // the hold drains; a normal prompt's echo would have handed the held
1129
+ // turn off before its result). Settle the held turn with its
1130
+ // recorded outcome — the user moving on outranks the hold, same
1131
+ // contract as the echo hand-off — and fall through to promote the
1132
+ // queue head, which this result belongs to. Without this, the head
1133
+ // would never be promoted (echo-less turns have no other promotion
1134
+ // path) and its prompt would hang, while this result's outcome
1135
+ // overwrote the held turn's. Orphan lanes below are necessarily
1136
+ // empty while a turn is held: orphans are seeded by cancel(), which
1137
+ // inline-settles a held turn, and activation cleared older ones.
1138
+ // settleActive also closes the held turn's delivery stretch, so the
1139
+ // promoted command's own delivery decision is not judged against the
1140
+ // held turn's followup text (issue #453) — the caller snapshots the
1141
+ // flag AFTER this runs.
1142
+ settleActive(session.activeTurn.deferredSettle);
857
1143
  }
1144
+ // Orphan accounting runs BEFORE the head check: an orphan's echo-less
1145
+ // result can arrive with an EMPTY queue (the common post-cancel
1146
+ // timeline — the active turn settled at the interrupt's idle and the
1147
+ // user hasn't typed yet), and it must still be consumed here. Skipping
1148
+ // the bookkeeping when there is nothing to promote would leave a
1149
+ // phantom entry/count that swallows the next live echo-less result
1150
+ // (e.g. /compact) instead.
858
1151
  if ((session.pendingOrphanResults ?? 0) > 0) {
859
1152
  session.pendingOrphanResults--;
860
1153
  return;
861
1154
  }
1155
+ // msg_lifecycle_v1 lane. Attribute this echo-less result using the
1156
+ // entries' states — turns run sequentially and frames arrive in stream
1157
+ // order, so at any result: every "zombie" is from an already-dead turn
1158
+ // whose own result already passed before the frame that created the
1159
+ // newest entry (or never existed), every "started" entry was dispatched
1160
+ // into THE turn that emitted this result (an older turn's entries got
1161
+ // their terminal frames before a newer turn's "started" frames), and a
1162
+ // "pending" entry was not dispatched before it. One result therefore
1163
+ // covers ALL started and zombie entries at once (N coalesced commands
1164
+ // share ONE result); their outstanding terminal frames then no-op on
1165
+ // the missing entries. NOTE this ordering argument is asserted from
1166
+ // observed CLI behavior, not a documented wire contract — if a dead
1167
+ // turn's late result could lag past the NEXT turn's dispatch frames,
1168
+ // deleting a zombie and a started entry on one result would
1169
+ // double-consume it. The unexpected-transition logging in the frame
1170
+ // handler is the tripwire for that class of drift.
1171
+ if (session.orphanCommands?.size) {
1172
+ let consumedOrphanResult = false;
1173
+ let oldestPending;
1174
+ for (const [uuid, state] of session.orphanCommands) {
1175
+ if (state === "started" || state === "zombie") {
1176
+ consumedOrphanResult = true;
1177
+ session.orphanCommands.delete(uuid);
1178
+ }
1179
+ else {
1180
+ oldestPending ??= uuid;
1181
+ }
1182
+ }
1183
+ if (consumedOrphanResult) {
1184
+ return;
1185
+ }
1186
+ if (oldestPending !== undefined) {
1187
+ // No dispatch was seen before this result, so it is very likely a
1188
+ // live turn's — but a lost "started" frame would mean it IS the
1189
+ // orphan's (dup-over-loss: prefer one wrong skip over
1190
+ // misattributing a dead turn's outcome to a live prompt). Grant
1191
+ // each pending entry exactly one skip, like the count lane did.
1192
+ session.orphanCommands.delete(oldestPending);
1193
+ return;
1194
+ }
1195
+ }
1196
+ const head = firstUnsettledQueuedTurn();
1197
+ if (!head) {
1198
+ return;
1199
+ }
862
1200
  activateTurn(head);
863
1201
  };
1202
+ /** Result-time bookkeeping that must run whether or not the result can be
1203
+ * attributed to a turn. (1) Latch `commandResultSeen` on every queued
1204
+ * turn whose command is known dispatched with no terminal frame yet —
1205
+ * the emitting turn is the one it was dispatched (possibly folded) into,
1206
+ * so its result has now passed; a later cancel() must not seed an orphan
1207
+ * entry that waits for it (see Turn.commandResultSeen). (2) When a turn
1208
+ * is ACTIVE, the result is attributed to it and never reaches
1209
+ * ensureActiveTurn — but it still covers the map's started entries
1210
+ * (commands folded into the active turn share its result) and zombies
1211
+ * (their late results have already passed or never existed), so drain
1212
+ * them here or they would zombify/linger and swallow a later live
1213
+ * echo-less result. */
1214
+ const recordResultForOrphanCommands = () => {
1215
+ for (const turn of session.turnQueue ?? []) {
1216
+ if (!turn.settled && turn.commandStarted && !turn.commandFinished) {
1217
+ turn.commandResultSeen = true;
1218
+ }
1219
+ }
1220
+ if (session.activeTurn && session.orphanCommands?.size) {
1221
+ for (const [uuid, state] of session.orphanCommands) {
1222
+ if (state === "started" || state === "zombie") {
1223
+ session.orphanCommands.delete(uuid);
1224
+ }
1225
+ }
1226
+ }
1227
+ };
1228
+ /** The unsettled in-flight turn owning this prompt uuid, if any. */
1229
+ const findUnsettledTurn = (uuid) => (session.turnQueue ?? []).find((t) => t.promptUuid === uuid && !t.settled);
1230
+ /** The first queued turn still awaiting its outcome, if any — the single
1231
+ * spelling of "a prompt is pending" shared by the head promotion and
1232
+ * the autonomous stretch-close guard. */
1233
+ const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
1234
+ /** Whether any background subagent this turn spawned is still live —
1235
+ * while true, the turn's settlement stays deferred so the subagent's
1236
+ * output and permission requests land inside it (see
1237
+ * Turn.deferredSettle). */
1238
+ const turnAwaitingSubagents = (turn) => {
1239
+ if (!turn.spawnedTaskIds?.size) {
1240
+ return false;
1241
+ }
1242
+ for (const taskId of turn.spawnedTaskIds) {
1243
+ const record = session.liveBackgroundTasks.get(taskId);
1244
+ // The isSubagent read is defense in depth for the shells-never-defer
1245
+ // contract: spawnedTaskIds only ever holds subagent ids today, but a
1246
+ // future add site must not silently let a long-lived shell hold a
1247
+ // prompt open. endedPerLevel entries are kept for attribution only —
1248
+ // the level signal says the task is gone (or its bookends were
1249
+ // lost), so a hold must not wait on them.
1250
+ if (record?.isSubagent && !record.endedPerLevel) {
1251
+ return true;
1252
+ }
1253
+ }
1254
+ return false;
1255
+ };
1256
+ /** Settle the active turn's stored deferred outcome once none of its
1257
+ * spawned subagents is live. The single drain rule shared by the
1258
+ * followup-result and idle settle sites, so the two lanes can't drift. */
1259
+ const settleDeferredIfDrained = () => {
1260
+ const turn = session.activeTurn;
1261
+ if (isHeldOpen(turn) && !turnAwaitingSubagents(turn)) {
1262
+ settleActive(turn.deferredSettle);
1263
+ }
1264
+ };
1265
+ /** Settle the active turn with `outcome` now — unless subagents it
1266
+ * spawned are still live, in which case store the outcome and hold the
1267
+ * turn open (see Turn.deferredSettle). Every result-time settle of a
1268
+ * turn that can have spawned subagents must route through here: a site
1269
+ * calling settleActive directly bypasses the hold and re-opens the
1270
+ * out-of-turn permission deadlock (issue #866) through its lane. */
1271
+ const settleOrDefer = (outcome) => {
1272
+ if (session.activeTurn &&
1273
+ !session.activeTurn.settled &&
1274
+ turnAwaitingSubagents(session.activeTurn)) {
1275
+ session.activeTurn.deferredSettle = outcome;
1276
+ }
1277
+ else {
1278
+ settleActive(outcome);
1279
+ }
1280
+ };
864
1281
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
865
1282
  * backstop (the turn is over), and drop it from the queue. */
866
1283
  const settleActive = (result) => {
@@ -868,22 +1285,34 @@ export class ClaudeAcpAgent {
868
1285
  if (!turn || turn.settled) {
869
1286
  return;
870
1287
  }
1288
+ // Captured before the settled flip below (isHeldOpen tests !settled).
1289
+ const wasHeld = isHeldOpen(turn);
871
1290
  turn.settled = true;
872
- if (session.forceCancelTimer) {
873
- clearTimeout(session.forceCancelTimer);
874
- session.forceCancelTimer = undefined;
875
- }
1291
+ disarmForceCancel(session);
876
1292
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
877
1293
  session.activeTurn = null;
1294
+ streamedToolInputs.clear();
1295
+ if (wasHeld) {
1296
+ // Settling a held turn is its delivery-stretch boundary: the turn's
1297
+ // answer finished long ago, so text streamed since the last boundary
1298
+ // is normally its followups' — left latched it would suppress a
1299
+ // following replayed turn's issue-#453 result-text fallback (the
1300
+ // common post-hold sequence). Known trade: at the echo hand-off an
1301
+ // incoming turn's pre-echo deltas share this one boolean, so a
1302
+ // STREAMING replay on a usage-omitting backend could re-emit its
1303
+ // answer — the flag cannot attribute text to a turn before its
1304
+ // echo, and the suppression direction is the common one, so the
1305
+ // clear wins. Every held-settle lane inherits this: the drain
1306
+ // settle, both hand-offs, and stream-done; cancel()'s inline mirror
1307
+ // carries its own copy.
1308
+ session.emittedAssistantText = false;
1309
+ }
878
1310
  turn.resolve(result);
879
1311
  };
880
1312
  /** Reject the active turn (auth required, error result, …) without tearing
881
1313
  * down the consumer: the stream continues to idle and later turns proceed. */
882
1314
  const failActive = (error) => {
883
- if (session.forceCancelTimer) {
884
- clearTimeout(session.forceCancelTimer);
885
- session.forceCancelTimer = undefined;
886
- }
1315
+ disarmForceCancel(session);
887
1316
  const turn = session.activeTurn;
888
1317
  if (!turn || turn.settled) {
889
1318
  return;
@@ -891,14 +1320,17 @@ export class ClaudeAcpAgent {
891
1320
  turn.settled = true;
892
1321
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
893
1322
  session.activeTurn = null;
1323
+ streamedToolInputs.clear();
1324
+ // A failed turn's stretch is over, and some failure lanes (the issue
1325
+ // #825 idle-fail) never see the result whose `finally` would close it —
1326
+ // start the next stretch clean, or its stale delivery record would
1327
+ // suppress the next turn's issue-#453 result-text fallback.
1328
+ session.emittedAssistantText = false;
894
1329
  turn.reject(error);
895
1330
  };
896
1331
  /** Reject every in-flight turn — used when the stream dies. */
897
1332
  const failAllTurns = (error) => {
898
- if (session.forceCancelTimer) {
899
- clearTimeout(session.forceCancelTimer);
900
- session.forceCancelTimer = undefined;
901
- }
1333
+ disarmForceCancel(session);
902
1334
  const turns = session.activeTurn
903
1335
  ? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
904
1336
  : [...(session.turnQueue ?? [])];
@@ -906,8 +1338,18 @@ export class ClaudeAcpAgent {
906
1338
  session.turnQueue = [];
907
1339
  for (const turn of turns) {
908
1340
  if (!turn.settled) {
1341
+ const wasHeld = isHeldOpen(turn);
909
1342
  turn.settled = true;
910
- turn.reject(error);
1343
+ if (wasHeld) {
1344
+ // A held turn's answer already streamed and its outcome is
1345
+ // recorded — a stream death during the post-answer hold is a
1346
+ // background failure, not the turn's. Resolve with the real
1347
+ // outcome, mirroring the stream-done path.
1348
+ turn.resolve(turn.deferredSettle);
1349
+ }
1350
+ else {
1351
+ turn.reject(error);
1352
+ }
911
1353
  }
912
1354
  }
913
1355
  };
@@ -961,9 +1403,45 @@ export class ClaudeAcpAgent {
961
1403
  // turn being abandoned. Stale counts self-heal: activation resets
962
1404
  // them (see activateTurn).
963
1405
  if (session.activeTurn && !session.activeTurn.settled) {
964
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
1406
+ // Seed by what the frames already told us, mirroring cancel()'s
1407
+ // queued-turn sweep — the consumer may have drained the wedged
1408
+ // turn's result and/or terminal frame before the backstop fired,
1409
+ // and an entry seeded for a result or frame that is already
1410
+ // spent would never drain (it would swallow an unrelated later
1411
+ // echo-less result instead).
1412
+ const active = session.activeTurn;
1413
+ if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
1414
+ // Finished SDK-side; any result already passed. Nothing to
1415
+ // track.
1416
+ }
1417
+ else if (active.commandFinished === "cancelled") {
1418
+ // Aborted after dispatch: its late result may still come —
1419
+ // unless it already did.
1420
+ if (!active.commandResultSeen) {
1421
+ this.trackOrphanCommand(session, active.promptUuid, "zombie");
1422
+ }
1423
+ }
1424
+ else if (active.commandResultSeen) {
1425
+ // Its result was already consumed (dropped at the cancelled
1426
+ // guard); only the terminal frame is outstanding, which no-ops
1427
+ // with no entry. Nothing to track.
1428
+ }
1429
+ else {
1430
+ // The wedged turn WAS dispatched (it's active), so track it
1431
+ // "started": its late result (if the SDK recovers) is skipped
1432
+ // echo-less, and its terminal frame — or that skip plus
1433
+ // activation's clear when the frame is lost to the wedge — is
1434
+ // what drains it.
1435
+ this.trackOrphanCommand(session, active.promptUuid, "started");
1436
+ }
965
1437
  }
966
- settleActive({ stopReason: "cancelled" });
1438
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1439
+ // The cancelled turn's result may never come (that's why the
1440
+ // backstop fired) — close its delivery stretch here so partial
1441
+ // streamed text can't suppress the next turn's issue-#453 fallback.
1442
+ // If a late orphan result does arrive, its `finally` clears again;
1443
+ // FIFO ordering means no live turn's text can have streamed yet.
1444
+ session.emittedAssistantText = false;
967
1445
  // If the session is being torn down — or this consumer was
968
1446
  // superseded by a lazy query recreate — abandon the in-flight
969
1447
  // next() (swallowing any later rejection so it can't surface as
@@ -999,10 +1477,15 @@ export class ClaudeAcpAgent {
999
1477
  // turn's real outcome.
1000
1478
  //
1001
1479
  // Settle the turn that was in flight so its prompt() doesn't hang:
1002
- // cancelled if a cancel is pending, otherwise the accumulated outcome.
1480
+ // cancelled if a cancel is pending, otherwise the outcome a
1481
+ // deferred turn already recorded (see Turn.deferredSettle) or the
1482
+ // accumulated scratch outcome. The scratch currently still equals
1483
+ // a deferred turn's stored outcome (followup results never mutate
1484
+ // it), but the stored one is the authoritative source.
1485
+ const inFlight = session.activeTurn;
1003
1486
  settleActive(session.cancelled
1004
- ? { stopReason: "cancelled" }
1005
- : { stopReason, usage: sessionUsage(session) });
1487
+ ? { stopReason: "cancelled", usage: sessionUsage(session) }
1488
+ : (inFlight?.deferredSettle ?? { stopReason, usage: sessionUsage(session) }));
1006
1489
  // Queued turns the SDK never started never ran, so reject them rather
1007
1490
  // than reporting a success (end_turn) — or a misleading "cancelled" —
1008
1491
  // for a prompt that produced no output. (A cancel already settled the
@@ -1029,19 +1512,116 @@ export class ClaudeAcpAgent {
1029
1512
  message: message,
1030
1513
  });
1031
1514
  }
1515
+ // CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
1516
+ // uuid-stamped queued command (queued/started/completed/cancelled/
1517
+ // discarded) as `command_lifecycle` frames — 2-3 per prompt, since
1518
+ // prompt() stamps a uuid on every message. The frame is @internal and
1519
+ // absent from the SDKMessage union, so handle it BEFORE the exhaustive
1520
+ // switch: it must not reach `unreachable`'s error log, and a `case`
1521
+ // for it wouldn't typecheck. It feeds only the orphan accounting (see
1522
+ // Session.orphanCommands); turn settlement stays driven by
1523
+ // echoes/results/idle. (Raw-mode emission above still forwards these
1524
+ // frames.)
1525
+ if (message.type === "command_lifecycle") {
1526
+ const frame = message;
1527
+ switch (frame.state) {
1528
+ case "started": {
1529
+ // Remember dispatch on the live turn so a cancel() that orphans
1530
+ // it seeds the right state (see Turn.commandStarted)...
1531
+ const queued = findUnsettledTurn(frame.command_uuid);
1532
+ if (queued) {
1533
+ queued.commandStarted = true;
1534
+ }
1535
+ // ...and promote an already-orphaned command: once dispatched,
1536
+ // a bare `cancelled` no longer means "dropped without running".
1537
+ const state = session.orphanCommands?.get(frame.command_uuid);
1538
+ if (state === "pending") {
1539
+ session.orphanCommands.set(frame.command_uuid, "started");
1540
+ }
1541
+ else if (state === "zombie") {
1542
+ // "started" after the command's terminal frame: the ordering
1543
+ // the whole lane rests on has been violated (frames are
1544
+ // per-uuid FIFO). Surface it — a silent drift here degrades
1545
+ // into swallowed or misattributed results.
1546
+ 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.`);
1547
+ }
1548
+ break;
1549
+ }
1550
+ case "completed":
1551
+ case "discarded":
1552
+ case "cancelled": {
1553
+ // Terminal frames. Latch the fate on a still-queued turn so a
1554
+ // later cancel() doesn't seed an orphan entry for a command
1555
+ // whose one-and-only terminal frame has already been consumed
1556
+ // (nothing would ever drain that entry).
1557
+ const queued = findUnsettledTurn(frame.command_uuid);
1558
+ if (queued) {
1559
+ queued.commandFinished = frame.state;
1560
+ }
1561
+ if (frame.state === "cancelled") {
1562
+ // Ambiguous by design (dup-over-loss): dropped before
1563
+ // dispatch (no result will ever come — safe to forget) vs
1564
+ // consumed into a turn that was aborted/failed. For the
1565
+ // latter, any result the dead turn managed to emit has
1566
+ // already deleted the entry (see
1567
+ // recordResultForOrphanCommands / ensureActiveTurn), so a
1568
+ // still-"started" entry means no result was seen since
1569
+ // dispatch — it becomes a zombie for the next
1570
+ // echo-less-result skip.
1571
+ const state = session.orphanCommands?.get(frame.command_uuid);
1572
+ if (state === "pending") {
1573
+ session.orphanCommands?.delete(frame.command_uuid);
1574
+ }
1575
+ else if (state === "started") {
1576
+ session.orphanCommands?.set(frame.command_uuid, "zombie");
1577
+ }
1578
+ break;
1579
+ }
1580
+ // Exactly-one-terminal: the command is finished. "completed" is
1581
+ // emitted after any result its turn produced (fresh turn) or the
1582
+ // command folded into another turn whose result is attributed
1583
+ // elsewhere — either way no echo-less result remains to skip.
1584
+ // "discarded" = session ended with it still queued; no result.
1585
+ session.orphanCommands?.delete(frame.command_uuid);
1586
+ break;
1587
+ }
1588
+ default:
1589
+ // "queued" carries no fate information. Anything else is a
1590
+ // state this adapter doesn't know — likely a CLI that grew the
1591
+ // v1 vocabulary. The entry still drains by result coverage or
1592
+ // activation's clear (bounded damage), but log it so the
1593
+ // degradation is visible instead of silent.
1594
+ if (frame.state !== "queued") {
1595
+ this.logger.error(`Session ${params.sessionId}: unknown command_lifecycle state "${frame.state}" for ${frame.command_uuid}; treating as uninformative.`);
1596
+ }
1597
+ break;
1598
+ }
1599
+ continue;
1600
+ }
1032
1601
  switch (message.type) {
1033
1602
  case "system":
1034
1603
  switch (message.subtype) {
1035
1604
  case "init":
1605
+ // Latch the lifecycle capability so cancel() routes orphan
1606
+ // accounting through `orphanCommands` (per-uuid, exact)
1607
+ // instead of the coalescing-blind count. Never unlatch: init
1608
+ // re-emits per turn and the capability can't be lost mid-CLI.
1609
+ if (message.capabilities?.includes("msg_lifecycle_v1")) {
1610
+ session.msgLifecycleV1 = true;
1611
+ }
1036
1612
  // A fresh `system`/init (e.g. after reinitialize) can carry an
1037
1613
  // updated Fast mode state; reconcile it with what we seeded at
1038
1614
  // session creation.
1039
1615
  await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
1040
1616
  break;
1041
1617
  case "status": {
1618
+ // These banners count as delivered text (via sendUpdate), so
1619
+ // an echo-less turn that only ever emits them (e.g. `/compact`,
1620
+ // promoted at its own result) doesn't have its result text
1621
+ // re-emitted by the issue-#453 fallback.
1042
1622
  if (message.status === "compacting") {
1043
1623
  compactionInProgress = true;
1044
- await this.client.sessionUpdate({
1624
+ await sendUpdate({
1045
1625
  sessionId: message.session_id,
1046
1626
  update: {
1047
1627
  sessionUpdate: "agent_message_chunk",
@@ -1054,7 +1634,7 @@ export class ClaudeAcpAgent {
1054
1634
  // message carrying `compact_result`, not the `compact_boundary`
1055
1635
  // message (which only fires when there's content to compact).
1056
1636
  compactionInProgress = false;
1057
- await this.client.sessionUpdate({
1637
+ await sendUpdate({
1058
1638
  sessionId: message.session_id,
1059
1639
  update: {
1060
1640
  sessionUpdate: "agent_message_chunk",
@@ -1065,7 +1645,7 @@ export class ClaudeAcpAgent {
1065
1645
  else if (message.compact_result === "failed" && compactionInProgress) {
1066
1646
  compactionInProgress = false;
1067
1647
  const reason = message.compact_error ? `: ${message.compact_error}` : ".";
1068
- await this.client.sessionUpdate({
1648
+ await sendUpdate({
1069
1649
  sessionId: message.session_id,
1070
1650
  update: {
1071
1651
  sessionUpdate: "agent_message_chunk",
@@ -1090,9 +1670,9 @@ export class ClaudeAcpAgent {
1090
1670
  // dropped dramatically) and replaced within seconds by the next
1091
1671
  // result message.
1092
1672
  //
1093
- // `size` keeps coming from session.contextWindowSize (learned
1094
- // from modelUsage / the model heuristic) getContextUsage's
1095
- // window field under-reports extended 1M windows.
1673
+ // `size` keeps coming from session.contextWindowSize
1674
+ // compaction frees occupancy, it doesn't change the model's
1675
+ // window.
1096
1676
  //
1097
1677
  // The "Compacting completed." text is emitted from the `status`
1098
1678
  // handler (keyed on `compact_result`), not here, so the failure
@@ -1100,7 +1680,7 @@ export class ClaudeAcpAgent {
1100
1680
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
1101
1681
  lastAssistantUsage = null;
1102
1682
  lastAssistantTotalUsage = usedTokens ?? 0;
1103
- await this.client.sessionUpdate({
1683
+ await sendUpdate({
1104
1684
  sessionId: message.session_id,
1105
1685
  update: {
1106
1686
  sessionUpdate: "usage_update",
@@ -1111,7 +1691,7 @@ export class ClaudeAcpAgent {
1111
1691
  break;
1112
1692
  }
1113
1693
  case "local_command_output": {
1114
- await this.client.sessionUpdate({
1694
+ await sendUpdate({
1115
1695
  sessionId: message.session_id,
1116
1696
  update: {
1117
1697
  sessionUpdate: "agent_message_chunk",
@@ -1121,6 +1701,7 @@ export class ClaudeAcpAgent {
1121
1701
  break;
1122
1702
  }
1123
1703
  case "session_state_changed": {
1704
+ session.lastSessionState = message.state;
1124
1705
  if (message.state === "idle") {
1125
1706
  // A non-cancelled turn normally settled at its terminal
1126
1707
  // `result` already (issue #773), and that result recorded an
@@ -1145,16 +1726,46 @@ export class ClaudeAcpAgent {
1145
1726
  // the turn NOW so its session/prompt gets a terminal
1146
1727
  // response, instead of leaving it hanging until the next
1147
1728
  // prompt drains the wreckage.
1729
+ // A cancelled turn still consumed tokens: its dropped result
1730
+ // already fed the accumulator (the usage tally at the result
1731
+ // handler runs before the `session.cancelled` guard), so
1732
+ // report it — clients metering spend would otherwise lose
1733
+ // the interrupted turn's tokens entirely (issue #844). Zero
1734
+ // when the cancel pre-empted the result (wedge/force-cancel).
1148
1735
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
1149
- settleActive({ stopReason: "cancelled" });
1736
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1737
+ // An interrupt can pre-empt the turn's result entirely
1738
+ // (nothing ran the result-case `finally`), so close the
1739
+ // delivery stretch here: idle is the SDK's authoritative
1740
+ // turn-over signal, and stale partial-text state would
1741
+ // suppress the next turn's issue-#453 fallback.
1742
+ session.emittedAssistantText = false;
1743
+ }
1744
+ else if (isHeldOpen(session.activeTurn)) {
1745
+ // A turn held open for its background subagents (see
1746
+ // Turn.deferredSettle). Idles keep their normal cadence
1747
+ // during the hold — the CLI emits one per processing
1748
+ // cycle (the turn's own trailer, then one per followup),
1749
+ // NOT one final "all drained" signal — so each one
1750
+ // absorbs an outstanding trailer debt, and the turn only
1751
+ // settles once none of its spawned subagents is left
1752
+ // (the followup-result settle usually got there first;
1753
+ // this is the fallback when no followup came). Mid-hold
1754
+ // idles never fall through: a held turn HAS its result,
1755
+ // so reading its idle as "turn abandoned without a
1756
+ // result" (issue #825) would fail a healthy prompt.
1757
+ if (session.owedTrailingIdles > 0) {
1758
+ session.owedTrailingIdles--;
1759
+ }
1760
+ settleDeferredIfDrained();
1150
1761
  }
1151
- else if (owedTrailingIdles > 0) {
1762
+ else if (session.owedTrailingIdles > 0) {
1152
1763
  // Absorb a settled turn's trailing idle. Also covers a
1153
1764
  // cancel that landed between a turn's counted result and
1154
1765
  // this lagged idle (no active turn to settle): the idle
1155
1766
  // still belongs to that settled turn, and skipping the
1156
1767
  // decrement would leak the debt permanently.
1157
- owedTrailingIdles--;
1768
+ session.owedTrailingIdles--;
1158
1769
  }
1159
1770
  else if (!session.cancelled &&
1160
1771
  session.activeTurn &&
@@ -1196,7 +1807,7 @@ export class ClaudeAcpAgent {
1196
1807
  const title = isSynthesis
1197
1808
  ? "Recalled synthesized memory"
1198
1809
  : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
1199
- await this.client.sessionUpdate({
1810
+ await sendUpdate({
1200
1811
  sessionId: message.session_id,
1201
1812
  update: {
1202
1813
  sessionUpdate: "tool_call",
@@ -1223,7 +1834,7 @@ export class ClaudeAcpAgent {
1223
1834
  // list with this payload: supportedCommands() is captured once
1224
1835
  // at initialize and never reflects mid-session changes, so we
1225
1836
  // forward message.commands directly rather than re-querying.
1226
- await this.client.sessionUpdate({
1837
+ await sendUpdate({
1227
1838
  sessionId: message.session_id,
1228
1839
  update: {
1229
1840
  sessionUpdate: "available_commands_update",
@@ -1247,7 +1858,7 @@ export class ClaudeAcpAgent {
1247
1858
  // rejection reason — otherwise the client shows a tool call
1248
1859
  // that silently never resolves.
1249
1860
  const reason = message.decision_reason ?? message.message;
1250
- await this.client.sessionUpdate({
1861
+ await sendUpdate({
1251
1862
  sessionId: message.session_id,
1252
1863
  update: {
1253
1864
  sessionUpdate: "tool_call_update",
@@ -1279,10 +1890,14 @@ export class ClaudeAcpAgent {
1279
1890
  // instead of a silent stop. ACP's agent_message_chunk has no
1280
1891
  // severity field, so fold the level into the text for the more
1281
1892
  // prominent levels ('info' is transcript-only noise — leave plain).
1893
+ // Sending via sendUpdate also marks the notice as this stretch's
1894
+ // delivered text: a hook-blocked turn's result repeats the block
1895
+ // reason with zero output tokens, and the issue-#453 fallback
1896
+ // must not emit it a second time.
1282
1897
  const text = message.level === "info"
1283
1898
  ? message.content
1284
1899
  : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
1285
- await this.client.sessionUpdate({
1900
+ await sendUpdate({
1286
1901
  sessionId: message.session_id,
1287
1902
  update: {
1288
1903
  sessionUpdate: "agent_message_chunk",
@@ -1295,16 +1910,51 @@ export class ClaudeAcpAgent {
1295
1910
  case "hook_progress":
1296
1911
  case "hook_response":
1297
1912
  case "files_persisted":
1913
+ case "task_progress":
1914
+ break;
1298
1915
  case "task_started":
1916
+ // For subagent tasks `task_id` is the subagent's agent id (the
1917
+ // SDK keys its task registry by agent id) and `tool_use_id` is
1918
+ // the Agent/Task tool_use that spawned it — recorded so the
1919
+ // subagent's permission requests, which reach canUseTool with
1920
+ // only `agentID`, can attribute their eagerly-emitted
1921
+ // tool_call to the parent tool call. Non-subagent tasks (e.g.
1922
+ // background Bash) land here too; their task_ids never match
1923
+ // an agentID, so those entries are inert for attribution.
1924
+ //
1925
+ // `isSubagent` marks Task/Agent-tool subagents — the tasks
1926
+ // whose completion wakes the model for a followup, so the
1927
+ // ones worth deferring turn settlement for. A sync subagent
1928
+ // is pruned (terminal task_updated) before its turn's result
1929
+ // can arrive, so registry membership at result time means an
1930
+ // async subagent. Their spawn is also recorded on the active
1931
+ // turn: a turn only ever waits on its own subagents, and a
1932
+ // spawn during a held-open drain window (an agent chain)
1933
+ // extends that turn's hold.
1934
+ session.liveBackgroundTasks.set(message.task_id, {
1935
+ parentToolUseId: message.tool_use_id,
1936
+ isSubagent: !!message.subagent_type,
1937
+ });
1938
+ if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
1939
+ (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
1940
+ }
1941
+ break;
1299
1942
  case "task_notification":
1300
- case "task_progress":
1943
+ // The task settled — no further tool calls can originate
1944
+ // from it, so its registry entry can be dropped.
1945
+ session.liveBackgroundTasks.delete(message.task_id);
1946
+ break;
1301
1947
  case "task_updated":
1302
- case "background_tasks_changed":
1303
- case "control_request_progress":
1304
- // `background_tasks_changed` is a level signal of live background
1305
- // tasks; this adapter drives lifecycle off the task edge bookends.
1306
- // `control_request_progress` only reports on side_question control
1307
- // requests, which this adapter never issues.
1948
+ // terminal-status task_updated patch and a (deduplicated)
1949
+ // task_notification when a task settles, but only the patch is
1950
+ // guaranteed per transition prune on it too so the registry
1951
+ // can't grow for the session's lifetime if a notification is
1952
+ // skipped.
1953
+ if (message.patch.status === "completed" ||
1954
+ message.patch.status === "failed" ||
1955
+ message.patch.status === "killed") {
1956
+ session.liveBackgroundTasks.delete(message.task_id);
1957
+ }
1308
1958
  break;
1309
1959
  case "worker_shutting_down":
1310
1960
  // A Remote Control worker announced a graceful teardown. This is a
@@ -1356,7 +2006,7 @@ export class ClaudeAcpAgent {
1356
2006
  const outcome = persistent
1357
2007
  ? `The session will continue on ${message.fallback_model}.`
1358
2008
  : `The session stays on ${message.original_model}.`;
1359
- await this.client.sessionUpdate({
2009
+ await sendUpdate({
1360
2010
  sessionId: message.session_id,
1361
2011
  update: {
1362
2012
  sessionUpdate: "agent_message_chunk",
@@ -1390,183 +2040,334 @@ export class ClaudeAcpAgent {
1390
2040
  lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
1391
2041
  }
1392
2042
  break;
2043
+ // `control_request_progress` only reports on side_question
2044
+ // control requests, which this adapter never issues.
2045
+ case "control_request_progress":
2046
+ break;
2047
+ case "background_tasks_changed":
2048
+ // A level signal: the full live background-task set on every
2049
+ // membership change, with REPLACE semantics. Used only to
2050
+ // reconcile `liveBackgroundTasks` — dropping (or, for
2051
+ // subagent entries, unpinning) any entry whose settle
2052
+ // bookend (task_notification / terminal task_updated) was
2053
+ // lost, so a leaked subagent entry can't defer its spawning
2054
+ // turn's settlement forever. Growth of retained
2055
+ // (endedPerLevel) subagent entries is bounded by the
2056
+ // activation-time sweep in activateTurn, not here. It never
2057
+ // ADDS entries (the payload carries no attribution or
2058
+ // subagent marker), so the unspecified ordering vs. the edge
2059
+ // bookends is safe: a level that precedes its task_started
2060
+ // simply no-ops here.
2061
+ if (session.liveBackgroundTasks.size > 0) {
2062
+ const live = new Set(message.tasks.map((t) => t.task_id));
2063
+ for (const [taskId, record] of session.liveBackgroundTasks) {
2064
+ if (live.has(taskId)) {
2065
+ // The level proves the task live in the background
2066
+ // universe (e.g. a foreground agent was backgrounded
2067
+ // after an earlier absent-marking, or that marking was
2068
+ // a racing payload built before the task registered) —
2069
+ // un-end it so a hold waits on it again, and disarm
2070
+ // the activation sweep.
2071
+ record.endedPerLevel = undefined;
2072
+ continue;
2073
+ }
2074
+ if (record.isSubagent) {
2075
+ // The level's universe is BACKGROUND tasks only, so a
2076
+ // live sync (foreground) subagent is legitimately
2077
+ // absent — deleting its entry would strand its
2078
+ // permission attribution (#859). Keep the entry but
2079
+ // stop any hold from waiting on the id: an absent id
2080
+ // can equally be a leaked async entry whose settle
2081
+ // bookends were lost.
2082
+ record.endedPerLevel ??= "ended";
2083
+ }
2084
+ else {
2085
+ session.liveBackgroundTasks.delete(taskId);
2086
+ }
2087
+ }
2088
+ }
2089
+ break;
1393
2090
  default:
1394
2091
  unreachable(message, this.logger);
1395
2092
  break;
1396
2093
  }
1397
2094
  break;
1398
2095
  case "result": {
1399
- // Task-notification followups are autonomous work triggered by a
1400
- // task-notification system message, not by the user's prompt.
1401
- // They should not influence the user-turn lifecycle (stop reason,
1402
- // slash-command output forwarding) but their cost is real.
1403
- const isTaskNotification = message.origin?.kind === "task-notification";
1404
- // Reconcile the Fast mode toggle with the SDK's reported state.
1405
- // Gated to user-driven turns like every other side effect below; a
1406
- // background followup's state lands on the next user turn's result.
1407
- // Runs even when the turn errors or was cancelled.
1408
- if (!isTaskNotification) {
1409
- await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1410
- }
1411
- // A user-turn result needs an active turn so its stop reason is
1412
- // attributed and the turn settles at idle. Local-only commands carry
1413
- // no user-message echo to promote them, so do it here from the head.
1414
- // Promote BEFORE accumulating usage, since activation resets the
1415
- // accumulator — promoting after would discard this result's tokens.
1416
- if (!isTaskNotification) {
1417
- ensureActiveTurn();
1418
- }
1419
- // Every user-turn result terminates a turn (settle, reject, or
1420
- // orphan skip) and the SDK follows it with a trailing
1421
- // `session_state_changed: idle` — record the debt so the idle
1422
- // handler absorbs that idle rather than reading it as a turn the
1423
- // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
1424
- // turn's own result. It is dropped at the `session.cancelled`
1425
- // guard, and either the idle itself settles the turn (consuming
1426
- // the trailer) or the next echo's hand-off does (which records
1427
- // the debt there instead) — counting here too would double it.
1428
- // Results skipped while cancelled with NO active turn — orphaned
1429
- // queued turns the SDK still ran, or a force-cancelled turn's
1430
- // late result after the backstop settled it — get no such settle,
1431
- // so their trailers must be counted here or they'd later be read
1432
- // as the next healthy turn being abandoned and false-fail it.
1433
- if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
1434
- owedTrailingIdles++;
1435
- }
1436
- // Accumulate usage into the user turn's tally. Skip task-notification
1437
- // followups: their cost is real but is reported separately via the
1438
- // usage_update below, and `session.accumulatedUsage` is only reset on
1439
- // turn activation — so folding a task-notification result that lands
1440
- // after the next turn is active (but before it settles) would leak
1441
- // those tokens into that turn's PromptResponse.usage.
1442
- if (!isTaskNotification) {
1443
- session.accumulatedUsage.inputTokens += message.usage.input_tokens;
1444
- session.accumulatedUsage.outputTokens += message.usage.output_tokens;
1445
- session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
1446
- session.accumulatedUsage.cachedWriteTokens +=
1447
- message.usage.cache_creation_input_tokens;
1448
- }
1449
- const matchingModelUsage = lastAssistantModel
1450
- ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
1451
- : null;
1452
- // Only overwrite when we have an authoritative value — a miss
1453
- // (e.g. a turn with no top-level assistant message) would
1454
- // otherwise discard the window learned on a prior turn and
1455
- // leave the next prompt's mid-stream updates reporting 200k.
1456
- if (matchingModelUsage) {
1457
- session.contextWindowSize = matchingModelUsage.contextWindow;
1458
- }
1459
- // Send usage_update notification
1460
- if (lastAssistantTotalUsage !== null) {
1461
- await this.client.sessionUpdate({
1462
- sessionId: params.sessionId,
1463
- update: {
1464
- sessionUpdate: "usage_update",
1465
- used: lastAssistantTotalUsage,
1466
- size: session.contextWindowSize,
1467
- cost: {
1468
- amount: message.total_cost_usd,
1469
- currency: "USD",
1470
- },
1471
- ...(message.origin && {
1472
- _meta: { "_claude/origin": message.origin },
1473
- }),
1474
- },
1475
- });
1476
- }
1477
- if (session.cancelled) {
1478
- if (!isTaskNotification) {
1479
- stopReason = "cancelled";
2096
+ // A result from an autonomous cycle a task-notification
2097
+ // followup, or a peer/coordinator/observer message the model
2098
+ // handled on its own (see AUTONOMOUS_RESULT_ORIGINS) is not
2099
+ // the user's prompt's. Autonomous results must never touch the
2100
+ // user-turn lifecycle (stop reason, settles, failActive,
2101
+ // slash-command output forwarding), though their cost is real.
2102
+ const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
2103
+ try {
2104
+ // Reconcile the Fast mode toggle with the SDK's reported state.
2105
+ // Gated to user-driven turns like every other side effect below;
2106
+ // an autonomous cycle's state lands on the next user turn's
2107
+ // result. Runs even when the turn errors or was cancelled.
2108
+ if (!isAutonomousResult) {
2109
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1480
2110
  }
1481
- break;
1482
- }
1483
- // A refusal can arrive on any result subtype (and may even set
1484
- // is_error), so handle it before the subtype switch — otherwise the
1485
- // is_error throw below would surface it as an internal error. The
1486
- // refused assistant message carries no visible content, so surface
1487
- // the classifier's explanation (when available) and report ACP's
1488
- // dedicated `refusal` stop reason.
1489
- if (message.stop_reason === "refusal" && !isTaskNotification) {
1490
- if (lastRefusalExplanation) {
1491
- await this.client.sessionUpdate({
2111
+ // A user-turn result needs an active turn so its stop reason is
2112
+ // attributed and the turn settles at idle. Local-only commands carry
2113
+ // no user-message echo to promote them, so do it here from the head.
2114
+ // Promote BEFORE accumulating usage, since activation resets the
2115
+ // accumulator promoting after would discard this result's tokens.
2116
+ // The orphan bookkeeping runs first: it covers folded/zombie
2117
+ // commands whose shared or late result this is, even when the
2118
+ // result is the ACTIVE turn's (ensureActiveTurn never looks at
2119
+ // the map in that case).
2120
+ if (!isAutonomousResult) {
2121
+ recordResultForOrphanCommands();
2122
+ ensureActiveTurn();
2123
+ }
2124
+ // A result closes the stretch of output it terminates: snapshot
2125
+ // the delivery record — AFTER ensureActiveTurn, whose held-turn
2126
+ // hand-off closes the held stretch, so an echo-less command
2127
+ // promoted here is judged on its own delivery, not on the held
2128
+ // turn's followup text — and before the handling below can emit
2129
+ // anything of its own; the `finally` then clears it so every
2130
+ // exit from this case (the cancelled-guard and refusal breaks
2131
+ // included) starts the next stretch clean. Clearing up front
2132
+ // instead would let result-time emissions (refusal explanation,
2133
+ // result-text forwarding) taint the next stretch and suppress a
2134
+ // following replayed turn's fallback. Autonomous cycles run
2135
+ // alongside a user turn and must not clear its flag (they exit
2136
+ // through the early break below, which the gated `finally`
2137
+ // leaves alone).
2138
+ const deliveredAssistantText = session.emittedAssistantText;
2139
+ // Every user-turn result terminates a turn (settle, reject, or
2140
+ // orphan skip) and the SDK follows it with a trailing
2141
+ // `session_state_changed: idle` — record the debt so the idle
2142
+ // handler absorbs that idle rather than reading it as a turn the
2143
+ // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
2144
+ // turn's own result. It is dropped at the `session.cancelled`
2145
+ // guard, and either the idle itself settles the turn (consuming
2146
+ // the trailer) or the next echo's hand-off does (which records
2147
+ // the debt there instead) — counting here too would double it.
2148
+ // Results skipped while cancelled with NO active turn — orphaned
2149
+ // queued turns the SDK still ran, or a force-cancelled turn's
2150
+ // late result after the backstop settled it — get no such settle,
2151
+ // so their trailers must be counted here or they'd later be read
2152
+ // as the next healthy turn being abandoned and false-fail it.
2153
+ // Autonomous results (followups, peer/channel/coordinator
2154
+ // cycles) are counted too: each is its own processing cycle with
2155
+ // its own trailing idle, and that idle can lag past the next
2156
+ // prompt's echo — which, un-owed, would be read as the fresh
2157
+ // turn being abandoned (#825 false-fail). That lag was mostly
2158
+ // unreachable when such cycles only ran with no pending turn,
2159
+ // but a held turn settling AT a followup result unblocks the
2160
+ // client at exactly that point, making the race the common
2161
+ // case.
2162
+ // The cancelled-ACTIVE-turn exclusion applies only to that
2163
+ // turn's OWN result — a followup result arriving inside the
2164
+ // cancel window still gets its own trailer and must be counted,
2165
+ // or that idle would later false-fail the next prompt.
2166
+ if (isAutonomousResult || !session.cancelled || !session.activeTurn) {
2167
+ session.owedTrailingIdles++;
2168
+ }
2169
+ // Accumulate usage into the user turn's tally. Skip autonomous
2170
+ // results: their cost is real but is reported separately via the
2171
+ // usage_update below, and `session.accumulatedUsage` is only reset on
2172
+ // turn activation — so folding an autonomous result that lands
2173
+ // after the next turn is active (but before it settles) would leak
2174
+ // those tokens into that turn's PromptResponse.usage.
2175
+ if (!isAutonomousResult) {
2176
+ session.accumulatedUsage.inputTokens += message.usage.input_tokens;
2177
+ session.accumulatedUsage.outputTokens += message.usage.output_tokens;
2178
+ session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
2179
+ session.accumulatedUsage.cachedWriteTokens +=
2180
+ message.usage.cache_creation_input_tokens;
2181
+ }
2182
+ const matchingModelUsage = lastAssistantModel
2183
+ ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
2184
+ : null;
2185
+ // Only overwrite when we have an authoritative value — a miss
2186
+ // (e.g. a turn with no top-level assistant message) would
2187
+ // otherwise discard the window learned on a prior turn and
2188
+ // leave the next prompt's mid-stream updates reporting 200k.
2189
+ if (matchingModelUsage) {
2190
+ session.contextWindowSize = matchingModelUsage.contextWindow;
2191
+ }
2192
+ // Send usage_update notification
2193
+ if (lastAssistantTotalUsage !== null) {
2194
+ await sendUpdate({
1492
2195
  sessionId: params.sessionId,
1493
2196
  update: {
1494
- sessionUpdate: "agent_message_chunk",
1495
- content: { type: "text", text: lastRefusalExplanation },
2197
+ sessionUpdate: "usage_update",
2198
+ used: lastAssistantTotalUsage,
2199
+ size: session.contextWindowSize,
2200
+ cost: {
2201
+ amount: message.total_cost_usd,
2202
+ currency: "USD",
2203
+ },
2204
+ ...(message.origin && {
2205
+ _meta: { "_claude/origin": message.origin },
2206
+ }),
1496
2207
  },
1497
2208
  });
1498
2209
  }
1499
- stopReason = "refusal";
1500
- settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
1501
- break;
1502
- }
1503
- switch (message.subtype) {
1504
- case "success": {
1505
- if (message.result.includes("Please run /login")) {
1506
- failActive(RequestError.authRequired());
1507
- break;
1508
- }
1509
- if (message.stop_reason === "max_tokens") {
1510
- if (!isTaskNotification) {
1511
- stopReason = "max_tokens";
1512
- }
1513
- break;
2210
+ if (session.cancelled) {
2211
+ if (!isAutonomousResult) {
2212
+ stopReason = "cancelled";
1514
2213
  }
1515
- if (message.is_error) {
1516
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
1517
- break;
2214
+ break;
2215
+ }
2216
+ // A held turn (see Turn.deferredSettle) settles at its
2217
+ // followup's terminal result: this is the earliest point at which
2218
+ // the promised summary has fully streamed — the trailing idle
2219
+ // would work too, but a client should not wait out another idle
2220
+ // round-trip for a response whose content is already complete.
2221
+ // (While the turn still awaits another of its subagents —
2222
+ // parallel spawns — the helper holds; the next notification's
2223
+ // followup settles it instead. Other autonomous origins — peer/
2224
+ // channel/coordinator cycles — reach here too: settling a
2225
+ // drained hold at their results is as good as the idle
2226
+ // fallback.) Then stop: everything below is user-turn
2227
+ // lifecycle, and an autonomous outcome must never touch it —
2228
+ // its is_error or "Please run /login" text would otherwise
2229
+ // failActive a live turn (the held one, or the user's next
2230
+ // prompt) whose own result recorded a different outcome.
2231
+ if (isAutonomousResult) {
2232
+ settleDeferredIfDrained();
2233
+ // With no turn in flight OR QUEUED (also after the settle
2234
+ // above), the stretch holds only autonomous prose — close
2235
+ // it, so a replayed next prompt isn't silently suppressed by
2236
+ // the issue-#453 delivery check. A live turn's flag may
2237
+ // guard the USER's already-streamed text — and so may a
2238
+ // QUEUED turn's: with mid-message echo lag its deltas stream
2239
+ // before the echo activates it (activeTurn still null), and
2240
+ // clearing then would re-emit that answer via the fallback,
2241
+ // the duplicate direction the flag's doc forbids.
2242
+ if (!session.activeTurn && !firstUnsettledQueuedTurn()) {
2243
+ session.emittedAssistantText = false;
1518
2244
  }
1519
- // For local-only commands (no model invocation), the result
1520
- // text is the command output — forward it to the client.
1521
- // Task-notification followups never originate from a user
1522
- // slash command, so skip the forwarding for them.
1523
- if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
1524
- for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
1525
- await this.client.sessionUpdate(notification);
1526
- }
2245
+ break;
2246
+ }
2247
+ // A refusal can arrive on any result subtype (and may even set
2248
+ // is_error), so handle it before the subtype switch — otherwise the
2249
+ // is_error throw below would surface it as an internal error. The
2250
+ // refused assistant message carries no visible content, so surface
2251
+ // the classifier's explanation (when available) and report ACP's
2252
+ // dedicated `refusal` stop reason.
2253
+ if (message.stop_reason === "refusal") {
2254
+ if (lastRefusalExplanation) {
2255
+ await sendUpdate({
2256
+ sessionId: params.sessionId,
2257
+ update: {
2258
+ sessionUpdate: "agent_message_chunk",
2259
+ content: { type: "text", text: lastRefusalExplanation },
2260
+ },
2261
+ });
1527
2262
  }
2263
+ stopReason = "refusal";
2264
+ // Through the deferral gate, not settleActive: a refusal can
2265
+ // land on a turn whose spawned subagents are still live, and
2266
+ // settling it out from under them would strand their output
2267
+ // and permission requests out-of-turn (issue #866's deadlock,
2268
+ // through the refusal lane).
2269
+ settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
1528
2270
  break;
1529
2271
  }
1530
- case "error_during_execution": {
1531
- if (message.stop_reason === "max_tokens") {
1532
- if (!isTaskNotification) {
2272
+ switch (message.subtype) {
2273
+ case "success": {
2274
+ if (message.result.includes("Please run /login")) {
2275
+ failActive(RequestError.authRequired());
2276
+ break;
2277
+ }
2278
+ if (message.stop_reason === "max_tokens") {
1533
2279
  stopReason = "max_tokens";
2280
+ break;
2281
+ }
2282
+ if (message.is_error) {
2283
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2284
+ break;
2285
+ }
2286
+ // The result text is forwarded in two cases. Local-only
2287
+ // commands (no model invocation): the result IS the command
2288
+ // output. Otherwise the result is normally a trailing copy of
2289
+ // text that already streamed — but a cache-replayed turn
2290
+ // generates no tokens, and some CLIs then skip streaming
2291
+ // entirely and answer on the `result` alone: no `stream_event`
2292
+ // deltas, no consolidated `assistant` message (issue #453).
2293
+ // Forward it rather than end the turn silently:
2294
+ // `deliveredAssistantText` covers whatever already reached the
2295
+ // client (a turn that showed its answer cannot emit it twice),
2296
+ // and the output-token check keeps the fallback to the
2297
+ // replayed turns it was reported for. `?? 0`: typed non-null,
2298
+ // but third-party backends have been observed omitting usage
2299
+ // token fields (see snapshotFromUsage), and the replay lane
2300
+ // was reported from exactly such a backend — treat a missing
2301
+ // count as the replay signature rather than silently disabling
2302
+ // the fallback there. (Autonomous results never get here —
2303
+ // they exit at the early break above — so no background
2304
+ // prose can be injected into the feed.)
2305
+ if (session.activeTurn?.isLocalOnlyCommand ||
2306
+ (!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
2307
+ for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
2308
+ await sendUpdate(notification);
2309
+ }
1534
2310
  }
1535
2311
  break;
1536
2312
  }
1537
- if (message.is_error) {
1538
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1539
- break;
1540
- }
1541
- if (!isTaskNotification) {
2313
+ case "error_during_execution": {
2314
+ if (message.stop_reason === "max_tokens") {
2315
+ stopReason = "max_tokens";
2316
+ break;
2317
+ }
2318
+ if (message.is_error) {
2319
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2320
+ break;
2321
+ }
1542
2322
  stopReason = "end_turn";
1543
- }
1544
- break;
1545
- }
1546
- case "error_max_budget_usd":
1547
- case "error_max_turns":
1548
- case "error_max_structured_output_retries":
1549
- if (message.is_error) {
1550
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1551
2323
  break;
1552
2324
  }
1553
- if (!isTaskNotification) {
2325
+ case "error_max_budget_usd":
2326
+ case "error_max_turns":
2327
+ case "error_max_structured_output_retries":
2328
+ if (message.is_error) {
2329
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2330
+ break;
2331
+ }
1554
2332
  stopReason = "max_turn_requests";
1555
- }
1556
- break;
1557
- default:
1558
- unreachable(message, this.logger);
1559
- break;
2333
+ break;
2334
+ default:
2335
+ unreachable(message, this.logger);
2336
+ break;
2337
+ }
2338
+ // Settle the user turn at its terminal result so the client unlocks
2339
+ // as soon as the answer is done, rather than waiting for the SDK's
2340
+ // trailing `idle` (which can lag while background work runs — issue
2341
+ // #773). The consumer keeps draining afterward (absorbing idle and
2342
+ // forwarding any background output).
2343
+ //
2344
+ // One exception: while background subagents this turn spawned are
2345
+ // still live, settling now would strand their remaining work
2346
+ // outside any turn — ACP allows out-of-turn session/update, but
2347
+ // many clients stop consuming at the prompt response, and a
2348
+ // subagent's permission request would block on an RPC nobody
2349
+ // answers (issues #864/#866). Hold the turn open instead: store
2350
+ // the outcome and settle with it once the subagents are done —
2351
+ // at their followup's terminal result (see the deferred-settle
2352
+ // block above the subtype switch) or at an idle with none of
2353
+ // them left — so the subagents' streamed output, their
2354
+ // permission requests, and the model's promised summary all land
2355
+ // inside the turn. `session/cancel` and the next prompt's echo
2356
+ // hand-off still settle a deferred turn early, so a long-running
2357
+ // subagent never holds the prompt hostage.
2358
+ //
2359
+ // is_error/auth already settled via failActive (activeTurn is null
2360
+ // then, so both branches no-op); cancellation is left to the
2361
+ // idle/abort path. settleActive is idempotent, so a duplicate
2362
+ // idle is a no-op.
2363
+ if (!session.cancelled) {
2364
+ settleOrDefer({ stopReason, usage: sessionUsage(session) });
2365
+ }
1560
2366
  }
1561
- // Settle the user turn at its terminal result so the client unlocks
1562
- // as soon as the answer is done, rather than waiting for the SDK's
1563
- // trailing `idle` (which can lag while background work runs — issue
1564
- // #773). The consumer keeps draining afterward (absorbing idle and
1565
- // forwarding any background output). is_error/auth already settled
1566
- // via failActive; cancellation is left to the idle/abort path.
1567
- // settleActive is idempotent, so a duplicate idle is a no-op.
1568
- if (!isTaskNotification && !session.cancelled) {
1569
- settleActive({ stopReason, usage: sessionUsage(session) });
2367
+ finally {
2368
+ if (!isAutonomousResult) {
2369
+ session.emittedAssistantText = false;
2370
+ }
1570
2371
  }
1571
2372
  break;
1572
2373
  }
@@ -1611,7 +2412,7 @@ export class ClaudeAcpAgent {
1611
2412
  // create a block the consolidated handler's `text.length > 0`
1612
2413
  // guard can never consume, stalling the diff cursor and
1613
2414
  // re-emitting the next block as a duplicate.
1614
- if (chunk && chunk.text.length > 0) {
2415
+ if (chunk?.text) {
1615
2416
  const index = message.event.index;
1616
2417
  const last = streamedBlocks[streamedBlocks.length - 1];
1617
2418
  if (last && last.index === index && last.type === chunk.type) {
@@ -1629,11 +2430,10 @@ export class ClaudeAcpAgent {
1629
2430
  const model = message.event.message.model;
1630
2431
  if (model && model !== "<synthetic>") {
1631
2432
  lastAssistantModel = model;
1632
- // Only upgrade from the default — once a `result` has given
1633
- // us an authoritative window, trust it over the heuristic.
1634
- // Model switches invalidate the cached window via
1635
- // `syncSessionConfigState`, which resets us back to the
1636
- // default so this branch runs again for the new model.
2433
+ // Only upgrade from the default — once the SDK has given us
2434
+ // an authoritative window (seeded at session creation,
2435
+ // refreshed on model switches in `applyConfigOptionValue`,
2436
+ // confirmed by each `result`), trust it over the heuristic.
1637
2437
  if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
1638
2438
  const inferred = inferContextWindowFromModel(model);
1639
2439
  if (inferred !== null) {
@@ -1659,7 +2459,7 @@ export class ClaudeAcpAgent {
1659
2459
  const nextUsage = totalTokens(lastAssistantUsage);
1660
2460
  if (nextUsage !== lastAssistantTotalUsage) {
1661
2461
  lastAssistantTotalUsage = nextUsage;
1662
- await this.client.sessionUpdate({
2462
+ await sendUpdate({
1663
2463
  sessionId: params.sessionId,
1664
2464
  update: {
1665
2465
  sessionUpdate: "usage_update",
@@ -1675,8 +2475,11 @@ export class ClaudeAcpAgent {
1675
2475
  taskState: session.taskState,
1676
2476
  emittedToolCalls: session.emittedToolCalls,
1677
2477
  messageId: currentStreamMessageId,
2478
+ streamedToolInputs,
1678
2479
  })) {
1679
- await this.client.sessionUpdate(notification);
2480
+ // sendUpdate records delivery; a subagent stream's chunks carry
2481
+ // the stamped parentToolUseId meta and are excluded there.
2482
+ await sendUpdate(notification);
1680
2483
  }
1681
2484
  break;
1682
2485
  }
@@ -1697,7 +2500,7 @@ export class ClaudeAcpAgent {
1697
2500
  // is still promoted — activateTurn() clears the flag. The turn's own
1698
2501
  // echo is then dropped from the feed (the client already shows it).
1699
2502
  if (message.type === "user" && "uuid" in message && message.uuid) {
1700
- const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
2503
+ const queued = findUnsettledTurn(message.uuid);
1701
2504
  if (queued) {
1702
2505
  // Only (re)activate if this isn't already the active turn — a
1703
2506
  // turn promoted early (e.g. by a result that preceded its echo)
@@ -1715,13 +2518,38 @@ export class ClaudeAcpAgent {
1715
2518
  // debt so that lagged idle is absorbed rather than read
1716
2519
  // as the freshly-activated turn ending without a result
1717
2520
  // (which would false-fail a healthy turn — issue #825).
1718
- owedTrailingIdles++;
1719
- settleActive({ stopReason: "cancelled" });
2521
+ // Counted for a DEFERRED turn too, even though its own
2522
+ // result already recorded a debt that may still be
2523
+ // outstanding: the interrupt can produce a trailer of
2524
+ // its own, and over-counting is benign (absorbs one
2525
+ // future idle) while under-counting risks the false
2526
+ // fail this debt exists to prevent.
2527
+ session.owedTrailingIdles++;
2528
+ // Before activateTurn resets the accumulator, so the
2529
+ // usage still belongs to the cancelled turn.
2530
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
2531
+ }
2532
+ else if (isHeldOpen(session.activeTurn)) {
2533
+ // A turn held open for its background subagents (see
2534
+ // Turn.deferredSettle) hands off with the real outcome
2535
+ // its result recorded, not a guessed end_turn — the
2536
+ // user moving on must not block behind a long-running
2537
+ // subagent, but it must not rewrite the stop reason
2538
+ // either. Its trailing-idle debt stands and is absorbed
2539
+ // when the drain idle eventually arrives.
2540
+ settleActive(session.activeTurn.deferredSettle);
1720
2541
  }
1721
2542
  else {
1722
2543
  settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
1723
2544
  }
1724
2545
  }
2546
+ // Unlike the no-result teardown lanes, this hand-off must
2547
+ // NOT clear emittedAssistantText for a NON-held previous
2548
+ // turn (a held one's settleActive above closes its own
2549
+ // stretch): the echo can land
2550
+ // mid-message, so deltas already streamed belong to the turn
2551
+ // being activated — clearing would forget them and let its
2552
+ // result re-emit the answer.
1725
2553
  activateTurn(queued);
1726
2554
  }
1727
2555
  break;
@@ -1774,7 +2602,7 @@ export class ClaudeAcpAgent {
1774
2602
  taskState: session.taskState,
1775
2603
  messageId: messageIdForGrouping(message),
1776
2604
  })) {
1777
- await this.client.sessionUpdate(notification);
2605
+ await sendUpdate(notification);
1778
2606
  }
1779
2607
  }
1780
2608
  else {
@@ -1798,12 +2626,7 @@ export class ClaudeAcpAgent {
1798
2626
  if (message.message.role === "system") {
1799
2627
  break;
1800
2628
  }
1801
- if (message.type === "assistant" &&
1802
- message.message.model === "<synthetic>" &&
1803
- Array.isArray(message.message.content) &&
1804
- message.message.content.length === 1 &&
1805
- message.message.content[0].type === "text" &&
1806
- message.message.content[0].text.includes("Please run /login")) {
2629
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
1807
2630
  failActive(RequestError.authRequired());
1808
2631
  break;
1809
2632
  }
@@ -1886,13 +2709,18 @@ export class ClaudeAcpAgent {
1886
2709
  taskState: session.taskState,
1887
2710
  emittedToolCalls: session.emittedToolCalls,
1888
2711
  messageId: messageIdForGrouping(message),
2712
+ toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
1889
2713
  })) {
1890
- await this.client.sessionUpdate(notification);
2714
+ // sendUpdate records delivery. Subagent text/thinking is
2715
+ // filtered out of `content` above; blocks that do pass through
2716
+ // (e.g. a subagent image) carry the stamped parentToolUseId
2717
+ // meta and are excluded there.
2718
+ await sendUpdate(notification);
1891
2719
  }
1892
2720
  break;
1893
2721
  }
1894
2722
  case "tool_progress": {
1895
- await this.client.sessionUpdate({
2723
+ await sendUpdate({
1896
2724
  sessionId: message.session_id,
1897
2725
  update: {
1898
2726
  sessionUpdate: "tool_call_update",
@@ -1910,7 +2738,7 @@ export class ClaudeAcpAgent {
1910
2738
  }
1911
2739
  case "rate_limit_event": {
1912
2740
  if (lastAssistantTotalUsage !== null) {
1913
- await this.client.sessionUpdate({
2741
+ await sendUpdate({
1914
2742
  sessionId: message.session_id,
1915
2743
  update: {
1916
2744
  sessionUpdate: "usage_update",
@@ -1931,7 +2759,7 @@ export class ClaudeAcpAgent {
1931
2759
  case "conversation_reset":
1932
2760
  break;
1933
2761
  default:
1934
- unreachable(message);
2762
+ unreachable(message, this.logger);
1935
2763
  break;
1936
2764
  }
1937
2765
  }
@@ -1975,6 +2803,30 @@ export class ClaudeAcpAgent {
1975
2803
  }
1976
2804
  }
1977
2805
  }
2806
+ /** Route one orphaned command into the session's orphan-accounting lane:
2807
+ * the per-uuid map on msg_lifecycle_v1 CLIs (drained by the command's own
2808
+ * terminal lifecycle frame and the echo-less-result skip), the plain count
2809
+ * elsewhere (the count lane can't express per-command states, so `state`
2810
+ * only matters on the map lane). Both orphan-producing paths — cancel()'s
2811
+ * queued-turn sweep and the consumer's force-cancel wedge path — must seed
2812
+ * through here so the lane split stays a single mechanism.
2813
+ *
2814
+ * Known window: `msgLifecycleV1` is only learnable from the stream's first
2815
+ * `system`/init (the control-channel initialize carries no capabilities),
2816
+ * so a cancel that beats that drain seeds the COUNT lane on a
2817
+ * lifecycle-capable CLI — where command coalescing can leave the count
2818
+ * stale by N-1 (the pre-map bug, confined to this sub-second window and
2819
+ * still healed by the next activation's reset). Structural until the SDK
2820
+ * exposes capabilities before the stream starts. */
2821
+ trackOrphanCommand(session, uuid, state) {
2822
+ if (session.msgLifecycleV1) {
2823
+ session.orphanCommands ??= new Map();
2824
+ session.orphanCommands.set(uuid, state);
2825
+ }
2826
+ else {
2827
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
2828
+ }
2829
+ }
1978
2830
  async cancel(params) {
1979
2831
  const session = this.sessions[params.sessionId];
1980
2832
  if (!session) {
@@ -1997,26 +2849,117 @@ export class ClaudeAcpAgent {
1997
2849
  return;
1998
2850
  }
1999
2851
  session.cancelled = true;
2852
+ // Capture the orphan-accounting lane before anything can await: the
2853
+ // consumer latches msgLifecycleV1 when it drains the first system/init,
2854
+ // which can happen DURING the awaited interrupt() below — the receipt
2855
+ // reconciliation must act on the same lane the seeding used, or a
2856
+ // count-lane orphan would be left for the map-lane receipt path (which
2857
+ // never decrements the count) to miss.
2858
+ const lifecycleLane = session.msgLifecycleV1 === true;
2000
2859
  // Settle queued turns that haven't started yet (no echo seen) right away —
2001
2860
  // they have no in-flight SDK work to interrupt. The active turn is settled
2002
2861
  // by the consumer when it observes the interrupt's trailing idle (or via the
2003
2862
  // backstop below). Mirrors the old pendingMessages cancellation.
2863
+ const orphanedTurns = [];
2004
2864
  if (session.turnQueue) {
2005
- let orphaned = 0;
2006
2865
  for (const turn of session.turnQueue) {
2007
2866
  if (turn !== session.activeTurn && !turn.settled) {
2008
2867
  turn.settled = true;
2868
+ // Deliberately no `usage`: a queued turn never ran, so the session
2869
+ // accumulator (the active turn's tally) is not its spend.
2009
2870
  turn.resolve({ stopReason: "cancelled" });
2010
- orphaned++;
2871
+ orphanedTurns.push(turn);
2011
2872
  }
2012
2873
  }
2013
2874
  // Each removed queued turn's user message was already pushed to the SDK,
2014
2875
  // which processes input FIFO and will still emit a result for it with no
2015
- // uuid to match. Count those so the consumer skips them (see
2876
+ // uuid to match. Track those so the consumer skips them (see
2016
2877
  // ensureActiveTurn) rather than misattributing them to the head.
2017
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
2878
+ // msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
2879
+ // own terminal lifecycle frame — exact under command coalescing, where
2880
+ // N queued commands fold into ONE turn emitting one result and a plain
2881
+ // count would go stale by N-1 and swallow a later echo-less result.
2882
+ // Older CLIs keep the count and its activation-time self-heal (they
2883
+ // never see lifecycle frames, so commandStarted/commandFinished stay
2884
+ // unset and every turn takes the plain-seed path below).
2885
+ for (const turn of orphanedTurns) {
2886
+ if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
2887
+ // The command already finished SDK-side and its terminal frame was
2888
+ // consumed while the turn sat queued — nothing is left to skip, and
2889
+ // a seeded entry would never drain.
2890
+ continue;
2891
+ }
2892
+ if (turn.commandFinished === "cancelled") {
2893
+ // Terminal frame already consumed. Dispatched-then-aborted: the
2894
+ // dead turn's late result may still come — seed the zombie the
2895
+ // frame handler would have made — unless that result already
2896
+ // passed pre-cancel (commandResultSeen: e.g. the command folded
2897
+ // into the active turn and their shared result was attributed
2898
+ // there), in which case a zombie would be a phantom that swallows
2899
+ // an unrelated later result. Never dispatched: dropped, no result
2900
+ // coming, nothing to track.
2901
+ if (turn.commandStarted && !turn.commandResultSeen) {
2902
+ this.trackOrphanCommand(session, turn.promptUuid, "zombie");
2903
+ }
2904
+ continue;
2905
+ }
2906
+ if (turn.commandStarted && turn.commandResultSeen) {
2907
+ // Dispatched and its turn's result already passed; only its
2908
+ // terminal frame is outstanding, which no-ops with no entry.
2909
+ continue;
2910
+ }
2911
+ this.trackOrphanCommand(session, turn.promptUuid, turn.commandStarted ? "started" : "pending");
2912
+ }
2018
2913
  session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
2019
2914
  }
2915
+ // A deferred active turn (see Turn.deferredSettle) already has its
2916
+ // result — it is only held open for its background subagents, which the
2917
+ // interrupt below tears down. Settle it "cancelled" NOW: during the hold
2918
+ // the session is typically already in state idle (the CLI's trailer
2919
+ // fired at the result), so the interrupt may produce no fresh idle for
2920
+ // the consumer's cancelled-settle path to run on, and the cancel would
2921
+ // otherwise stall until the force-cancel backstop. Any outstanding
2922
+ // trailer debt is absorbed by the idle handler when its idle does come.
2923
+ // The turn's own usage snapshot is reported per the cancelled-usage
2924
+ // contract (issue #844).
2925
+ {
2926
+ const active = session.activeTurn;
2927
+ if (isHeldOpen(active)) {
2928
+ active.settled = true;
2929
+ // Mirror settleActive's invariants (it is consumer-scoped and
2930
+ // unreachable from here): disarm the backstop — none should be
2931
+ // armed for a held turn, but a drift here must not leave a timer
2932
+ // firing on a settled turn — and drop the turn from the queue.
2933
+ disarmForceCancel(session);
2934
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== active);
2935
+ session.activeTurn = null;
2936
+ // Settling a held turn closes its delivery stretch: any streamed
2937
+ // text since the last boundary was its followups', and left latched
2938
+ // it would suppress a following replayed turn's issue-#453 fallback.
2939
+ session.emittedAssistantText = false;
2940
+ // When the interrupt below pre-empts a live cycle — running, or
2941
+ // blocked on a permission request (requires_action, the #866 shape
2942
+ // users cancel out of) — it produces a trailer idle with no counted
2943
+ // result; with the hold's own trailer typically already absorbed,
2944
+ // that idle would be un-owed and could lag past the next prompt's
2945
+ // echo — read as the fresh turn ending without a result (issue #825
2946
+ // false-fail). Pre-count it unless the session sits idle: there the
2947
+ // interrupt emits nothing, and a debt that never drains would mask
2948
+ // one future #825 detection. (lastSessionState is last-CONSUMED, so
2949
+ // both stale reads exist and both are accepted one-cycle windows: a
2950
+ // running transition still in the backlog reads as stale idle and
2951
+ // under-counts — that false-fail additionally needs the trailer to
2952
+ // lag past the next echo — while a cycle already completed into the
2953
+ // backlog reads as stale non-idle and over-counts, masking one
2954
+ // future #825 detection. Undefined — no state event consumed —
2955
+ // pre-counts; that only occurs on CLIs whose missing idle events
2956
+ // also disable the detector the debt could mask.)
2957
+ if (session.lastSessionState !== "idle") {
2958
+ session.owedTrailingIdles++;
2959
+ }
2960
+ active.resolve({ stopReason: "cancelled", usage: active.deferredSettle.usage });
2961
+ }
2962
+ }
2020
2963
  // Arm a backstop before interrupting: if a turn is actively consuming the
2021
2964
  // query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
2022
2965
  // block — issue #680), force the consumer to settle the active turn
@@ -2038,7 +2981,48 @@ export class ClaudeAcpAgent {
2038
2981
  cancelController.abort();
2039
2982
  }, this.forceCancelGraceMs);
2040
2983
  }
2041
- await session.query.interrupt();
2984
+ const receipt = await session.query.interrupt();
2985
+ // On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
2986
+ // lists exactly which queued messages survive the interrupt and will still
2987
+ // run. An orphaned turn whose uuid is absent was dropped by the interrupt
2988
+ // and will never emit a result — uncount it now instead of leaving a stale
2989
+ // skip that activateTurn's reset only clears once a later live ECHO
2990
+ // arrives: an echo-less result in between (a local-only command like
2991
+ // `/context`) would be wrongly swallowed by the leftover count. Subtracting
2992
+ // a count (rather than tracking uuids) stays race-safe against the
2993
+ // consumer draining concurrently: dropped uuids produce no results, so the
2994
+ // consumer's decrements only ever consume the still-queued share. Unknown
2995
+ // uuids in the receipt (internally-enqueued messages) are ignored, per its
2996
+ // contract. Older CLIs resolve `undefined` (guard the FIELD, not just the
2997
+ // receipt, so a bare `{}` success from a gateway can't read as "everything
2998
+ // was dropped") — keep the count-everything behavior and its
2999
+ // activation-time self-heal.
3000
+ if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
3001
+ const stillQueued = new Set(receipt.still_queued);
3002
+ if (lifecycleLane) {
3003
+ // Lifecycle lane: forget dropped orphans by uuid. Only entries still
3004
+ // "pending" — an orphan absent from `still_queued` because it was
3005
+ // DISPATCHED before the interrupt (not dropped) has usually been
3006
+ // promoted to "started" by its lifecycle frame by now, and its own
3007
+ // terminal frame must stay in charge of its fate. (If that frame is
3008
+ // still in the consumer's backlog we mis-forget — the same exposure
3009
+ // the count lane has always had for a dropped-then-run command.)
3010
+ // Mostly redundant with the "cancelled"-frame removal, but a receipt
3011
+ // survives paths where that frame was never emitted.
3012
+ for (const turn of orphanedTurns) {
3013
+ if (!stillQueued.has(turn.promptUuid) &&
3014
+ session.orphanCommands?.get(turn.promptUuid) === "pending") {
3015
+ session.orphanCommands.delete(turn.promptUuid);
3016
+ }
3017
+ }
3018
+ }
3019
+ else {
3020
+ const dropped = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid)).length;
3021
+ if (dropped > 0) {
3022
+ session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
3023
+ }
3024
+ }
3025
+ }
2042
3026
  }
2043
3027
  /** Mark a session's SDK query stream as permanently ended and release the
2044
3028
  * resources tied to it: drop the consumer handle, dispose the settings
@@ -2084,10 +3068,7 @@ export class ClaudeAcpAgent {
2084
3068
  // after the floor, and clear the timer so it can't outlive the deleted
2085
3069
  // session (it isn't unref'd and would otherwise keep the event loop alive
2086
3070
  // until it fires).
2087
- if (session.forceCancelTimer) {
2088
- clearTimeout(session.forceCancelTimer);
2089
- session.forceCancelTimer = undefined;
2090
- }
3071
+ disarmForceCancel(session);
2091
3072
  session.cancelController?.abort();
2092
3073
  this.closeQueryStream(session);
2093
3074
  // Abort the SDK abort signal only on explicit destroy. closeQueryStream
@@ -2196,22 +3177,40 @@ export class ClaudeAcpAgent {
2196
3177
  ? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
2197
3178
  : [];
2198
3179
  let validValue = allValues.find((o) => o.value === params.value);
3180
+ // The option's reported currentValue is always a valid target, even when
3181
+ // it has no options entry: a session running an out-of-picker model
3182
+ // (resumed onto an allowlist-excluded model, or a refusal fallback)
3183
+ // reports a currentValue that isn't selectable, and a client
3184
+ // round-tripping it must not get "Invalid value". It flows through the
3185
+ // normal apply path below — re-asserting an already-current value is
3186
+ // harmless and can repair SDK drift.
3187
+ if (!validValue && option.currentValue === params.value) {
3188
+ validValue = { value: params.value, name: params.value };
3189
+ }
2199
3190
  // For model options, fall back to resolveModelPreference when the exact
2200
3191
  // value doesn't match. This lets callers use human-friendly aliases like
2201
3192
  // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
3193
+ // Resolve against session.modelInfos first: those entries carry
3194
+ // `resolvedModel`, so a full model id (in either hint spelling) lands on
3195
+ // the right row via the exact tier instead of a fuzzier one picking a
3196
+ // same-family sibling from a different context lane. The options-derived
3197
+ // list (which never carries `resolvedModel`) remains as a fallback for
3198
+ // resolutions that don't map back onto a selectable option (e.g. a fuzzy
3199
+ // hit on an out-of-picker verbatim entry).
2202
3200
  // No deprecation filter here (R4.2, no double work): this list is rebuilt
2203
3201
  // from the RENDERED option rows, which flow from the already-filtered
2204
3202
  // picker list (`hideDeprecatedModels` at every session-creation branch) —
2205
3203
  // so hidden rows are not selectable via aliases either.
2206
3204
  if (!validValue && params.configId === MODEL_CONFIG_ID) {
2207
- const modelInfos = allValues.map((o) => ({
2208
- value: o.value,
2209
- displayName: o.name,
2210
- description: o.description ?? "",
2211
- }));
2212
- const resolved = resolveModelPreference(modelInfos, params.value);
2213
- if (resolved) {
2214
- validValue = allValues.find((o) => o.value === resolved.value);
3205
+ const toOptionValue = (resolved) => resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
3206
+ validValue = toOptionValue(resolveModelPreference(session.modelInfos, params.value));
3207
+ if (!validValue) {
3208
+ const optionInfos = allValues.map((o) => ({
3209
+ value: o.value,
3210
+ displayName: o.name,
3211
+ description: o.description ?? "",
3212
+ }));
3213
+ validValue = toOptionValue(resolveModelPreference(optionInfos, params.value));
2215
3214
  }
2216
3215
  }
2217
3216
  if (!validValue) {
@@ -2287,6 +3286,12 @@ export class ClaudeAcpAgent {
2287
3286
  if (replaySession && replayMessageId && message.uuid) {
2288
3287
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
2289
3288
  }
3289
+ // The live prompt loop converts the synthetic "Please run /login"
3290
+ // assistant message into an authRequired error instead of showing its
3291
+ // TUI-specific text; skip it on replay too (issue #863).
3292
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3293
+ continue;
3294
+ }
2290
3295
  // @ts-expect-error - untyped in SDK but we handle all of these
2291
3296
  let content = message.message.content;
2292
3297
  // @ts-expect-error - untyped in SDK but we handle all of these
@@ -2325,14 +3330,14 @@ export class ClaudeAcpAgent {
2325
3330
  * outcome or a `requestCancelled` rejection). Either way we surface the same
2326
3331
  * "Tool use aborted" the callers already expect, so a cancelled dialog no
2327
3332
  * longer leaves the `await` hanging. */
2328
- async requestPermissionFromClient(params, toolName, signal) {
3333
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
2329
3334
  // The SDK may invoke `canUseTool` (and therefore this permission request)
2330
3335
  // before the assistant message's tool_use block streams to us. Some ACP clients
2331
3336
  // expect the `tool_call` a permission request references to already exist,
2332
3337
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
2333
3338
  // later refines it with a `tool_call_update` rather than emitting a
2334
3339
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
2335
- await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
3340
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
2336
3341
  try {
2337
3342
  return await this.client.requestPermission(params, signal);
2338
3343
  }
@@ -2349,10 +3354,15 @@ export class ClaudeAcpAgent {
2349
3354
  * instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
2350
3355
  * `toolCallNotification` helper as the streamed path so the two are identical.
2351
3356
  * Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
2352
- * skipped so a permission prompt for them never surfaces a stray tool_call. */
2353
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput) {
3357
+ * emitted too: a permission request referencing a tool call the client has
3358
+ * never seen can trip strict clients (issue #851), so the reference must
3359
+ * always resolve. Since the streamed path never completes those calls, they
3360
+ * are resolved at tool_result time instead (see `toAcpNotifications`).
3361
+ * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
3362
+ * that spawned it, matching the streamed path's `_meta`. */
3363
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
2354
3364
  const session = this.sessions[sessionId];
2355
- if (!session || !shouldEmitToolCall(toolName)) {
3365
+ if (!session) {
2356
3366
  return;
2357
3367
  }
2358
3368
  if (session.emittedToolCalls.has(toolCallId)) {
@@ -2360,13 +3370,20 @@ export class ClaudeAcpAgent {
2360
3370
  }
2361
3371
  session.emittedToolCalls.add(toolCallId);
2362
3372
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2363
- await this.client.sessionUpdate({
2364
- sessionId,
2365
- update: toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd),
2366
- });
3373
+ const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
3374
+ if (parentToolUseId) {
3375
+ update._meta = {
3376
+ ...update._meta,
3377
+ claudeCode: {
3378
+ ...(update._meta?.claudeCode || {}),
3379
+ parentToolUseId,
3380
+ },
3381
+ };
3382
+ }
3383
+ await this.client.sessionUpdate({ sessionId, update });
2367
3384
  }
2368
3385
  canUseTool(sessionId) {
2369
- return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
3386
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID }) => {
2370
3387
  const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
2371
3388
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2372
3389
  const session = this.sessions[sessionId];
@@ -2376,6 +3393,23 @@ export class ClaudeAcpAgent {
2376
3393
  message: "Session not found",
2377
3394
  };
2378
3395
  }
3396
+ // When the tool call originates inside a subagent, attribute the eagerly
3397
+ // emitted tool_call (and the permission request itself) to the Agent/Task
3398
+ // tool call that spawned the subagent, mirroring the streamed subagent
3399
+ // path's `_meta.claudeCode.parentToolUseId` (see `liveBackgroundTasks`).
3400
+ const parentToolUseId = agentID
3401
+ ? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
3402
+ : undefined;
3403
+ if (agentID && !parentToolUseId) {
3404
+ // The attribution rests on an undocumented SDK invariant
3405
+ // (task_started.task_id === canUseTool's agentID for subagent tasks;
3406
+ // verified against the bundled CLI). Should an SDK bump break it — or
3407
+ // the consumer lose the race with task_started — the lookup misses and
3408
+ // the request goes out unattributed; log it so the regression is
3409
+ // observable rather than silent.
3410
+ this.logger.log(`[claude-agent-acp] No parent tool_use recorded for subagent ${agentID}; ` +
3411
+ `sending the ${toolName} permission request unattributed`);
3412
+ }
2379
3413
  // AskUserQuestion is surfaced to us as a normal permission check (the SDK
2380
3414
  // routes it through canUseTool whenever a callback is registered, rather
2381
3415
  // than the interactive dialog). Present it as an ACP form elicitation and
@@ -2383,7 +3417,7 @@ export class ClaudeAcpAgent {
2383
3417
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
2384
3418
  // Like permission requests, the elicitation references this toolUseID, so
2385
3419
  // make sure the tool_call has surfaced to the client before we send it.
2386
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
3420
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
2387
3421
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
2388
3422
  }
2389
3423
  // Fallback for clients WITHOUT `elicitation.form`: route each question
@@ -2452,8 +3486,13 @@ export class ClaudeAcpAgent {
2452
3486
  toolCallId: toolUseID,
2453
3487
  rawInput: toolInput,
2454
3488
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3489
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3490
+ // so clients can rely on one shape everywhere.
3491
+ ...(parentToolUseId
3492
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3493
+ : {}),
2455
3494
  },
2456
- }, toolName, signal);
3495
+ }, toolName, signal, parentToolUseId);
2457
3496
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2458
3497
  throw new Error("Tool use aborted");
2459
3498
  }
@@ -2511,8 +3550,13 @@ export class ClaudeAcpAgent {
2511
3550
  toolCallId: toolUseID,
2512
3551
  rawInput: toolInput,
2513
3552
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3553
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3554
+ // so clients can rely on one shape everywhere.
3555
+ ...(parentToolUseId
3556
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3557
+ : {}),
2514
3558
  },
2515
- }, toolName, signal);
3559
+ }, toolName, signal, parentToolUseId);
2516
3560
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2517
3561
  throw new Error("Tool use aborted");
2518
3562
  }
@@ -2688,12 +3732,17 @@ export class ClaudeAcpAgent {
2688
3732
  // carries no "1m" token.
2689
3733
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
2690
3734
  if (session.models.currentModelId !== value) {
2691
- // The cached context window was learned for the previous model; reset
2692
- // to the new model's heuristic so mid-stream updates between now and
2693
- // the next `result` reflect the user's selection instead of the old
2694
- // model's window.
3735
+ // The cached context window was learned for the previous model. The
3736
+ // SDK is already running the new model here (user-driven switches call
3737
+ // `query.setModel` before this, and the refusal-fallback sync only
3738
+ // reconciles a switch the SDK already made), so ask it for the new
3739
+ // window; fall back to the text heuristic so mid-stream updates
3740
+ // between now and the next `result` reflect the user's selection
3741
+ // instead of the old model's window.
2695
3742
  session.contextWindowSize =
2696
- inferContextWindowFromModel(value, newModelInfo?.displayName, newModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW;
3743
+ (await fetchContextWindowSize(session.query, this.logger)) ??
3744
+ inferContextWindowFromModel(value, newModelInfo?.resolvedModel, newModelInfo?.displayName, newModelInfo?.description) ??
3745
+ DEFAULT_CONTEXT_WINDOW;
2697
3746
  }
2698
3747
  session.models = { ...session.models, currentModelId: value };
2699
3748
  // Recompute availableModes for the new model and clamp the current
@@ -3318,7 +4367,10 @@ export class ClaudeAcpAgent {
3318
4367
  env: {
3319
4368
  ...process.env,
3320
4369
  ...userProvidedOptions?.env,
3321
- ...createEnvForGateway(this.gatewayAuthRequest),
4370
+ // Client-managed LLM routing: `providers/set` config wins, else the
4371
+ // legacy gateway auth request. Baked into the query at creation, so it
4372
+ // only affects sessions started after the change (matching the RFD).
4373
+ ...createEnvForProvider(this.resolveProviderConfig()),
3322
4374
  // Opt-in to session state events like when the agent is idle
3323
4375
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
3324
4376
  },
@@ -3485,12 +4537,49 @@ export class ClaudeAcpAgent {
3485
4537
  const allowedModels = Array.isArray(settingsAvailableModels)
3486
4538
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
3487
4539
  : hideDeprecatedModels(initializationResult.models, this.logger);
3488
- const models = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger);
4540
+ const models = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
3489
4541
  // Gate `auto` (and future model-specific modes) on the resolved model's
3490
4542
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
3491
4543
  // Looked up in the UNfiltered catalog: a session honoring a persisted
3492
4544
  // deprecated preference must keep that model's real capabilities (R4.3).
3493
- const currentModelInfo = catalogModels.find((m) => m.value === models.currentModelId);
4545
+ // A resumed session can also be running a model outside the
4546
+ // `availableModels` allowlist (currentModelId is then the verbatim live
4547
+ // id, see `matchResumedModel`); its capabilities are still known to the
4548
+ // SDK's unfiltered list, so fall back to that before treating the model
4549
+ // as unknown — otherwise auto mode would be spuriously clamped and the
4550
+ // Fast-mode/Effort options hidden for a model that supports them.
4551
+ const catalogModelInfo = catalogModels.find((m) => m.value === models.currentModelId);
4552
+ const fallbackModelInfo = catalogModelInfo
4553
+ ? undefined
4554
+ : (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
4555
+ const currentModelInfo = catalogModelInfo ?? fallbackModelInfo;
4556
+ // Register the fallback-resolved capabilities under the verbatim live id
4557
+ // so every modelInfos consumer (buildConfigOptions' effort lookup, later
4558
+ // rebuilds via session.modelInfos) agrees with the gating below. The
4559
+ // picker options themselves come from `models.availableModels`, so this
4560
+ // adds no selectable entry. The spread keeps every capability flag
4561
+ // (current and future); the identity fields are overridden because the
4562
+ // fuzzy-matched sibling's resolvedModel/displayName/description can
4563
+ // describe a different context lane and would poison later resolvedModel
4564
+ // matching (syncModelAfterRefusalFallback) and context-window inference
4565
+ // (applyConfigOptionValue) if they traveled under this id.
4566
+ // Built on the UNfiltered catalog, NOT the picker list: `modelInfos` is
4567
+ // never rendered (picker rows come from `models.availableModels`) — it
4568
+ // feeds capability lookups and `resolveModelPreference` (refusal
4569
+ // fallback), which must keep seeing deprecated rows (R4.3,
4570
+ // visibility-only filter).
4571
+ const modelInfos = fallbackModelInfo
4572
+ ? [
4573
+ ...catalogModels,
4574
+ {
4575
+ ...fallbackModelInfo,
4576
+ value: models.currentModelId,
4577
+ displayName: models.currentModelId,
4578
+ description: "",
4579
+ resolvedModel: undefined,
4580
+ },
4581
+ ]
4582
+ : catalogModels;
3494
4583
  const availableModes = buildAvailableModes(currentModelInfo);
3495
4584
  // Clamp `permissionMode` if the resolved session does not offer it. The
3496
4585
  // common case is `permissions.defaultMode: "auto"` resolving to a model
@@ -3544,12 +4633,13 @@ export class ClaudeAcpAgent {
3544
4633
  enabled: fastModeEnabled,
3545
4634
  };
3546
4635
  const configOptions = buildConfigOptions(modes, models,
3547
- // The UNfiltered catalog, matching the model-switch rebuild (which
3548
- // passes `session.modelInfos`): `buildConfigOptions` reads this argument
3549
- // only for the current model's effort capabilities — picker rows come
3550
- // from `models.availableModels` — so a deprecated current model keeps
3551
- // its effort option without leaking hidden rows (R4.3).
3552
- catalogModels, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode,
4636
+ // Catalog-based (see `modelInfos` above), matching the model-switch
4637
+ // rebuild (which passes `session.modelInfos`): `buildConfigOptions`
4638
+ // reads this argument only for the current model's effort capabilities
4639
+ // — picker rows come from `models.availableModels` — so a deprecated
4640
+ // current model keeps its effort option without leaking hidden rows
4641
+ // (R4.3).
4642
+ modelInfos, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode,
3553
4643
  // A fresh session's Thinking intent is untouched (undefined), so the
3554
4644
  // display follows the env-driven state. `thinking` already holds
3555
4645
  // effectiveThinkingConfig(undefined, MAX_THINKING_TOKENS) from above, so
@@ -3565,6 +4655,22 @@ export class ClaudeAcpAgent {
3565
4655
  effortLevel: initialEffort.currentValue,
3566
4656
  });
3567
4657
  }
4658
+ // Seed the context window from the SDK's authoritative report. Text
4659
+ // inference alone misses aliases that resolve to extended-context models
4660
+ // with no "1m" token anywhere in their id or description (e.g. `sonnet` →
4661
+ // claude-sonnet-5, natively ~1M): those streamed `usage_update.size:
4662
+ // 200000` until the first result's modelUsage corrected it — again on
4663
+ // every process restart or session re-creation, since the learned window
4664
+ // lives only on the Session (issue #596).
4665
+ //
4666
+ // The inference fallback is deliberately keyed to the allowlisted entry: a
4667
+ // fallback-resolved sibling's resolvedModel/displayName/description can
4668
+ // describe a different context lane than the verbatim live id (e.g. an
4669
+ // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
4670
+ // the id itself is a trustworthy window signal.
4671
+ const contextWindowSize = (await fetchContextWindowSize(q, this.logger)) ??
4672
+ inferContextWindowFromModel(models.currentModelId, catalogModelInfo?.resolvedModel, catalogModelInfo?.displayName, catalogModelInfo?.description) ??
4673
+ DEFAULT_CONTEXT_WINDOW;
3568
4674
  this.sessions[sessionId] = {
3569
4675
  query: q,
3570
4676
  input: input,
@@ -3583,21 +4689,24 @@ export class ClaudeAcpAgent {
3583
4689
  },
3584
4690
  modes,
3585
4691
  models,
3586
- // The UNfiltered catalog, NOT the picker list: `modelInfos` is never
3587
- // rendered (picker rows come from `models.availableModels`) — it feeds
4692
+ // Catalog-based, NOT the picker list: `modelInfos` is never rendered
4693
+ // (picker rows come from `models.availableModels`) — it feeds
3588
4694
  // capability lookups and `resolveModelPreference` (refusal fallback),
3589
4695
  // which must keep seeing deprecated rows (R4.3, visibility-only filter).
3590
- modelInfos: catalogModels,
4696
+ modelInfos,
3591
4697
  configOptions,
3592
4698
  agents,
3593
4699
  currentAgent,
3594
4700
  fastModeEnabled,
3595
4701
  abortController,
3596
4702
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
3597
- contextWindowSize: inferContextWindowFromModel(models.currentModelId, currentModelInfo?.displayName, currentModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
4703
+ contextWindowSize,
3598
4704
  taskState,
3599
4705
  toolUseCache: {},
3600
4706
  emittedToolCalls: new Set(),
4707
+ liveBackgroundTasks: new Map(),
4708
+ emittedAssistantText: false,
4709
+ owedTrailingIdles: 0,
3601
4710
  messageIdToUuid: new Map(),
3602
4711
  };
3603
4712
  return {
@@ -3667,27 +4776,76 @@ function snapshotFromUsage(usage) {
3667
4776
  cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
3668
4777
  };
3669
4778
  }
3670
- function createEnvForGateway(request) {
4779
+ /**
4780
+ * Adapt a legacy gateway `authenticate` request into the shared
4781
+ * {@link ProviderConfig} shape. Returns `null` when no gateway request is
4782
+ * present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
4783
+ * otherwise anthropic.
4784
+ */
4785
+ function gatewayRequestToProviderConfig(request) {
3671
4786
  if (!request?._meta) {
4787
+ return null;
4788
+ }
4789
+ return {
4790
+ apiType: request.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
4791
+ baseUrl: request._meta.gateway.baseUrl,
4792
+ headers: request._meta.gateway.headers,
4793
+ };
4794
+ }
4795
+ /**
4796
+ * Map a resolved provider config into the Claude Code env vars that redirect API
4797
+ * traffic and inject headers. Returns an empty object when routing is
4798
+ * unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
4799
+ * skips its normal login/credential checks when a gateway is in use.
4800
+ */
4801
+ function createEnvForProvider(config) {
4802
+ if (!config) {
3672
4803
  return {};
3673
4804
  }
3674
- const customHeaders = Object.entries(request._meta.gateway.headers)
4805
+ const customHeaders = Object.entries(config.headers)
3675
4806
  .map(([key, value]) => `${key}: ${value}`)
3676
4807
  .join("\n");
3677
- if (request.methodId === "gateway-bedrock") {
4808
+ if (config.apiType === "bedrock") {
3678
4809
  return {
3679
4810
  CLAUDE_CODE_USE_BEDROCK: "1",
3680
4811
  AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
3681
- ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
4812
+ ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
4813
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
4814
+ };
4815
+ }
4816
+ if (config.apiType === "vertex") {
4817
+ // `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
4818
+ // validation; fall back to empty strings defensively.
4819
+ return {
4820
+ CLAUDE_CODE_USE_VERTEX: "1",
4821
+ ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
4822
+ ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
4823
+ CLOUD_ML_REGION: config.vertex?.region ?? "",
3682
4824
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3683
4825
  };
3684
4826
  }
3685
4827
  return {
3686
- ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
4828
+ ANTHROPIC_BASE_URL: config.baseUrl,
3687
4829
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3688
4830
  ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
3689
4831
  };
3690
4832
  }
4833
+ /**
4834
+ * Validate a provider base URL: must be a non-empty absolute http(s) URL.
4835
+ */
4836
+ function isValidBaseUrl(baseUrl) {
4837
+ if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
4838
+ return false;
4839
+ }
4840
+ let parsed;
4841
+ try {
4842
+ parsed = new URL(baseUrl);
4843
+ }
4844
+ catch {
4845
+ return false;
4846
+ }
4847
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
4848
+ }
3691
4849
  /**
3692
4850
  * Build the list of permission modes the agent will advertise for the given
3693
4851
  * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
@@ -3932,14 +5090,32 @@ thinkingEnabled) {
3932
5090
  // Claude Code CLI persists display strings like "opus[1m]" in settings,
3933
5091
  // but the SDK model list uses IDs like "claude-opus-4-6-1m".
3934
5092
  const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
5093
+ // The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m");
5094
+ // shared by the strip and canonicalize helpers below so the two can't drift.
5095
+ const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
5096
+ /** Remove context-window hints — the display form "[1m]" and the SDK id
5097
+ * suffix form "-1m" — from a model string. Those digits describe context
5098
+ * size, not model identity or generation version. */
5099
+ function stripContextHints(s) {
5100
+ return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
5101
+ }
5102
+ /** Canonicalize a model id for exact comparison: trimmed, lowercased, with
5103
+ * the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]").
5104
+ * The hint itself is kept — bare and 1M ids must stay distinct. */
5105
+ function canonicalizeModelId(s) {
5106
+ return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
5107
+ }
5108
+ /** The context hint a model string carries ("1m" for either spelling), or
5109
+ * null for a bare id. */
5110
+ function contextHintOf(s) {
5111
+ return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
5112
+ }
3935
5113
  // Captures a model family version: `4-6`/`4.7` for dated generations, or a
3936
5114
  // bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
3937
5115
  // `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
3938
5116
  const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
3939
5117
  function extractModelFamilyVersion(s) {
3940
- // Strip "[1m]"-style context hints first — that digit is context window
3941
- // size, not a model generation version.
3942
- const match = s.replace(/\[\d+m\]/gi, "").match(MODEL_FAMILY_VERSION_PATTERN);
5118
+ const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN);
3943
5119
  if (!match)
3944
5120
  return null;
3945
5121
  return match[2] ? `${match[1]}.${match[2]}` : match[1];
@@ -3992,25 +5168,38 @@ export function resolveModelPreference(models, preference) {
3992
5168
  if (!trimmed)
3993
5169
  return null;
3994
5170
  const lower = trimmed.toLowerCase();
3995
- // Exact match on value or display name
5171
+ // Exact match on value or display name. Values compare on the canonical
5172
+ // hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
5173
+ const canonicalPreference = canonicalizeModelId(trimmed);
3996
5174
  const directMatch = models.find((model) => model.value === trimmed ||
3997
- model.value.toLowerCase() === lower ||
5175
+ canonicalizeModelId(model.value) === canonicalPreference ||
3998
5176
  model.displayName.toLowerCase() === lower);
3999
5177
  if (directMatch)
4000
5178
  return directMatch;
4001
5179
  // Exact match on the alias's canonical resolved id (e.g. a pinned
4002
5180
  // "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
4003
5181
  // reported and unambiguous, so it's tried before the fuzzier tiers below.
4004
- // "default" is skipped first since it shares a resolvedModel with
4005
- // whichever alias the CLI currently recommends a specific pin should
4006
- // land on that named alias, not "default".
4007
- const resolvedMatch = models.find((model) => model.value !== "default" && model.resolvedModel?.toLowerCase() === lower) ?? models.find((model) => model.resolvedModel?.toLowerCase() === lower);
5182
+ // Compared on the canonical hint spelling so a "-1m"-suffix pin matches a
5183
+ // "[1m]"-spelled resolvedModel instead of falling into the substring tier
5184
+ // (which would land on the bare 200k sibling). "default" is skipped first
5185
+ // since it shares a resolvedModel with whichever alias the CLI currently
5186
+ // recommends — a specific pin should land on that named alias, not
5187
+ // "default".
5188
+ const matchesResolved = (model) => model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
5189
+ const resolvedMatch = models.find((model) => model.value !== "default" && matchesResolved(model)) ??
5190
+ models.find(matchesResolved);
4008
5191
  if (resolvedMatch)
4009
5192
  return resolvedMatch;
4010
- // Substring match
5193
+ // Substring match. Skips candidates whose context hint disagrees with the
5194
+ // preference's — a bare row must not absorb a 1M-hinted preference (nor
5195
+ // vice versa); such pairs fall through to the tokenized tier, which
5196
+ // weighs hints in its scoring and still finds the best same-family row.
5197
+ const preferenceHint = contextHintOf(trimmed);
4011
5198
  const includesMatch = models.find((model) => {
4012
5199
  if (!modelVersionsCompatible(trimmed, model))
4013
5200
  return false;
5201
+ if (contextHintOf(model.value) !== preferenceHint)
5202
+ return false;
4014
5203
  const value = model.value.toLowerCase();
4015
5204
  const display = model.displayName.toLowerCase();
4016
5205
  return value.includes(lower) || display.includes(lower) || lower.includes(value);
@@ -4034,6 +5223,51 @@ export function resolveModelPreference(models, preference) {
4034
5223
  }
4035
5224
  return bestMatch;
4036
5225
  }
5226
+ /** Map the live model reported by a resumed session onto the picker's model
5227
+ * list. The CLI restores a resumed session's model from the transcript's
5228
+ * last assistant message, which records the concrete API id (e.g.
5229
+ * "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order:
5230
+ * 1. Exact match with the Default entry's resolution — when a named alias
5231
+ * shares Default's resolvedModel verbatim, the live id can't tell the
5232
+ * two apart, and a never-customized session should stay on Default.
5233
+ * 2. Exact resolvedModel match on a named row. Checked before the
5234
+ * hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands
5235
+ * on the "sonnet[1m]" row rather than a Default that resolves to the
5236
+ * bare "claude-sonnet-5" — the two rows differ in context window, which
5237
+ * drives `contextWindowSize` and capability gating downstream.
5238
+ * 3. Hint-stripped match with Default's resolution — a session that never
5239
+ * left the default resumes as the bare transcript id, and shouldn't show
5240
+ * a concrete picker entry.
5241
+ * 4. `resolveModelPreference` over the picker entries.
5242
+ * 5. A model with no picker counterpart (e.g. excluded by an
5243
+ * `availableModels` allowlist) is tracked verbatim, mirroring
5244
+ * `syncModelAfterRefusalFallback`: the picker shows no selection, but the
5245
+ * model-dependent bookkeeping stays truthful to what the SDK is running. */
5246
+ export function matchResumedModel(models, liveModel) {
5247
+ const live = canonicalizeModelId(liveModel);
5248
+ const defaultEntry = models.find((m) => m.value === "default");
5249
+ const defaultResolved = defaultEntry?.resolvedModel
5250
+ ? canonicalizeModelId(defaultEntry.resolvedModel)
5251
+ : undefined;
5252
+ if (defaultEntry && defaultResolved === live) {
5253
+ return defaultEntry;
5254
+ }
5255
+ // No default-row exclusion needed: a default row matching `live` exactly
5256
+ // already returned at the tier above.
5257
+ const exactMatch = models.find((m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live);
5258
+ if (exactMatch)
5259
+ return exactMatch;
5260
+ if (defaultEntry &&
5261
+ defaultResolved &&
5262
+ stripContextHints(defaultResolved) === stripContextHints(live)) {
5263
+ return defaultEntry;
5264
+ }
5265
+ return (resolveModelPreference(models, liveModel) ?? {
5266
+ value: liveModel,
5267
+ displayName: liveModel,
5268
+ description: "",
5269
+ });
5270
+ }
4037
5271
  function resolveSettingsModel(models, settingsModel, logger) {
4038
5272
  if (settingsModel === undefined) {
4039
5273
  return null;
@@ -4161,6 +5395,21 @@ function hideDeprecatedModels(models, logger) {
4161
5395
  export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides, logger) {
4162
5396
  return hideDeprecatedModels(buildAllowlistedModels(sdkModels, allowlist, settingsModelOverrides), logger);
4163
5397
  }
5398
+ /** Read the model a resumed session is actually running (via the
5399
+ * `getContextUsage` control request — the same source `/context` prints) and
5400
+ * map it onto the picker. Best-effort: a control-request failure is logged
5401
+ * and returns null so callers keep their current choice; failing the whole
5402
+ * session/load over an unreadable report would be worse. */
5403
+ async function readResumedLiveModel(query, models, logger) {
5404
+ try {
5405
+ const liveModel = (await query.getContextUsage()).model;
5406
+ return liveModel ? matchResumedModel(models, liveModel) : null;
5407
+ }
5408
+ catch (error) {
5409
+ logger.error("Failed to read the resumed session's live model:", error);
5410
+ return null;
5411
+ }
5412
+ }
4164
5413
  async function getAvailableModels(query,
4165
5414
  /** Deprecation-UNfiltered catalog (allowlist-applied when configured):
4166
5415
  * preference resolution and the default pick read this list so a persisted
@@ -4170,14 +5419,15 @@ models,
4170
5419
  * rows (R4.2). The resolved `currentModelId` may legitimately be absent
4171
5420
  * from these rows (deprecated persisted preference) — the picker then
4172
5421
  * shows no selection, mirroring the refusal-fallback bookkeeping. */
4173
- pickerModels, sdkModels, settingsManager, logger) {
5422
+ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
4174
5423
  const settings = settingsManager.getSettings();
4175
5424
  let currentModel = models[0];
4176
5425
  let resolvedFromInput;
4177
5426
  // Model priority (highest to lowest):
4178
5427
  // 1. ANTHROPIC_MODEL environment variable
4179
5428
  // 2. settings.model (user configuration)
4180
- // 3. models[0] (default first model)
5429
+ // 3. the resumed session's live model (resumed sessions only)
5430
+ // 4. models[0] (default first model)
4181
5431
  if (process.env.ANTHROPIC_MODEL) {
4182
5432
  const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
4183
5433
  if (match) {
@@ -4192,21 +5442,49 @@ pickerModels, sdkModels, settingsManager, logger) {
4192
5442
  resolvedFromInput = settings.model;
4193
5443
  }
4194
5444
  }
5445
+ // A resumed session restores the model it was previously running (the CLI
5446
+ // re-reads it from the transcript), so without an env/settings override the
5447
+ // freshly-computed default above can disagree with what the session actually
5448
+ // runs — session/load then reports a model the session isn't using (issue
5449
+ // #845). Ask the CLI for the live model and reflect it. No `setModel` here:
5450
+ // the SDK is already running this model, and pushing a picker alias back
5451
+ // (e.g. "opus[1m]") could change the live model rather than describe it.
5452
+ if (resolvedFromInput === undefined && isResumedSession) {
5453
+ currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
5454
+ }
4195
5455
  // Skip the setModel round-trip when we can prove the SDK has already landed
4196
5456
  // on the same model. Two cases qualify:
4197
- // (a) No override applied — currentModel stayed at models[0]; the SDK is on
4198
- // its own default and we have nothing to sync.
5457
+ // (a) No override applied — currentModel is the SDK's own default (or, on
5458
+ // resume, the live model read back from the SDK above); nothing to sync.
4199
5459
  // (b) The resolver returned the user's input verbatim AND that value exists
4200
5460
  // in the SDK's original model list — meaning no fuzzy match or
4201
5461
  // allowlist rewrite was involved, and the SDK (which reads the same
4202
5462
  // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
5463
+ // This only holds for fresh sessions: a resumed session lands on the
5464
+ // transcript's model regardless of env/settings, so the override must
5465
+ // be re-asserted to keep the reported model truthful.
4203
5466
  // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
4204
5467
  // setModel call so we don't drift from the user's intended pin.
4205
5468
  const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
4206
5469
  const skipSetModel = resolvedFromInput === undefined ||
4207
- (currentModel.value === resolvedFromInput && sdkSawSameValue);
5470
+ (!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
4208
5471
  if (!skipSetModel) {
4209
- await query.setModel(currentModel.value);
5472
+ try {
5473
+ await query.setModel(currentModel.value);
5474
+ }
5475
+ catch (error) {
5476
+ // On a fresh session the pin is a defining option — fail loudly. A
5477
+ // resumed session already runs fine on the transcript's model, so
5478
+ // failing the whole session/load over the re-assert would be worse
5479
+ // than loading with the pin unapplied (mirrors the setPermissionMode
5480
+ // containment in createSession). The SDK then stayed on the
5481
+ // transcript's model, so read that back rather than reporting the
5482
+ // pin the session isn't running.
5483
+ if (!isResumedSession)
5484
+ throw error;
5485
+ logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
5486
+ currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
5487
+ }
4210
5488
  }
4211
5489
  return {
4212
5490
  availableModels: pickerModels.map((model) => ({
@@ -4399,10 +5677,12 @@ function isTaskTool(toolName) {
4399
5677
  toolName === "TaskList" ||
4400
5678
  toolName === "TaskGet");
4401
5679
  }
4402
- /** Whether a tool's tool_use surfaces to the client as a standalone
5680
+ /** Whether the streamed tool_use path surfaces this tool as a standalone
4403
5681
  * `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
4404
5682
  * suppressed (their plan snapshot is emitted at tool_result time), so neither
4405
- * produces a tool_call. */
5683
+ * produces a streamed tool_call/tool_call_update — which means a
5684
+ * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
5685
+ * resolved explicitly at tool_result time. */
4406
5686
  function shouldEmitToolCall(toolName) {
4407
5687
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
4408
5688
  }
@@ -4437,6 +5717,28 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
4437
5717
  ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
4438
5718
  };
4439
5719
  }
5720
+ /** Refine a pending tool call from the complete top-level fields recovered
5721
+ * from its still-streaming input. Shares `toolInfoFromToolUse` with the
5722
+ * consolidated path but never carries `content`: content built from partial
5723
+ * input is misleading (an Edit missing its `new_string` renders as a pure
5724
+ * deletion) or invalid (a Write diff without `content` lacks the required
5725
+ * `newText`), and the consolidated message supplies it moments later. */
5726
+ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
5727
+ // TodoWrite/Task* never surfaced a tool_call to refine (plan lane).
5728
+ if (!shouldEmitToolCall(toolUse.name)) {
5729
+ return undefined;
5730
+ }
5731
+ const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
5732
+ return {
5733
+ _meta: { claudeCode: { toolName: toolUse.name } },
5734
+ toolCallId: toolUse.id,
5735
+ sessionUpdate: "tool_call_update",
5736
+ rawInput: input,
5737
+ title,
5738
+ kind,
5739
+ ...(locations ? { locations } : {}),
5740
+ };
5741
+ }
4440
5742
  /**
4441
5743
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
4442
5744
  * Only handles text, image, and thinking chunks for now.
@@ -4468,14 +5770,23 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4468
5770
  }
4469
5771
  return [{ sessionId, update }];
4470
5772
  }
5773
+ // `tool_use_result` is message-level and carries no tool_use_id of its own:
5774
+ // it describes "the" tool_result block of the message it rode in on. If
5775
+ // several tool_result blocks were ever batched into one message it couldn't
5776
+ // be attributed, so it is only honored when the message carries exactly one.
5777
+ const toolUseResult = options?.toolUseResult !== undefined &&
5778
+ content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
5779
+ .length === 1
5780
+ ? options.toolUseResult
5781
+ : undefined;
4471
5782
  const output = [];
4472
5783
  // Only handle the first chunk for streaming; extend as needed for batching
4473
5784
  for (const chunk of content) {
4474
5785
  let update = null;
4475
5786
  switch (chunk.type) {
4476
5787
  case "text":
4477
- case "text_delta":
4478
- if (chunk.text.length > 0) {
5788
+ case "text_delta": {
5789
+ if (chunk.text) {
4479
5790
  update = {
4480
5791
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
4481
5792
  content: {
@@ -4485,6 +5796,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4485
5796
  };
4486
5797
  }
4487
5798
  break;
5799
+ }
4488
5800
  case "image":
4489
5801
  update = {
4490
5802
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
@@ -4497,10 +5809,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4497
5809
  };
4498
5810
  break;
4499
5811
  case "thinking":
4500
- case "thinking_delta":
5812
+ case "thinking_delta": {
4501
5813
  // Recent models default `thinking.display` to "omitted", which streams
4502
5814
  // signature-only thinking blocks whose text is empty.
4503
- if (chunk.thinking.length > 0) {
5815
+ if (chunk.thinking) {
4504
5816
  update = {
4505
5817
  sessionUpdate: "agent_thought_chunk",
4506
5818
  content: {
@@ -4510,6 +5822,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4510
5822
  };
4511
5823
  }
4512
5824
  break;
5825
+ }
4513
5826
  case "tool_use":
4514
5827
  case "server_tool_use":
4515
5828
  case "mcp_tool_use": {
@@ -4607,12 +5920,59 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4607
5920
  case "bash_code_execution_tool_result":
4608
5921
  case "text_editor_code_execution_tool_result":
4609
5922
  case "mcp_tool_result": {
5923
+ const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
4610
5924
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
4611
5925
  const toolUse = toolUseCache[chunk.tool_use_id];
4612
5926
  if (!toolUse) {
5927
+ // The permission flow may have surfaced this tool_call even though
5928
+ // its tool_use never reached the cache (e.g. the assistant message
5929
+ // carrying it was dropped by the cancelled-turn guard and a straggler
5930
+ // result landed later). Resolve the surfaced call anyway so it can't
5931
+ // stay pending in the client forever; without the cache entry the
5932
+ // tool name is unknown, so no claudeCode meta is attached.
5933
+ if (wasEmitted) {
5934
+ output.push({
5935
+ sessionId,
5936
+ update: {
5937
+ toolCallId: chunk.tool_use_id,
5938
+ sessionUpdate: "tool_call_update",
5939
+ status: "is_error" in chunk && chunk.is_error
5940
+ ? "failed"
5941
+ : "completed",
5942
+ rawOutput: chunk.content,
5943
+ },
5944
+ });
5945
+ }
4613
5946
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
4614
5947
  break;
4615
5948
  }
5949
+ // A permission request may have surfaced a plan-rendered (TodoWrite) or
5950
+ // suppressed (Task*) tool as a real tool_call so the request referenced
5951
+ // a tool call the client knows about (see `ensureToolCallEmitted`,
5952
+ // issue #851). The branches below never emit a tool_call_update for
5953
+ // those tools, which would leave the surfaced call pending in the
5954
+ // client forever — resolve it here. `wasEmitted` is only ever true for
5955
+ // these tools via the permission flow: the streamed plan/suppressed
5956
+ // branches don't record emissions.
5957
+ if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
5958
+ output.push({
5959
+ sessionId,
5960
+ update: {
5961
+ _meta: {
5962
+ claudeCode: {
5963
+ toolName: toolUse.name,
5964
+ ...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
5965
+ },
5966
+ },
5967
+ toolCallId: chunk.tool_use_id,
5968
+ sessionUpdate: "tool_call_update",
5969
+ status: "is_error" in chunk && chunk.is_error
5970
+ ? "failed"
5971
+ : "completed",
5972
+ rawOutput: chunk.content,
5973
+ },
5974
+ });
5975
+ }
4616
5976
  if (isTaskTool(toolUse.name)) {
4617
5977
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
4618
5978
  // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
@@ -4636,7 +5996,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4636
5996
  }
4637
5997
  }
4638
5998
  else if (toolUse.name !== "TodoWrite") {
4639
- const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
5999
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, toolUseResult);
4640
6000
  // When terminal output is supported, send terminal_output as a
4641
6001
  // separate notification to match codex-acp's streaming lifecycle:
4642
6002
  // 1. tool_call → _meta.terminal_info (already sent above)
@@ -4713,35 +6073,105 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4713
6073
  }
4714
6074
  export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
4715
6075
  const event = message.event;
6076
+ const streamKey = message.parent_tool_use_id ?? "";
6077
+ const streamedToolInputs = options?.streamedToolInputs;
6078
+ const forwardedOptions = {
6079
+ clientCapabilities: options?.clientCapabilities,
6080
+ parentToolUseId: message.parent_tool_use_id,
6081
+ cwd: options?.cwd,
6082
+ taskState: options?.taskState,
6083
+ emittedToolCalls: options?.emittedToolCalls,
6084
+ messageId: options?.messageId,
6085
+ };
4716
6086
  switch (event.type) {
4717
- case "content_block_start":
4718
- return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
4719
- clientCapabilities: options?.clientCapabilities,
4720
- parentToolUseId: message.parent_tool_use_id,
4721
- cwd: options?.cwd,
4722
- taskState: options?.taskState,
4723
- emittedToolCalls: options?.emittedToolCalls,
4724
- messageId: options?.messageId,
4725
- });
4726
- case "content_block_delta":
4727
- return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
4728
- clientCapabilities: options?.clientCapabilities,
4729
- parentToolUseId: message.parent_tool_use_id,
4730
- cwd: options?.cwd,
4731
- taskState: options?.taskState,
4732
- emittedToolCalls: options?.emittedToolCalls,
4733
- messageId: options?.messageId,
4734
- });
6087
+ case "content_block_start": {
6088
+ const block = event.content_block;
6089
+ if (streamedToolInputs &&
6090
+ (block.type === "tool_use" ||
6091
+ block.type === "server_tool_use" ||
6092
+ block.type === "mcp_tool_use")) {
6093
+ let inputsForMessage = streamedToolInputs.get(streamKey);
6094
+ if (!inputsForMessage) {
6095
+ inputsForMessage = new Map();
6096
+ streamedToolInputs.set(streamKey, inputsForMessage);
6097
+ }
6098
+ inputsForMessage.set(event.index, {
6099
+ id: block.id,
6100
+ name: block.name,
6101
+ partialJson: "",
6102
+ scannedTo: 0,
6103
+ inString: false,
6104
+ escaped: false,
6105
+ objectDepth: 0,
6106
+ arrayDepth: 0,
6107
+ lastTopLevelComma: -1,
6108
+ emittedThroughComma: -1,
6109
+ });
6110
+ }
6111
+ return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
6112
+ }
6113
+ case "content_block_delta": {
6114
+ if (event.delta.type === "input_json_delta") {
6115
+ const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
6116
+ if (!streamedInput)
6117
+ return [];
6118
+ streamedInput.partialJson += event.delta.partial_json;
6119
+ if (scanStreamedToolInput(streamedInput)) {
6120
+ // Input complete: the consolidated assistant message replays the
6121
+ // block with its full input and refines the call there; emitting
6122
+ // here too would send a duplicate identical update.
6123
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
6124
+ inputsForMessage?.delete(event.index);
6125
+ if (inputsForMessage?.size === 0)
6126
+ streamedToolInputs?.delete(streamKey);
6127
+ return [];
6128
+ }
6129
+ if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
6130
+ return [];
6131
+ }
6132
+ streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
6133
+ const input = recoveredToolInput(streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma));
6134
+ if (!input)
6135
+ return [];
6136
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
6137
+ const update = streamedInputRefinement(streamedInput, input, supportsTerminalOutput, options?.cwd);
6138
+ if (!update)
6139
+ return [];
6140
+ if (message.parent_tool_use_id) {
6141
+ update._meta = {
6142
+ ...update._meta,
6143
+ claudeCode: {
6144
+ ...(update._meta?.claudeCode || {}),
6145
+ parentToolUseId: message.parent_tool_use_id,
6146
+ },
6147
+ };
6148
+ }
6149
+ applyMessageId(update, options?.messageId);
6150
+ return [{ sessionId, update }];
6151
+ }
6152
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
6153
+ }
4735
6154
  // No content. `ping` is a Messages-API keep-alive event that the SDK's
4736
6155
  // `BetaRawMessageStreamEvent` union doesn't include even though the
4737
6156
  // wire format emits it; the `as never` cast lets us no-op it here
4738
6157
  // instead of letting it fall through to `unreachable`.
4739
6158
  case "ping":
4740
- case "message_start":
4741
6159
  case "message_delta":
6160
+ return [];
6161
+ // A message boundary ends every input stream on this lane: message_stop is
6162
+ // the normal end, and a message_start clears anything a prior message on
6163
+ // the lane left behind (e.g. a stream cut short mid-block).
6164
+ case "message_start":
4742
6165
  case "message_stop":
4743
- case "content_block_stop":
6166
+ streamedToolInputs?.delete(streamKey);
4744
6167
  return [];
6168
+ case "content_block_stop": {
6169
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
6170
+ inputsForMessage?.delete(event.index);
6171
+ if (inputsForMessage?.size === 0)
6172
+ streamedToolInputs?.delete(streamKey);
6173
+ return [];
6174
+ }
4745
6175
  default:
4746
6176
  unreachable(event, logger);
4747
6177
  return [];
@@ -4799,6 +6229,9 @@ export function runAcp() {
4799
6229
  .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
4800
6230
  .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
4801
6231
  .onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
6232
+ .onRequest(methods.agent.providers.list, (ctx) => agent.unstable_listProviders(ctx.params))
6233
+ .onRequest(methods.agent.providers.set, (ctx) => agent.unstable_setProvider(ctx.params))
6234
+ .onRequest(methods.agent.providers.disable, (ctx) => agent.unstable_disableProvider(ctx.params))
4802
6235
  .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
4803
6236
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
4804
6237
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
@@ -4820,12 +6253,14 @@ function commonPrefixLength(a, b) {
4820
6253
  * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
4821
6254
  * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
4822
6255
  * matching things like "10m" or embedded substrings. Semantic aliases like
4823
- * `default` carry no such token in the ID, but the SDK's human-facing
4824
- * `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
4825
- * pass those too the same `\b1m\b` token appears in "1M context". The SDK's
4826
- * `ModelInfo` exposes no structured context-window field, so this text scan is
4827
- * the only pre-`result` signal available. A miss falls back to the default
4828
- * window and is corrected by `result.modelUsage` within one turn. */
6256
+ * `default` carry no such token in the ID, but their `resolvedModel` and the
6257
+ * SDK's human-facing `displayName`/`description` can (e.g.
6258
+ * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
6259
+ * This text scan can't catch every model some resolve to extended-context
6260
+ * models with no "1m" anywhere (e.g. `sonnet` claude-sonnet-5, natively
6261
+ * ~1M) which is why `fetchContextWindowSize` is preferred wherever a live
6262
+ * query is available. A miss falls back to the default window and is
6263
+ * corrected by `result.modelUsage` within one turn. */
4829
6264
  function inferContextWindowFromModel(...texts) {
4830
6265
  if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
4831
6266
  return 1_000_000;
@@ -4835,12 +6270,7 @@ function inferContextWindowFromModel(...texts) {
4835
6270
  * `getContextUsage` control request. Unlike the per-message API usage numbers
4836
6271
  * (which only count message tokens), this `totalTokens` includes the system
4837
6272
  * prompt, tool schemas, MCP tools, and memory-file overhead — the real
4838
- * occupancy the user sees. Returns `null` on any control-request failure.
4839
- *
4840
- * Note: we deliberately do NOT use this response's window fields for `size`.
4841
- * They have been observed to under-report extended (1M) context windows, so
4842
- * the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
4843
- * which handle the 1M variants correctly. */
6273
+ * occupancy the user sees. Returns `null` on any control-request failure. */
4844
6274
  async function fetchContextUsedTokens(query, logger) {
4845
6275
  try {
4846
6276
  const usage = await query.getContextUsage();
@@ -4851,6 +6281,28 @@ async function fetchContextUsedTokens(query, logger) {
4851
6281
  return null;
4852
6282
  }
4853
6283
  }
6284
+ /** Fetch the current model's full context window (`rawMaxTokens`) via the
6285
+ * `getContextUsage` control request — the same source `/context` prints.
6286
+ * This is the only pre-`result` signal that covers semantic aliases whose
6287
+ * text carries no "1m" token (e.g. `sonnet` → claude-sonnet-5, natively
6288
+ * ~1M), so it's the primary window source at session creation and on model
6289
+ * switches; `inferContextWindowFromModel` remains the fallback. A returned
6290
+ * window is still superseded by each `result.modelUsage.contextWindow`.
6291
+ *
6292
+ * (Older CLIs under-reported extended 1M windows here — commit 20ef663
6293
+ * dropped the field for that reason — but the CLI vendored by the pinned
6294
+ * SDK reports them correctly again.) Returns `null` on any control-request
6295
+ * failure or a nonsensical (non-positive) window. */
6296
+ async function fetchContextWindowSize(query, logger) {
6297
+ try {
6298
+ const usage = await query.getContextUsage();
6299
+ return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null;
6300
+ }
6301
+ catch (error) {
6302
+ logger.error("Failed to fetch context window size from SDK:", error);
6303
+ return null;
6304
+ }
6305
+ }
4854
6306
  function parseModelConfig(raw) {
4855
6307
  if (!raw)
4856
6308
  return undefined;