claude-code-acp-ts 0.57.0 → 0.59.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
@@ -48,6 +48,47 @@ const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
48
48
  * completed/stalled without the host turn resolving (issue #825). */
49
49
  const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
50
50
  "(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
51
+ /** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
52
+ * model did on its own (a task-notification followup, a peer/coordinator/
53
+ * observer message it handled) rather than the user's prompt. Absent,
54
+ * `human`, and `channel` origins are the user's own turn (this adapter's
55
+ * prompts arrive as the ACP channel on some CLI configurations — ALL
56
+ * channel servers are treated as user, so a foreign channel integration's
57
+ * autonomously-handled result is misclassified as the user's; accepted,
58
+ * see below), and `auto-continuation` continues the user's turn, so its
59
+ * result is the turn's real terminal.
60
+ *
61
+ * Deliberately fail-OPEN: an unknown future kind defaults to the user
62
+ * lane. Misrouting a USER result into the autonomous lane hangs the
63
+ * prompt un-detectably (the result is skipped, its trailing idle absorbed
64
+ * as owed, so the #825 detector can't fire); misrouting an autonomous
65
+ * result into the user lane is the bounded misattribution class this set
66
+ * exists to reduce. */
67
+ const AUTONOMOUS_RESULT_ORIGINS = new Set([
68
+ "task-notification",
69
+ "peer",
70
+ "coordinator",
71
+ "observer",
72
+ "observer-activity",
73
+ ]);
74
+ /** Whether this turn's terminal result arrived but its settlement is being
75
+ * held for background subagents it spawned (see Turn.deferredSettle). The
76
+ * single spelling of the hold predicate, shared by the consumer's settle
77
+ * lanes and cancel(). */
78
+ function isHeldOpen(turn) {
79
+ return turn != null && turn.deferredSettle !== undefined && !turn.settled;
80
+ }
81
+ /** Disarm the force-cancel backstop (see Session.forceCancelTimer). Every
82
+ * path that settles the active turn must run this so a timer can never fire
83
+ * on an already-settled turn — and must leave the field undefined, or the
84
+ * arm site's !forceCancelTimer guard would refuse to arm the backstop for
85
+ * the NEXT turn's cancel. */
86
+ function disarmForceCancel(session) {
87
+ if (session.forceCancelTimer) {
88
+ clearTimeout(session.forceCancelTimer);
89
+ session.forceCancelTimer = undefined;
90
+ }
91
+ }
51
92
  /** Compute a stable fingerprint of the session-defining params so we can
52
93
  * detect when a loadSession/resumeSession call requires tearing down and
53
94
  * recreating the underlying Query process. MCP servers are sorted by name
@@ -56,6 +97,78 @@ function computeSessionFingerprint(params) {
56
97
  const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
57
98
  return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
58
99
  }
100
+ /**
101
+ * The single provider ID this agent exposes via `providers/*`. Claude Code has
102
+ * one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
103
+ * is exactly one configurable provider.
104
+ */
105
+ const PROVIDER_ID = "main";
106
+ /**
107
+ * Protocols the `main` provider can be configured with. These mirror the
108
+ * env-var mappings understood by {@link createEnvForProvider}.
109
+ */
110
+ const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
111
+ /**
112
+ * Advance the lexer state across the fragment appended since the last delta:
113
+ * just enough JSON awareness (string/escape, nesting depth) to spot commas
114
+ * that sit at the top level of the input object — everything before such a
115
+ * comma is a set of complete fields. Returns true once the input object's
116
+ * closing brace arrives.
117
+ */
118
+ function scanStreamedToolInput(state) {
119
+ let complete = false;
120
+ for (let index = state.scannedTo; index < state.partialJson.length; index++) {
121
+ const character = state.partialJson[index];
122
+ if (state.inString) {
123
+ if (state.escaped) {
124
+ state.escaped = false;
125
+ }
126
+ else if (character === "\\") {
127
+ state.escaped = true;
128
+ }
129
+ else if (character === '"') {
130
+ state.inString = false;
131
+ }
132
+ continue;
133
+ }
134
+ if (character === '"') {
135
+ state.inString = true;
136
+ }
137
+ else if (character === "{") {
138
+ state.objectDepth++;
139
+ }
140
+ else if (character === "}") {
141
+ state.objectDepth--;
142
+ if (state.objectDepth === 0) {
143
+ complete = true;
144
+ }
145
+ }
146
+ else if (character === "[") {
147
+ state.arrayDepth++;
148
+ }
149
+ else if (character === "]") {
150
+ state.arrayDepth--;
151
+ }
152
+ else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
153
+ state.lastTopLevelComma = index;
154
+ }
155
+ }
156
+ state.scannedTo = state.partialJson.length;
157
+ return complete;
158
+ }
159
+ /** Parse the complete top-level fields before a top-level comma by closing the
160
+ * object at that boundary. */
161
+ function recoveredToolInput(prefix) {
162
+ try {
163
+ const value = JSON.parse(prefix + "}");
164
+ return value !== null && typeof value === "object" && !Array.isArray(value)
165
+ ? value
166
+ : undefined;
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ }
59
172
  export async function claudeCliPath() {
60
173
  if (process.env.CLAUDE_CODE_EXECUTABLE) {
61
174
  return process.env.CLAUDE_CODE_EXECUTABLE;
@@ -188,6 +301,36 @@ export function stripLocalCommandMetadata(content) {
188
301
  export function isLocalCommandMetadata(content) {
189
302
  return stripLocalCommandMetadata(content) === null;
190
303
  }
304
+ /**
305
+ * True for the synthetic assistant message the CLI injects into the transcript
306
+ * when a turn fails authentication (e.g. "Not logged in · Please run /login",
307
+ * "Session expired. Please run /login to sign in again."). The `/login`
308
+ * instruction is Claude Code TUI-specific and meaningless to ACP clients
309
+ * (issue #863). The live prompt loop suppresses the text and fails the turn
310
+ * with `authRequired` so the client can run its own auth flow; replay must
311
+ * skip it too — both for parity with what the client saw live and because the
312
+ * message stays in the transcript forever, so it would resurface on every
313
+ * session/load even after the user has logged back in.
314
+ *
315
+ * Takes the API message (`message.message`), which replay only knows as
316
+ * `unknown`. The persisted record's structured `error: "authentication_failed"`
317
+ * marker is stripped by `getSessionMessages`, so the synthetic model + text is
318
+ * all both paths have to match on.
319
+ */
320
+ export function isSyntheticLoginMessage(apiMessage) {
321
+ if (!apiMessage || typeof apiMessage !== "object") {
322
+ return false;
323
+ }
324
+ const { model, content } = apiMessage;
325
+ if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
326
+ return false;
327
+ }
328
+ const block = content[0];
329
+ return (!!block &&
330
+ block.type === "text" &&
331
+ typeof block.text === "string" &&
332
+ block.text.includes("Please run /login"));
333
+ }
191
334
  const PERMISSION_MODE_ALIASES = {
192
335
  auto: "auto",
193
336
  default: "default",
@@ -301,6 +444,10 @@ export class ClaudeAcpAgent {
301
444
  clientCapabilities;
302
445
  logger;
303
446
  gatewayAuthRequest;
447
+ /** Client-managed LLM routing set via `providers/set`. Process-scoped and
448
+ * never persisted to disk (see the Configurable LLM Providers RFD). When
449
+ * set, it takes precedence over {@link gatewayAuthRequest}. */
450
+ providerConfig;
304
451
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
305
452
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
306
453
  * tests can shrink it. */
@@ -426,6 +573,10 @@ export class ClaudeAcpAgent {
426
573
  auth: {
427
574
  logout: {},
428
575
  },
576
+ // Client-managed LLM routing via `providers/list`, `providers/set`, and
577
+ // `providers/disable`. Advertised unconditionally; there is no client
578
+ // capability prerequisite for the provider methods.
579
+ providers: {},
429
580
  loadSession: true,
430
581
  sessionCapabilities: {
431
582
  additionalDirectories: {},
@@ -549,11 +700,94 @@ export class ClaudeAcpAgent {
549
700
  }
550
701
  throw new Error("Method not implemented.");
551
702
  }
703
+ /**
704
+ * `providers/list` — returns the single client-configurable custom gateway
705
+ * provider (`main`). `current` carries only non-secret routing (never headers,
706
+ * which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
707
+ * display, and is `null` when the provider is not configured/disabled. The
708
+ * provider is optional (`required: false`): while disabled/unconfigured the
709
+ * agent falls back to its own default routing (normal Claude login).
710
+ */
711
+ async unstable_listProviders(_params) {
712
+ const config = this.resolveProviderConfig();
713
+ const provider = {
714
+ providerId: PROVIDER_ID,
715
+ supported: SUPPORTED_PROTOCOLS,
716
+ required: false,
717
+ current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
718
+ };
719
+ return { providers: [provider] };
720
+ }
721
+ /**
722
+ * `providers/set` — replace the full configuration for the `main` provider.
723
+ * Rejects unknown IDs, unsupported protocols, and empty/invalid base URLs with
724
+ * `invalid_params`. Config is process-scoped and applies to sessions created or
725
+ * loaded after this call.
726
+ */
727
+ async unstable_setProvider(params) {
728
+ if (params.providerId !== PROVIDER_ID) {
729
+ throw RequestError.invalidParams({ providerId: params.providerId }, `Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`);
730
+ }
731
+ if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
732
+ throw RequestError.invalidParams({ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS }, `Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`);
733
+ }
734
+ if (!isValidBaseUrl(params.baseUrl)) {
735
+ throw RequestError.invalidParams({ baseUrl: params.baseUrl }, "baseUrl must be a non-empty absolute http(s) URL.");
736
+ }
737
+ const config = {
738
+ apiType: params.apiType,
739
+ baseUrl: params.baseUrl,
740
+ headers: params.headers ?? {},
741
+ };
742
+ // Vertex requires project + region, which the standard payload cannot
743
+ // carry, so they arrive via `_meta.claudeCode.vertex`.
744
+ if (params.apiType === "vertex") {
745
+ const vertex = params._meta?.claudeCode?.vertex;
746
+ if (!vertex ||
747
+ typeof vertex.projectId !== "string" ||
748
+ vertex.projectId.trim() === "" ||
749
+ typeof vertex.region !== "string" ||
750
+ vertex.region.trim() === "") {
751
+ throw RequestError.invalidParams(undefined, "vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.");
752
+ }
753
+ config.vertex = { projectId: vertex.projectId, region: vertex.region };
754
+ }
755
+ this.providerConfig = config;
756
+ return {};
757
+ }
758
+ /**
759
+ * `providers/disable` — disabling the `main` provider clears any client-managed
760
+ * routing (both a `providers/set` config and the legacy gateway auth request),
761
+ * so the agent reverts to its own default routing and `providers/list` reports
762
+ * `current: null`. Disabling any other (unknown) ID is treated as a successful
763
+ * no-op per the RFD's idempotency rule.
764
+ */
765
+ async unstable_disableProvider(params) {
766
+ if (params.providerId === PROVIDER_ID) {
767
+ this.providerConfig = undefined;
768
+ this.gatewayAuthRequest = undefined;
769
+ }
770
+ // Unknown provider: idempotent success.
771
+ return {};
772
+ }
773
+ /**
774
+ * Resolve the effective client-managed routing config. `providers/set` takes
775
+ * precedence; otherwise fall back to the legacy gateway auth request. Returns
776
+ * `null` when neither is configured.
777
+ */
778
+ resolveProviderConfig() {
779
+ if (this.providerConfig) {
780
+ return this.providerConfig;
781
+ }
782
+ return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
783
+ }
552
784
  async logout(_params) {
553
- // Clear in-memory gateway credentials supplied via `authenticate`. The
554
- // gateway method never touches the on-disk credential store, so dropping
555
- // this reference is the whole logout for that path.
785
+ // Clear in-memory gateway credentials supplied via `authenticate` and any
786
+ // provider routing set via `providers/set`. Neither touches the on-disk
787
+ // credential store, so dropping these references is the whole logout for
788
+ // those paths.
556
789
  this.gatewayAuthRequest = undefined;
790
+ this.providerConfig = undefined;
557
791
  // For the Claude/Console login methods the credentials live in the native
558
792
  // CLI's store (keychain or config dir), which only the binary can clear.
559
793
  // `claude auth logout` is non-interactive and idempotent.
@@ -666,23 +900,34 @@ export class ClaudeAcpAgent {
666
900
  // gateways that don't carry a stable/matching id across the stream and the
667
901
  // consolidated message. Reset after each consolidated message consumes it.
668
902
  const streamedBlocks = [];
903
+ // Tool-use blocks start streaming before their JSON input. Keep the
904
+ // partial input per parent message and block index so completed top-level
905
+ // fields can refine the pending tool call while it streams. Entries are
906
+ // dropped at block/message boundaries; the whole map is swept when a turn
907
+ // settles, since an interrupted subagent stream (keyed by a
908
+ // parent_tool_use_id that never recurs) has no boundary event of its own.
909
+ const streamedToolInputs = new Map();
669
910
  // Stop reason accumulated for the active turn (result subtype, refusal,
670
911
  // max_tokens, …). Reset per turn; read when the turn settles at idle.
671
912
  let stopReason = "end_turn";
672
- // How many trailing `session_state_changed: idle` messages are already
673
- // accounted for: every user-turn result that terminates a turn (settle,
674
- // reject, or orphan skip) is followed by one, as is a cancelled turn
675
- // settled by the next turn's echo hand-off. The idle handler absorbs owed
676
- // idles; an idle that arrives when NONE is owed while the active turn is
677
- // still unsettled means the SDK ended the turn without ever emitting its
678
- // result, so the turn will never settle on its own (issue #825).
679
- // Stream-level debt, deliberately NOT reset per turn: a lagged idle can
680
- // arrive after the next turn has already activated (issue #773), and the
681
- // debt is what attributes it to the turn that owed it. Over-counting (an
682
- // idle the SDK never emits, e.g. CLI binaries without session-state
683
- // events — issue #497) is benign: the counter just absorbs one future
684
- // idle, and detection degrades to the status quo rather than misfiring.
685
- let owedTrailingIdles = 0;
913
+ /** The consumer's single send chokepoint: every `sessionUpdate` in this
914
+ * loop goes through here (never `this.client.sessionUpdate` directly) so
915
+ * answer-delivery tracking is a property of sending, not something each
916
+ * emission site must remember. A top-level `agent_message_chunk` marks
917
+ * the stretch's answer as delivered; subagent-attributed chunks are
918
+ * recognizable by the `parentToolUseId` meta that toAcpNotifications
919
+ * stamps from `parent_tool_use_id`, and never reach the top-level feed
920
+ * as the turn's answer. */
921
+ const sendUpdate = async (notification) => {
922
+ const { update } = notification;
923
+ if (update.sessionUpdate === "agent_message_chunk") {
924
+ const claudeMeta = update._meta?.claudeCode;
925
+ if (!claudeMeta?.parentToolUseId) {
926
+ session.emittedAssistantText = true;
927
+ }
928
+ }
929
+ await this.client.sessionUpdate(notification);
930
+ };
686
931
  const resetTurnScratch = () => {
687
932
  lastAssistantTotalUsage = null;
688
933
  lastAssistantUsage = null;
@@ -718,6 +963,31 @@ export class ClaudeAcpAgent {
718
963
  session.activeTurn = turn;
719
964
  session.cancelled = false;
720
965
  session.pendingOrphanResults = 0;
966
+ session.orphanCommands?.clear();
967
+ // Two-phase sweep of registry entries the level signal ended (see
968
+ // the endedPerLevel field doc): armed at the first activation,
969
+ // deleted at the second — the same activation-time self-heal as the
970
+ // orphan lanes, and the growth bound for leaked entries whose settle
971
+ // bookends never arrive. The one-activation grace lets a corrective
972
+ // inclusive level rescue a live async agent that a racing payload
973
+ // absent-marked (deletion is irreversible: levels never ADD entries).
974
+ // Local-only commands don't advance the clock: two quick /context
975
+ // calls would otherwise burn the whole grace in seconds of wall time
976
+ // while the corrective level is still in flight, and they interact
977
+ // with no tasks — a later real turn still bounds growth.
978
+ if (!turn.isLocalOnlyCommand) {
979
+ for (const [taskId, record] of session.liveBackgroundTasks) {
980
+ if (!record.endedPerLevel) {
981
+ continue;
982
+ }
983
+ if (record.endedPerLevel === "sweep-armed") {
984
+ session.liveBackgroundTasks.delete(taskId);
985
+ }
986
+ else {
987
+ record.endedPerLevel = "sweep-armed";
988
+ }
989
+ }
990
+ }
721
991
  resetTurnScratch();
722
992
  };
723
993
  /** Ensure there is an active turn before a user-turn result that carries no
@@ -737,18 +1007,165 @@ export class ClaudeAcpAgent {
737
1007
  * result), so we skip those and only promote once the count is drained. */
738
1008
  const ensureActiveTurn = () => {
739
1009
  if (session.activeTurn) {
740
- return;
741
- }
742
- const head = (session.turnQueue ?? []).find((t) => !t.settled);
743
- if (!head) {
744
- return;
1010
+ if (!isHeldOpen(session.activeTurn)) {
1011
+ return;
1012
+ }
1013
+ // A held turn (Turn.deferredSettle) already produced its result, so
1014
+ // this incoming user-turn result cannot be its — it belongs to the
1015
+ // next queued command (an echo-less one, e.g. `/context` sent while
1016
+ // the hold drains; a normal prompt's echo would have handed the held
1017
+ // turn off before its result). Settle the held turn with its
1018
+ // recorded outcome — the user moving on outranks the hold, same
1019
+ // contract as the echo hand-off — and fall through to promote the
1020
+ // queue head, which this result belongs to. Without this, the head
1021
+ // would never be promoted (echo-less turns have no other promotion
1022
+ // path) and its prompt would hang, while this result's outcome
1023
+ // overwrote the held turn's. Orphan lanes below are necessarily
1024
+ // empty while a turn is held: orphans are seeded by cancel(), which
1025
+ // inline-settles a held turn, and activation cleared older ones.
1026
+ // settleActive also closes the held turn's delivery stretch, so the
1027
+ // promoted command's own delivery decision is not judged against the
1028
+ // held turn's followup text (issue #453) — the caller snapshots the
1029
+ // flag AFTER this runs.
1030
+ settleActive(session.activeTurn.deferredSettle);
745
1031
  }
1032
+ // Orphan accounting runs BEFORE the head check: an orphan's echo-less
1033
+ // result can arrive with an EMPTY queue (the common post-cancel
1034
+ // timeline — the active turn settled at the interrupt's idle and the
1035
+ // user hasn't typed yet), and it must still be consumed here. Skipping
1036
+ // the bookkeeping when there is nothing to promote would leave a
1037
+ // phantom entry/count that swallows the next live echo-less result
1038
+ // (e.g. /compact) instead.
746
1039
  if ((session.pendingOrphanResults ?? 0) > 0) {
747
1040
  session.pendingOrphanResults--;
748
1041
  return;
749
1042
  }
1043
+ // msg_lifecycle_v1 lane. Attribute this echo-less result using the
1044
+ // entries' states — turns run sequentially and frames arrive in stream
1045
+ // order, so at any result: every "zombie" is from an already-dead turn
1046
+ // whose own result already passed before the frame that created the
1047
+ // newest entry (or never existed), every "started" entry was dispatched
1048
+ // into THE turn that emitted this result (an older turn's entries got
1049
+ // their terminal frames before a newer turn's "started" frames), and a
1050
+ // "pending" entry was not dispatched before it. One result therefore
1051
+ // covers ALL started and zombie entries at once (N coalesced commands
1052
+ // share ONE result); their outstanding terminal frames then no-op on
1053
+ // the missing entries. NOTE this ordering argument is asserted from
1054
+ // observed CLI behavior, not a documented wire contract — if a dead
1055
+ // turn's late result could lag past the NEXT turn's dispatch frames,
1056
+ // deleting a zombie and a started entry on one result would
1057
+ // double-consume it. The unexpected-transition logging in the frame
1058
+ // handler is the tripwire for that class of drift.
1059
+ if (session.orphanCommands?.size) {
1060
+ let consumedOrphanResult = false;
1061
+ let oldestPending;
1062
+ for (const [uuid, state] of session.orphanCommands) {
1063
+ if (state === "started" || state === "zombie") {
1064
+ consumedOrphanResult = true;
1065
+ session.orphanCommands.delete(uuid);
1066
+ }
1067
+ else {
1068
+ oldestPending ??= uuid;
1069
+ }
1070
+ }
1071
+ if (consumedOrphanResult) {
1072
+ return;
1073
+ }
1074
+ if (oldestPending !== undefined) {
1075
+ // No dispatch was seen before this result, so it is very likely a
1076
+ // live turn's — but a lost "started" frame would mean it IS the
1077
+ // orphan's (dup-over-loss: prefer one wrong skip over
1078
+ // misattributing a dead turn's outcome to a live prompt). Grant
1079
+ // each pending entry exactly one skip, like the count lane did.
1080
+ session.orphanCommands.delete(oldestPending);
1081
+ return;
1082
+ }
1083
+ }
1084
+ const head = firstUnsettledQueuedTurn();
1085
+ if (!head) {
1086
+ return;
1087
+ }
750
1088
  activateTurn(head);
751
1089
  };
1090
+ /** Result-time bookkeeping that must run whether or not the result can be
1091
+ * attributed to a turn. (1) Latch `commandResultSeen` on every queued
1092
+ * turn whose command is known dispatched with no terminal frame yet —
1093
+ * the emitting turn is the one it was dispatched (possibly folded) into,
1094
+ * so its result has now passed; a later cancel() must not seed an orphan
1095
+ * entry that waits for it (see Turn.commandResultSeen). (2) When a turn
1096
+ * is ACTIVE, the result is attributed to it and never reaches
1097
+ * ensureActiveTurn — but it still covers the map's started entries
1098
+ * (commands folded into the active turn share its result) and zombies
1099
+ * (their late results have already passed or never existed), so drain
1100
+ * them here or they would zombify/linger and swallow a later live
1101
+ * echo-less result. */
1102
+ const recordResultForOrphanCommands = () => {
1103
+ for (const turn of session.turnQueue ?? []) {
1104
+ if (!turn.settled && turn.commandStarted && !turn.commandFinished) {
1105
+ turn.commandResultSeen = true;
1106
+ }
1107
+ }
1108
+ if (session.activeTurn && session.orphanCommands?.size) {
1109
+ for (const [uuid, state] of session.orphanCommands) {
1110
+ if (state === "started" || state === "zombie") {
1111
+ session.orphanCommands.delete(uuid);
1112
+ }
1113
+ }
1114
+ }
1115
+ };
1116
+ /** The unsettled in-flight turn owning this prompt uuid, if any. */
1117
+ const findUnsettledTurn = (uuid) => (session.turnQueue ?? []).find((t) => t.promptUuid === uuid && !t.settled);
1118
+ /** The first queued turn still awaiting its outcome, if any — the single
1119
+ * spelling of "a prompt is pending" shared by the head promotion and
1120
+ * the autonomous stretch-close guard. */
1121
+ const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
1122
+ /** Whether any background subagent this turn spawned is still live —
1123
+ * while true, the turn's settlement stays deferred so the subagent's
1124
+ * output and permission requests land inside it (see
1125
+ * Turn.deferredSettle). */
1126
+ const turnAwaitingSubagents = (turn) => {
1127
+ if (!turn.spawnedTaskIds?.size) {
1128
+ return false;
1129
+ }
1130
+ for (const taskId of turn.spawnedTaskIds) {
1131
+ const record = session.liveBackgroundTasks.get(taskId);
1132
+ // The isSubagent read is defense in depth for the shells-never-defer
1133
+ // contract: spawnedTaskIds only ever holds subagent ids today, but a
1134
+ // future add site must not silently let a long-lived shell hold a
1135
+ // prompt open. endedPerLevel entries are kept for attribution only —
1136
+ // the level signal says the task is gone (or its bookends were
1137
+ // lost), so a hold must not wait on them.
1138
+ if (record?.isSubagent && !record.endedPerLevel) {
1139
+ return true;
1140
+ }
1141
+ }
1142
+ return false;
1143
+ };
1144
+ /** Settle the active turn's stored deferred outcome once none of its
1145
+ * spawned subagents is live. The single drain rule shared by the
1146
+ * followup-result and idle settle sites, so the two lanes can't drift. */
1147
+ const settleDeferredIfDrained = () => {
1148
+ const turn = session.activeTurn;
1149
+ if (isHeldOpen(turn) && !turnAwaitingSubagents(turn)) {
1150
+ settleActive(turn.deferredSettle);
1151
+ }
1152
+ };
1153
+ /** Settle the active turn with `outcome` now — unless subagents it
1154
+ * spawned are still live, in which case store the outcome and hold the
1155
+ * turn open (see Turn.deferredSettle). Every result-time settle of a
1156
+ * turn that can have spawned subagents must route through here: a site
1157
+ * calling settleActive directly bypasses the hold and re-opens the
1158
+ * out-of-turn permission deadlock (issue #866) through its lane. */
1159
+ const settleOrDefer = (outcome) => {
1160
+ if (session.activeTurn &&
1161
+ !session.activeTurn.settled &&
1162
+ turnAwaitingSubagents(session.activeTurn)) {
1163
+ session.activeTurn.deferredSettle = outcome;
1164
+ }
1165
+ else {
1166
+ settleActive(outcome);
1167
+ }
1168
+ };
752
1169
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
753
1170
  * backstop (the turn is over), and drop it from the queue. */
754
1171
  const settleActive = (result) => {
@@ -756,22 +1173,34 @@ export class ClaudeAcpAgent {
756
1173
  if (!turn || turn.settled) {
757
1174
  return;
758
1175
  }
1176
+ // Captured before the settled flip below (isHeldOpen tests !settled).
1177
+ const wasHeld = isHeldOpen(turn);
759
1178
  turn.settled = true;
760
- if (session.forceCancelTimer) {
761
- clearTimeout(session.forceCancelTimer);
762
- session.forceCancelTimer = undefined;
763
- }
1179
+ disarmForceCancel(session);
764
1180
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
765
1181
  session.activeTurn = null;
1182
+ streamedToolInputs.clear();
1183
+ if (wasHeld) {
1184
+ // Settling a held turn is its delivery-stretch boundary: the turn's
1185
+ // answer finished long ago, so text streamed since the last boundary
1186
+ // is normally its followups' — left latched it would suppress a
1187
+ // following replayed turn's issue-#453 result-text fallback (the
1188
+ // common post-hold sequence). Known trade: at the echo hand-off an
1189
+ // incoming turn's pre-echo deltas share this one boolean, so a
1190
+ // STREAMING replay on a usage-omitting backend could re-emit its
1191
+ // answer — the flag cannot attribute text to a turn before its
1192
+ // echo, and the suppression direction is the common one, so the
1193
+ // clear wins. Every held-settle lane inherits this: the drain
1194
+ // settle, both hand-offs, and stream-done; cancel()'s inline mirror
1195
+ // carries its own copy.
1196
+ session.emittedAssistantText = false;
1197
+ }
766
1198
  turn.resolve(result);
767
1199
  };
768
1200
  /** Reject the active turn (auth required, error result, …) without tearing
769
1201
  * down the consumer: the stream continues to idle and later turns proceed. */
770
1202
  const failActive = (error) => {
771
- if (session.forceCancelTimer) {
772
- clearTimeout(session.forceCancelTimer);
773
- session.forceCancelTimer = undefined;
774
- }
1203
+ disarmForceCancel(session);
775
1204
  const turn = session.activeTurn;
776
1205
  if (!turn || turn.settled) {
777
1206
  return;
@@ -779,14 +1208,17 @@ export class ClaudeAcpAgent {
779
1208
  turn.settled = true;
780
1209
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
781
1210
  session.activeTurn = null;
1211
+ streamedToolInputs.clear();
1212
+ // A failed turn's stretch is over, and some failure lanes (the issue
1213
+ // #825 idle-fail) never see the result whose `finally` would close it —
1214
+ // start the next stretch clean, or its stale delivery record would
1215
+ // suppress the next turn's issue-#453 result-text fallback.
1216
+ session.emittedAssistantText = false;
782
1217
  turn.reject(error);
783
1218
  };
784
1219
  /** Reject every in-flight turn — used when the stream dies. */
785
1220
  const failAllTurns = (error) => {
786
- if (session.forceCancelTimer) {
787
- clearTimeout(session.forceCancelTimer);
788
- session.forceCancelTimer = undefined;
789
- }
1221
+ disarmForceCancel(session);
790
1222
  const turns = session.activeTurn
791
1223
  ? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
792
1224
  : [...(session.turnQueue ?? [])];
@@ -794,8 +1226,18 @@ export class ClaudeAcpAgent {
794
1226
  session.turnQueue = [];
795
1227
  for (const turn of turns) {
796
1228
  if (!turn.settled) {
1229
+ const wasHeld = isHeldOpen(turn);
797
1230
  turn.settled = true;
798
- turn.reject(error);
1231
+ if (wasHeld) {
1232
+ // A held turn's answer already streamed and its outcome is
1233
+ // recorded — a stream death during the post-answer hold is a
1234
+ // background failure, not the turn's. Resolve with the real
1235
+ // outcome, mirroring the stream-done path.
1236
+ turn.resolve(turn.deferredSettle);
1237
+ }
1238
+ else {
1239
+ turn.reject(error);
1240
+ }
799
1241
  }
800
1242
  }
801
1243
  };
@@ -836,9 +1278,45 @@ export class ClaudeAcpAgent {
836
1278
  // turn being abandoned. Stale counts self-heal: activation resets
837
1279
  // them (see activateTurn).
838
1280
  if (session.activeTurn && !session.activeTurn.settled) {
839
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
1281
+ // Seed by what the frames already told us, mirroring cancel()'s
1282
+ // queued-turn sweep — the consumer may have drained the wedged
1283
+ // turn's result and/or terminal frame before the backstop fired,
1284
+ // and an entry seeded for a result or frame that is already
1285
+ // spent would never drain (it would swallow an unrelated later
1286
+ // echo-less result instead).
1287
+ const active = session.activeTurn;
1288
+ if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
1289
+ // Finished SDK-side; any result already passed. Nothing to
1290
+ // track.
1291
+ }
1292
+ else if (active.commandFinished === "cancelled") {
1293
+ // Aborted after dispatch: its late result may still come —
1294
+ // unless it already did.
1295
+ if (!active.commandResultSeen) {
1296
+ this.trackOrphanCommand(session, active.promptUuid, "zombie");
1297
+ }
1298
+ }
1299
+ else if (active.commandResultSeen) {
1300
+ // Its result was already consumed (dropped at the cancelled
1301
+ // guard); only the terminal frame is outstanding, which no-ops
1302
+ // with no entry. Nothing to track.
1303
+ }
1304
+ else {
1305
+ // The wedged turn WAS dispatched (it's active), so track it
1306
+ // "started": its late result (if the SDK recovers) is skipped
1307
+ // echo-less, and its terminal frame — or that skip plus
1308
+ // activation's clear when the frame is lost to the wedge — is
1309
+ // what drains it.
1310
+ this.trackOrphanCommand(session, active.promptUuid, "started");
1311
+ }
840
1312
  }
841
1313
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1314
+ // The cancelled turn's result may never come (that's why the
1315
+ // backstop fired) — close its delivery stretch here so partial
1316
+ // streamed text can't suppress the next turn's issue-#453 fallback.
1317
+ // If a late orphan result does arrive, its `finally` clears again;
1318
+ // FIFO ordering means no live turn's text can have streamed yet.
1319
+ session.emittedAssistantText = false;
842
1320
  // If the session is being torn down, abandon the in-flight next()
843
1321
  // (swallowing any later rejection so it can't surface as unhandled)
844
1322
  // and stop; otherwise re-arm and keep consuming — `pendingNext`
@@ -862,11 +1340,15 @@ export class ClaudeAcpAgent {
862
1340
  // turn's real outcome.
863
1341
  //
864
1342
  // Settle the turn that was in flight so its prompt() doesn't hang:
865
- // cancelled if a cancel is pending, otherwise the accumulated outcome.
866
- settleActive({
867
- stopReason: session.cancelled ? "cancelled" : stopReason,
868
- usage: sessionUsage(session),
869
- });
1343
+ // cancelled if a cancel is pending, otherwise the outcome a
1344
+ // deferred turn already recorded (see Turn.deferredSettle) or the
1345
+ // accumulated scratch outcome. The scratch currently still equals
1346
+ // a deferred turn's stored outcome (followup results never mutate
1347
+ // it), but the stored one is the authoritative source.
1348
+ const inFlight = session.activeTurn;
1349
+ settleActive(session.cancelled
1350
+ ? { stopReason: "cancelled", usage: sessionUsage(session) }
1351
+ : (inFlight?.deferredSettle ?? { stopReason, usage: sessionUsage(session) }));
870
1352
  // Queued turns the SDK never started never ran, so reject them rather
871
1353
  // than reporting a success (end_turn) — or a misleading "cancelled" —
872
1354
  // for a prompt that produced no output. (A cancel already settled the
@@ -893,19 +1375,116 @@ export class ClaudeAcpAgent {
893
1375
  message: message,
894
1376
  });
895
1377
  }
1378
+ // CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
1379
+ // uuid-stamped queued command (queued/started/completed/cancelled/
1380
+ // discarded) as `command_lifecycle` frames — 2-3 per prompt, since
1381
+ // prompt() stamps a uuid on every message. The frame is @internal and
1382
+ // absent from the SDKMessage union, so handle it BEFORE the exhaustive
1383
+ // switch: it must not reach `unreachable`'s error log, and a `case`
1384
+ // for it wouldn't typecheck. It feeds only the orphan accounting (see
1385
+ // Session.orphanCommands); turn settlement stays driven by
1386
+ // echoes/results/idle. (Raw-mode emission above still forwards these
1387
+ // frames.)
1388
+ if (message.type === "command_lifecycle") {
1389
+ const frame = message;
1390
+ switch (frame.state) {
1391
+ case "started": {
1392
+ // Remember dispatch on the live turn so a cancel() that orphans
1393
+ // it seeds the right state (see Turn.commandStarted)...
1394
+ const queued = findUnsettledTurn(frame.command_uuid);
1395
+ if (queued) {
1396
+ queued.commandStarted = true;
1397
+ }
1398
+ // ...and promote an already-orphaned command: once dispatched,
1399
+ // a bare `cancelled` no longer means "dropped without running".
1400
+ const state = session.orphanCommands?.get(frame.command_uuid);
1401
+ if (state === "pending") {
1402
+ session.orphanCommands.set(frame.command_uuid, "started");
1403
+ }
1404
+ else if (state === "zombie") {
1405
+ // "started" after the command's terminal frame: the ordering
1406
+ // the whole lane rests on has been violated (frames are
1407
+ // per-uuid FIFO). Surface it — a silent drift here degrades
1408
+ // into swallowed or misattributed results.
1409
+ 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.`);
1410
+ }
1411
+ break;
1412
+ }
1413
+ case "completed":
1414
+ case "discarded":
1415
+ case "cancelled": {
1416
+ // Terminal frames. Latch the fate on a still-queued turn so a
1417
+ // later cancel() doesn't seed an orphan entry for a command
1418
+ // whose one-and-only terminal frame has already been consumed
1419
+ // (nothing would ever drain that entry).
1420
+ const queued = findUnsettledTurn(frame.command_uuid);
1421
+ if (queued) {
1422
+ queued.commandFinished = frame.state;
1423
+ }
1424
+ if (frame.state === "cancelled") {
1425
+ // Ambiguous by design (dup-over-loss): dropped before
1426
+ // dispatch (no result will ever come — safe to forget) vs
1427
+ // consumed into a turn that was aborted/failed. For the
1428
+ // latter, any result the dead turn managed to emit has
1429
+ // already deleted the entry (see
1430
+ // recordResultForOrphanCommands / ensureActiveTurn), so a
1431
+ // still-"started" entry means no result was seen since
1432
+ // dispatch — it becomes a zombie for the next
1433
+ // echo-less-result skip.
1434
+ const state = session.orphanCommands?.get(frame.command_uuid);
1435
+ if (state === "pending") {
1436
+ session.orphanCommands?.delete(frame.command_uuid);
1437
+ }
1438
+ else if (state === "started") {
1439
+ session.orphanCommands?.set(frame.command_uuid, "zombie");
1440
+ }
1441
+ break;
1442
+ }
1443
+ // Exactly-one-terminal: the command is finished. "completed" is
1444
+ // emitted after any result its turn produced (fresh turn) or the
1445
+ // command folded into another turn whose result is attributed
1446
+ // elsewhere — either way no echo-less result remains to skip.
1447
+ // "discarded" = session ended with it still queued; no result.
1448
+ session.orphanCommands?.delete(frame.command_uuid);
1449
+ break;
1450
+ }
1451
+ default:
1452
+ // "queued" carries no fate information. Anything else is a
1453
+ // state this adapter doesn't know — likely a CLI that grew the
1454
+ // v1 vocabulary. The entry still drains by result coverage or
1455
+ // activation's clear (bounded damage), but log it so the
1456
+ // degradation is visible instead of silent.
1457
+ if (frame.state !== "queued") {
1458
+ this.logger.error(`Session ${params.sessionId}: unknown command_lifecycle state "${frame.state}" for ${frame.command_uuid}; treating as uninformative.`);
1459
+ }
1460
+ break;
1461
+ }
1462
+ continue;
1463
+ }
896
1464
  switch (message.type) {
897
1465
  case "system":
898
1466
  switch (message.subtype) {
899
1467
  case "init":
1468
+ // Latch the lifecycle capability so cancel() routes orphan
1469
+ // accounting through `orphanCommands` (per-uuid, exact)
1470
+ // instead of the coalescing-blind count. Never unlatch: init
1471
+ // re-emits per turn and the capability can't be lost mid-CLI.
1472
+ if (message.capabilities?.includes("msg_lifecycle_v1")) {
1473
+ session.msgLifecycleV1 = true;
1474
+ }
900
1475
  // A fresh `system`/init (e.g. after reinitialize) can carry an
901
1476
  // updated Fast mode state; reconcile it with what we seeded at
902
1477
  // session creation.
903
1478
  await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
904
1479
  break;
905
1480
  case "status": {
1481
+ // These banners count as delivered text (via sendUpdate), so
1482
+ // an echo-less turn that only ever emits them (e.g. `/compact`,
1483
+ // promoted at its own result) doesn't have its result text
1484
+ // re-emitted by the issue-#453 fallback.
906
1485
  if (message.status === "compacting") {
907
1486
  compactionInProgress = true;
908
- await this.client.sessionUpdate({
1487
+ await sendUpdate({
909
1488
  sessionId: message.session_id,
910
1489
  update: {
911
1490
  sessionUpdate: "agent_message_chunk",
@@ -918,7 +1497,7 @@ export class ClaudeAcpAgent {
918
1497
  // message carrying `compact_result`, not the `compact_boundary`
919
1498
  // message (which only fires when there's content to compact).
920
1499
  compactionInProgress = false;
921
- await this.client.sessionUpdate({
1500
+ await sendUpdate({
922
1501
  sessionId: message.session_id,
923
1502
  update: {
924
1503
  sessionUpdate: "agent_message_chunk",
@@ -929,7 +1508,7 @@ export class ClaudeAcpAgent {
929
1508
  else if (message.compact_result === "failed" && compactionInProgress) {
930
1509
  compactionInProgress = false;
931
1510
  const reason = message.compact_error ? `: ${message.compact_error}` : ".";
932
- await this.client.sessionUpdate({
1511
+ await sendUpdate({
933
1512
  sessionId: message.session_id,
934
1513
  update: {
935
1514
  sessionUpdate: "agent_message_chunk",
@@ -954,9 +1533,9 @@ export class ClaudeAcpAgent {
954
1533
  // dropped dramatically) and replaced within seconds by the next
955
1534
  // result message.
956
1535
  //
957
- // `size` keeps coming from session.contextWindowSize (learned
958
- // from modelUsage / the model heuristic) — getContextUsage's
959
- // window field under-reports extended 1M windows.
1536
+ // `size` keeps coming from session.contextWindowSize —
1537
+ // compaction frees occupancy, it doesn't change the model's
1538
+ // window.
960
1539
  //
961
1540
  // The "Compacting completed." text is emitted from the `status`
962
1541
  // handler (keyed on `compact_result`), not here, so the failure
@@ -964,7 +1543,7 @@ export class ClaudeAcpAgent {
964
1543
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
965
1544
  lastAssistantUsage = null;
966
1545
  lastAssistantTotalUsage = usedTokens ?? 0;
967
- await this.client.sessionUpdate({
1546
+ await sendUpdate({
968
1547
  sessionId: message.session_id,
969
1548
  update: {
970
1549
  sessionUpdate: "usage_update",
@@ -975,7 +1554,7 @@ export class ClaudeAcpAgent {
975
1554
  break;
976
1555
  }
977
1556
  case "local_command_output": {
978
- await this.client.sessionUpdate({
1557
+ await sendUpdate({
979
1558
  sessionId: message.session_id,
980
1559
  update: {
981
1560
  sessionUpdate: "agent_message_chunk",
@@ -985,6 +1564,7 @@ export class ClaudeAcpAgent {
985
1564
  break;
986
1565
  }
987
1566
  case "session_state_changed": {
1567
+ session.lastSessionState = message.state;
988
1568
  if (message.state === "idle") {
989
1569
  // A non-cancelled turn normally settled at its terminal
990
1570
  // `result` already (issue #773), and that result recorded an
@@ -1017,14 +1597,38 @@ export class ClaudeAcpAgent {
1017
1597
  // when the cancel pre-empted the result (wedge/force-cancel).
1018
1598
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
1019
1599
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1600
+ // An interrupt can pre-empt the turn's result entirely
1601
+ // (nothing ran the result-case `finally`), so close the
1602
+ // delivery stretch here: idle is the SDK's authoritative
1603
+ // turn-over signal, and stale partial-text state would
1604
+ // suppress the next turn's issue-#453 fallback.
1605
+ session.emittedAssistantText = false;
1020
1606
  }
1021
- else if (owedTrailingIdles > 0) {
1607
+ else if (isHeldOpen(session.activeTurn)) {
1608
+ // A turn held open for its background subagents (see
1609
+ // Turn.deferredSettle). Idles keep their normal cadence
1610
+ // during the hold — the CLI emits one per processing
1611
+ // cycle (the turn's own trailer, then one per followup),
1612
+ // NOT one final "all drained" signal — so each one
1613
+ // absorbs an outstanding trailer debt, and the turn only
1614
+ // settles once none of its spawned subagents is left
1615
+ // (the followup-result settle usually got there first;
1616
+ // this is the fallback when no followup came). Mid-hold
1617
+ // idles never fall through: a held turn HAS its result,
1618
+ // so reading its idle as "turn abandoned without a
1619
+ // result" (issue #825) would fail a healthy prompt.
1620
+ if (session.owedTrailingIdles > 0) {
1621
+ session.owedTrailingIdles--;
1622
+ }
1623
+ settleDeferredIfDrained();
1624
+ }
1625
+ else if (session.owedTrailingIdles > 0) {
1022
1626
  // Absorb a settled turn's trailing idle. Also covers a
1023
1627
  // cancel that landed between a turn's counted result and
1024
1628
  // this lagged idle (no active turn to settle): the idle
1025
1629
  // still belongs to that settled turn, and skipping the
1026
1630
  // decrement would leak the debt permanently.
1027
- owedTrailingIdles--;
1631
+ session.owedTrailingIdles--;
1028
1632
  }
1029
1633
  else if (!session.cancelled &&
1030
1634
  session.activeTurn &&
@@ -1066,7 +1670,7 @@ export class ClaudeAcpAgent {
1066
1670
  const title = isSynthesis
1067
1671
  ? "Recalled synthesized memory"
1068
1672
  : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
1069
- await this.client.sessionUpdate({
1673
+ await sendUpdate({
1070
1674
  sessionId: message.session_id,
1071
1675
  update: {
1072
1676
  sessionUpdate: "tool_call",
@@ -1093,7 +1697,7 @@ export class ClaudeAcpAgent {
1093
1697
  // list with this payload: supportedCommands() is captured once
1094
1698
  // at initialize and never reflects mid-session changes, so we
1095
1699
  // forward message.commands directly rather than re-querying.
1096
- await this.client.sessionUpdate({
1700
+ await sendUpdate({
1097
1701
  sessionId: message.session_id,
1098
1702
  update: {
1099
1703
  sessionUpdate: "available_commands_update",
@@ -1117,7 +1721,7 @@ export class ClaudeAcpAgent {
1117
1721
  // rejection reason — otherwise the client shows a tool call
1118
1722
  // that silently never resolves.
1119
1723
  const reason = message.decision_reason ?? message.message;
1120
- await this.client.sessionUpdate({
1724
+ await sendUpdate({
1121
1725
  sessionId: message.session_id,
1122
1726
  update: {
1123
1727
  sessionUpdate: "tool_call_update",
@@ -1149,10 +1753,14 @@ export class ClaudeAcpAgent {
1149
1753
  // instead of a silent stop. ACP's agent_message_chunk has no
1150
1754
  // severity field, so fold the level into the text for the more
1151
1755
  // prominent levels ('info' is transcript-only noise — leave plain).
1756
+ // Sending via sendUpdate also marks the notice as this stretch's
1757
+ // delivered text: a hook-blocked turn's result repeats the block
1758
+ // reason with zero output tokens, and the issue-#453 fallback
1759
+ // must not emit it a second time.
1152
1760
  const text = message.level === "info"
1153
1761
  ? message.content
1154
1762
  : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
1155
- await this.client.sessionUpdate({
1763
+ await sendUpdate({
1156
1764
  sessionId: message.session_id,
1157
1765
  update: {
1158
1766
  sessionUpdate: "agent_message_chunk",
@@ -1165,10 +1773,51 @@ export class ClaudeAcpAgent {
1165
1773
  case "hook_progress":
1166
1774
  case "hook_response":
1167
1775
  case "files_persisted":
1776
+ case "task_progress":
1777
+ break;
1168
1778
  case "task_started":
1779
+ // For subagent tasks `task_id` is the subagent's agent id (the
1780
+ // SDK keys its task registry by agent id) and `tool_use_id` is
1781
+ // the Agent/Task tool_use that spawned it — recorded so the
1782
+ // subagent's permission requests, which reach canUseTool with
1783
+ // only `agentID`, can attribute their eagerly-emitted
1784
+ // tool_call to the parent tool call. Non-subagent tasks (e.g.
1785
+ // background Bash) land here too; their task_ids never match
1786
+ // an agentID, so those entries are inert for attribution.
1787
+ //
1788
+ // `isSubagent` marks Task/Agent-tool subagents — the tasks
1789
+ // whose completion wakes the model for a followup, so the
1790
+ // ones worth deferring turn settlement for. A sync subagent
1791
+ // is pruned (terminal task_updated) before its turn's result
1792
+ // can arrive, so registry membership at result time means an
1793
+ // async subagent. Their spawn is also recorded on the active
1794
+ // turn: a turn only ever waits on its own subagents, and a
1795
+ // spawn during a held-open drain window (an agent chain)
1796
+ // extends that turn's hold.
1797
+ session.liveBackgroundTasks.set(message.task_id, {
1798
+ parentToolUseId: message.tool_use_id,
1799
+ isSubagent: !!message.subagent_type,
1800
+ });
1801
+ if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
1802
+ (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
1803
+ }
1804
+ break;
1169
1805
  case "task_notification":
1170
- case "task_progress":
1806
+ // The task settled — no further tool calls can originate
1807
+ // from it, so its registry entry can be dropped.
1808
+ session.liveBackgroundTasks.delete(message.task_id);
1809
+ break;
1171
1810
  case "task_updated":
1811
+ // terminal-status task_updated patch and a (deduplicated)
1812
+ // task_notification when a task settles, but only the patch is
1813
+ // guaranteed per transition — prune on it too so the registry
1814
+ // can't grow for the session's lifetime if a notification is
1815
+ // skipped.
1816
+ if (message.patch.status === "completed" ||
1817
+ message.patch.status === "failed" ||
1818
+ message.patch.status === "killed") {
1819
+ session.liveBackgroundTasks.delete(message.task_id);
1820
+ }
1172
1821
  break;
1173
1822
  case "worker_shutting_down":
1174
1823
  // A Remote Control worker announced a graceful teardown. This is a
@@ -1220,7 +1869,7 @@ export class ClaudeAcpAgent {
1220
1869
  const outcome = persistent
1221
1870
  ? `The session will continue on ${message.fallback_model}.`
1222
1871
  : `The session stays on ${message.original_model}.`;
1223
- await this.client.sessionUpdate({
1872
+ await sendUpdate({
1224
1873
  sessionId: message.session_id,
1225
1874
  update: {
1226
1875
  sessionUpdate: "agent_message_chunk",
@@ -1254,183 +1903,334 @@ export class ClaudeAcpAgent {
1254
1903
  lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
1255
1904
  }
1256
1905
  break;
1906
+ // `control_request_progress` only reports on side_question
1907
+ // control requests, which this adapter never issues.
1908
+ case "control_request_progress":
1909
+ break;
1910
+ case "background_tasks_changed":
1911
+ // A level signal: the full live background-task set on every
1912
+ // membership change, with REPLACE semantics. Used only to
1913
+ // reconcile `liveBackgroundTasks` — dropping (or, for
1914
+ // subagent entries, unpinning) any entry whose settle
1915
+ // bookend (task_notification / terminal task_updated) was
1916
+ // lost, so a leaked subagent entry can't defer its spawning
1917
+ // turn's settlement forever. Growth of retained
1918
+ // (endedPerLevel) subagent entries is bounded by the
1919
+ // activation-time sweep in activateTurn, not here. It never
1920
+ // ADDS entries (the payload carries no attribution or
1921
+ // subagent marker), so the unspecified ordering vs. the edge
1922
+ // bookends is safe: a level that precedes its task_started
1923
+ // simply no-ops here.
1924
+ if (session.liveBackgroundTasks.size > 0) {
1925
+ const live = new Set(message.tasks.map((t) => t.task_id));
1926
+ for (const [taskId, record] of session.liveBackgroundTasks) {
1927
+ if (live.has(taskId)) {
1928
+ // The level proves the task live in the background
1929
+ // universe (e.g. a foreground agent was backgrounded
1930
+ // after an earlier absent-marking, or that marking was
1931
+ // a racing payload built before the task registered) —
1932
+ // un-end it so a hold waits on it again, and disarm
1933
+ // the activation sweep.
1934
+ record.endedPerLevel = undefined;
1935
+ continue;
1936
+ }
1937
+ if (record.isSubagent) {
1938
+ // The level's universe is BACKGROUND tasks only, so a
1939
+ // live sync (foreground) subagent is legitimately
1940
+ // absent — deleting its entry would strand its
1941
+ // permission attribution (#859). Keep the entry but
1942
+ // stop any hold from waiting on the id: an absent id
1943
+ // can equally be a leaked async entry whose settle
1944
+ // bookends were lost.
1945
+ record.endedPerLevel ??= "ended";
1946
+ }
1947
+ else {
1948
+ session.liveBackgroundTasks.delete(taskId);
1949
+ }
1950
+ }
1951
+ }
1952
+ break;
1257
1953
  default:
1258
1954
  unreachable(message, this.logger);
1259
1955
  break;
1260
1956
  }
1261
1957
  break;
1262
1958
  case "result": {
1263
- // Task-notification followups are autonomous work triggered by a
1264
- // task-notification system message, not by the user's prompt.
1265
- // They should not influence the user-turn lifecycle (stop reason,
1266
- // slash-command output forwarding) but their cost is real.
1267
- const isTaskNotification = message.origin?.kind === "task-notification";
1268
- // Reconcile the Fast mode toggle with the SDK's reported state.
1269
- // Gated to user-driven turns like every other side effect below; a
1270
- // background followup's state lands on the next user turn's result.
1271
- // Runs even when the turn errors or was cancelled.
1272
- if (!isTaskNotification) {
1273
- await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1274
- }
1275
- // A user-turn result needs an active turn so its stop reason is
1276
- // attributed and the turn settles at idle. Local-only commands carry
1277
- // no user-message echo to promote them, so do it here from the head.
1278
- // Promote BEFORE accumulating usage, since activation resets the
1279
- // accumulator — promoting after would discard this result's tokens.
1280
- if (!isTaskNotification) {
1281
- ensureActiveTurn();
1282
- }
1283
- // Every user-turn result terminates a turn (settle, reject, or
1284
- // orphan skip) and the SDK follows it with a trailing
1285
- // `session_state_changed: idle` — record the debt so the idle
1286
- // handler absorbs that idle rather than reading it as a turn the
1287
- // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
1288
- // turn's own result. It is dropped at the `session.cancelled`
1289
- // guard, and either the idle itself settles the turn (consuming
1290
- // the trailer) or the next echo's hand-off does (which records
1291
- // the debt there instead) — counting here too would double it.
1292
- // Results skipped while cancelled with NO active turn — orphaned
1293
- // queued turns the SDK still ran, or a force-cancelled turn's
1294
- // late result after the backstop settled it — get no such settle,
1295
- // so their trailers must be counted here or they'd later be read
1296
- // as the next healthy turn being abandoned and false-fail it.
1297
- if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
1298
- owedTrailingIdles++;
1299
- }
1300
- // Accumulate usage into the user turn's tally. Skip task-notification
1301
- // followups: their cost is real but is reported separately via the
1302
- // usage_update below, and `session.accumulatedUsage` is only reset on
1303
- // turn activation — so folding a task-notification result that lands
1304
- // after the next turn is active (but before it settles) would leak
1305
- // those tokens into that turn's PromptResponse.usage.
1306
- if (!isTaskNotification) {
1307
- session.accumulatedUsage.inputTokens += message.usage.input_tokens;
1308
- session.accumulatedUsage.outputTokens += message.usage.output_tokens;
1309
- session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
1310
- session.accumulatedUsage.cachedWriteTokens +=
1311
- message.usage.cache_creation_input_tokens;
1312
- }
1313
- const matchingModelUsage = lastAssistantModel
1314
- ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
1315
- : null;
1316
- // Only overwrite when we have an authoritative value — a miss
1317
- // (e.g. a turn with no top-level assistant message) would
1318
- // otherwise discard the window learned on a prior turn and
1319
- // leave the next prompt's mid-stream updates reporting 200k.
1320
- if (matchingModelUsage) {
1321
- session.contextWindowSize = matchingModelUsage.contextWindow;
1322
- }
1323
- // Send usage_update notification
1324
- if (lastAssistantTotalUsage !== null) {
1325
- await this.client.sessionUpdate({
1326
- sessionId: params.sessionId,
1327
- update: {
1328
- sessionUpdate: "usage_update",
1329
- used: lastAssistantTotalUsage,
1330
- size: session.contextWindowSize,
1331
- cost: {
1332
- amount: message.total_cost_usd,
1333
- currency: "USD",
1334
- },
1335
- ...(message.origin && {
1336
- _meta: { "_claude/origin": message.origin },
1337
- }),
1338
- },
1339
- });
1340
- }
1341
- if (session.cancelled) {
1342
- if (!isTaskNotification) {
1343
- stopReason = "cancelled";
1959
+ // A result from an autonomous cycle — a task-notification
1960
+ // followup, or a peer/coordinator/observer message the model
1961
+ // handled on its own (see AUTONOMOUS_RESULT_ORIGINS) — is not
1962
+ // the user's prompt's. Autonomous results must never touch the
1963
+ // user-turn lifecycle (stop reason, settles, failActive,
1964
+ // slash-command output forwarding), though their cost is real.
1965
+ const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
1966
+ try {
1967
+ // Reconcile the Fast mode toggle with the SDK's reported state.
1968
+ // Gated to user-driven turns like every other side effect below;
1969
+ // an autonomous cycle's state lands on the next user turn's
1970
+ // result. Runs even when the turn errors or was cancelled.
1971
+ if (!isAutonomousResult) {
1972
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1344
1973
  }
1345
- break;
1346
- }
1347
- // A refusal can arrive on any result subtype (and may even set
1348
- // is_error), so handle it before the subtype switch — otherwise the
1349
- // is_error throw below would surface it as an internal error. The
1350
- // refused assistant message carries no visible content, so surface
1351
- // the classifier's explanation (when available) and report ACP's
1352
- // dedicated `refusal` stop reason.
1353
- if (message.stop_reason === "refusal" && !isTaskNotification) {
1354
- if (lastRefusalExplanation) {
1355
- await this.client.sessionUpdate({
1974
+ // A user-turn result needs an active turn so its stop reason is
1975
+ // attributed and the turn settles at idle. Local-only commands carry
1976
+ // no user-message echo to promote them, so do it here from the head.
1977
+ // Promote BEFORE accumulating usage, since activation resets the
1978
+ // accumulator — promoting after would discard this result's tokens.
1979
+ // The orphan bookkeeping runs first: it covers folded/zombie
1980
+ // commands whose shared or late result this is, even when the
1981
+ // result is the ACTIVE turn's (ensureActiveTurn never looks at
1982
+ // the map in that case).
1983
+ if (!isAutonomousResult) {
1984
+ recordResultForOrphanCommands();
1985
+ ensureActiveTurn();
1986
+ }
1987
+ // A result closes the stretch of output it terminates: snapshot
1988
+ // the delivery record — AFTER ensureActiveTurn, whose held-turn
1989
+ // hand-off closes the held stretch, so an echo-less command
1990
+ // promoted here is judged on its own delivery, not on the held
1991
+ // turn's followup text — and before the handling below can emit
1992
+ // anything of its own; the `finally` then clears it so every
1993
+ // exit from this case (the cancelled-guard and refusal breaks
1994
+ // included) starts the next stretch clean. Clearing up front
1995
+ // instead would let result-time emissions (refusal explanation,
1996
+ // result-text forwarding) taint the next stretch and suppress a
1997
+ // following replayed turn's fallback. Autonomous cycles run
1998
+ // alongside a user turn and must not clear its flag (they exit
1999
+ // through the early break below, which the gated `finally`
2000
+ // leaves alone).
2001
+ const deliveredAssistantText = session.emittedAssistantText;
2002
+ // Every user-turn result terminates a turn (settle, reject, or
2003
+ // orphan skip) and the SDK follows it with a trailing
2004
+ // `session_state_changed: idle` — record the debt so the idle
2005
+ // handler absorbs that idle rather than reading it as a turn the
2006
+ // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
2007
+ // turn's own result. It is dropped at the `session.cancelled`
2008
+ // guard, and either the idle itself settles the turn (consuming
2009
+ // the trailer) or the next echo's hand-off does (which records
2010
+ // the debt there instead) — counting here too would double it.
2011
+ // Results skipped while cancelled with NO active turn — orphaned
2012
+ // queued turns the SDK still ran, or a force-cancelled turn's
2013
+ // late result after the backstop settled it — get no such settle,
2014
+ // so their trailers must be counted here or they'd later be read
2015
+ // as the next healthy turn being abandoned and false-fail it.
2016
+ // Autonomous results (followups, peer/channel/coordinator
2017
+ // cycles) are counted too: each is its own processing cycle with
2018
+ // its own trailing idle, and that idle can lag past the next
2019
+ // prompt's echo — which, un-owed, would be read as the fresh
2020
+ // turn being abandoned (#825 false-fail). That lag was mostly
2021
+ // unreachable when such cycles only ran with no pending turn,
2022
+ // but a held turn settling AT a followup result unblocks the
2023
+ // client at exactly that point, making the race the common
2024
+ // case.
2025
+ // The cancelled-ACTIVE-turn exclusion applies only to that
2026
+ // turn's OWN result — a followup result arriving inside the
2027
+ // cancel window still gets its own trailer and must be counted,
2028
+ // or that idle would later false-fail the next prompt.
2029
+ if (isAutonomousResult || !session.cancelled || !session.activeTurn) {
2030
+ session.owedTrailingIdles++;
2031
+ }
2032
+ // Accumulate usage into the user turn's tally. Skip autonomous
2033
+ // results: their cost is real but is reported separately via the
2034
+ // usage_update below, and `session.accumulatedUsage` is only reset on
2035
+ // turn activation — so folding an autonomous result that lands
2036
+ // after the next turn is active (but before it settles) would leak
2037
+ // those tokens into that turn's PromptResponse.usage.
2038
+ if (!isAutonomousResult) {
2039
+ session.accumulatedUsage.inputTokens += message.usage.input_tokens;
2040
+ session.accumulatedUsage.outputTokens += message.usage.output_tokens;
2041
+ session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
2042
+ session.accumulatedUsage.cachedWriteTokens +=
2043
+ message.usage.cache_creation_input_tokens;
2044
+ }
2045
+ const matchingModelUsage = lastAssistantModel
2046
+ ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
2047
+ : null;
2048
+ // Only overwrite when we have an authoritative value — a miss
2049
+ // (e.g. a turn with no top-level assistant message) would
2050
+ // otherwise discard the window learned on a prior turn and
2051
+ // leave the next prompt's mid-stream updates reporting 200k.
2052
+ if (matchingModelUsage) {
2053
+ session.contextWindowSize = matchingModelUsage.contextWindow;
2054
+ }
2055
+ // Send usage_update notification
2056
+ if (lastAssistantTotalUsage !== null) {
2057
+ await sendUpdate({
1356
2058
  sessionId: params.sessionId,
1357
2059
  update: {
1358
- sessionUpdate: "agent_message_chunk",
1359
- content: { type: "text", text: lastRefusalExplanation },
2060
+ sessionUpdate: "usage_update",
2061
+ used: lastAssistantTotalUsage,
2062
+ size: session.contextWindowSize,
2063
+ cost: {
2064
+ amount: message.total_cost_usd,
2065
+ currency: "USD",
2066
+ },
2067
+ ...(message.origin && {
2068
+ _meta: { "_claude/origin": message.origin },
2069
+ }),
1360
2070
  },
1361
2071
  });
1362
2072
  }
1363
- stopReason = "refusal";
1364
- settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
1365
- break;
1366
- }
1367
- switch (message.subtype) {
1368
- case "success": {
1369
- if (message.result.includes("Please run /login")) {
1370
- failActive(RequestError.authRequired());
1371
- break;
1372
- }
1373
- if (message.stop_reason === "max_tokens") {
1374
- if (!isTaskNotification) {
1375
- stopReason = "max_tokens";
1376
- }
1377
- break;
2073
+ if (session.cancelled) {
2074
+ if (!isAutonomousResult) {
2075
+ stopReason = "cancelled";
1378
2076
  }
1379
- if (message.is_error) {
1380
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
1381
- break;
2077
+ break;
2078
+ }
2079
+ // A held turn (see Turn.deferredSettle) settles at its
2080
+ // followup's terminal result: this is the earliest point at which
2081
+ // the promised summary has fully streamed — the trailing idle
2082
+ // would work too, but a client should not wait out another idle
2083
+ // round-trip for a response whose content is already complete.
2084
+ // (While the turn still awaits another of its subagents —
2085
+ // parallel spawns — the helper holds; the next notification's
2086
+ // followup settles it instead. Other autonomous origins — peer/
2087
+ // channel/coordinator cycles — reach here too: settling a
2088
+ // drained hold at their results is as good as the idle
2089
+ // fallback.) Then stop: everything below is user-turn
2090
+ // lifecycle, and an autonomous outcome must never touch it —
2091
+ // its is_error or "Please run /login" text would otherwise
2092
+ // failActive a live turn (the held one, or the user's next
2093
+ // prompt) whose own result recorded a different outcome.
2094
+ if (isAutonomousResult) {
2095
+ settleDeferredIfDrained();
2096
+ // With no turn in flight OR QUEUED (also after the settle
2097
+ // above), the stretch holds only autonomous prose — close
2098
+ // it, so a replayed next prompt isn't silently suppressed by
2099
+ // the issue-#453 delivery check. A live turn's flag may
2100
+ // guard the USER's already-streamed text — and so may a
2101
+ // QUEUED turn's: with mid-message echo lag its deltas stream
2102
+ // before the echo activates it (activeTurn still null), and
2103
+ // clearing then would re-emit that answer via the fallback,
2104
+ // the duplicate direction the flag's doc forbids.
2105
+ if (!session.activeTurn && !firstUnsettledQueuedTurn()) {
2106
+ session.emittedAssistantText = false;
1382
2107
  }
1383
- // For local-only commands (no model invocation), the result
1384
- // text is the command output — forward it to the client.
1385
- // Task-notification followups never originate from a user
1386
- // slash command, so skip the forwarding for them.
1387
- if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
1388
- for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
1389
- await this.client.sessionUpdate(notification);
1390
- }
2108
+ break;
2109
+ }
2110
+ // A refusal can arrive on any result subtype (and may even set
2111
+ // is_error), so handle it before the subtype switch — otherwise the
2112
+ // is_error throw below would surface it as an internal error. The
2113
+ // refused assistant message carries no visible content, so surface
2114
+ // the classifier's explanation (when available) and report ACP's
2115
+ // dedicated `refusal` stop reason.
2116
+ if (message.stop_reason === "refusal") {
2117
+ if (lastRefusalExplanation) {
2118
+ await sendUpdate({
2119
+ sessionId: params.sessionId,
2120
+ update: {
2121
+ sessionUpdate: "agent_message_chunk",
2122
+ content: { type: "text", text: lastRefusalExplanation },
2123
+ },
2124
+ });
1391
2125
  }
2126
+ stopReason = "refusal";
2127
+ // Through the deferral gate, not settleActive: a refusal can
2128
+ // land on a turn whose spawned subagents are still live, and
2129
+ // settling it out from under them would strand their output
2130
+ // and permission requests out-of-turn (issue #866's deadlock,
2131
+ // through the refusal lane).
2132
+ settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
1392
2133
  break;
1393
2134
  }
1394
- case "error_during_execution": {
1395
- if (message.stop_reason === "max_tokens") {
1396
- if (!isTaskNotification) {
2135
+ switch (message.subtype) {
2136
+ case "success": {
2137
+ if (message.result.includes("Please run /login")) {
2138
+ failActive(RequestError.authRequired());
2139
+ break;
2140
+ }
2141
+ if (message.stop_reason === "max_tokens") {
1397
2142
  stopReason = "max_tokens";
2143
+ break;
2144
+ }
2145
+ if (message.is_error) {
2146
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2147
+ break;
2148
+ }
2149
+ // The result text is forwarded in two cases. Local-only
2150
+ // commands (no model invocation): the result IS the command
2151
+ // output. Otherwise the result is normally a trailing copy of
2152
+ // text that already streamed — but a cache-replayed turn
2153
+ // generates no tokens, and some CLIs then skip streaming
2154
+ // entirely and answer on the `result` alone: no `stream_event`
2155
+ // deltas, no consolidated `assistant` message (issue #453).
2156
+ // Forward it rather than end the turn silently:
2157
+ // `deliveredAssistantText` covers whatever already reached the
2158
+ // client (a turn that showed its answer cannot emit it twice),
2159
+ // and the output-token check keeps the fallback to the
2160
+ // replayed turns it was reported for. `?? 0`: typed non-null,
2161
+ // but third-party backends have been observed omitting usage
2162
+ // token fields (see snapshotFromUsage), and the replay lane
2163
+ // was reported from exactly such a backend — treat a missing
2164
+ // count as the replay signature rather than silently disabling
2165
+ // the fallback there. (Autonomous results never get here —
2166
+ // they exit at the early break above — so no background
2167
+ // prose can be injected into the feed.)
2168
+ if (session.activeTurn?.isLocalOnlyCommand ||
2169
+ (!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
2170
+ for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
2171
+ await sendUpdate(notification);
2172
+ }
1398
2173
  }
1399
2174
  break;
1400
2175
  }
1401
- if (message.is_error) {
1402
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1403
- break;
1404
- }
1405
- if (!isTaskNotification) {
2176
+ case "error_during_execution": {
2177
+ if (message.stop_reason === "max_tokens") {
2178
+ stopReason = "max_tokens";
2179
+ break;
2180
+ }
2181
+ if (message.is_error) {
2182
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2183
+ break;
2184
+ }
1406
2185
  stopReason = "end_turn";
1407
- }
1408
- break;
1409
- }
1410
- case "error_max_budget_usd":
1411
- case "error_max_turns":
1412
- case "error_max_structured_output_retries":
1413
- if (message.is_error) {
1414
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1415
2186
  break;
1416
2187
  }
1417
- if (!isTaskNotification) {
2188
+ case "error_max_budget_usd":
2189
+ case "error_max_turns":
2190
+ case "error_max_structured_output_retries":
2191
+ if (message.is_error) {
2192
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2193
+ break;
2194
+ }
1418
2195
  stopReason = "max_turn_requests";
1419
- }
1420
- break;
1421
- default:
1422
- unreachable(message, this.logger);
1423
- break;
2196
+ break;
2197
+ default:
2198
+ unreachable(message, this.logger);
2199
+ break;
2200
+ }
2201
+ // Settle the user turn at its terminal result so the client unlocks
2202
+ // as soon as the answer is done, rather than waiting for the SDK's
2203
+ // trailing `idle` (which can lag while background work runs — issue
2204
+ // #773). The consumer keeps draining afterward (absorbing idle and
2205
+ // forwarding any background output).
2206
+ //
2207
+ // One exception: while background subagents this turn spawned are
2208
+ // still live, settling now would strand their remaining work
2209
+ // outside any turn — ACP allows out-of-turn session/update, but
2210
+ // many clients stop consuming at the prompt response, and a
2211
+ // subagent's permission request would block on an RPC nobody
2212
+ // answers (issues #864/#866). Hold the turn open instead: store
2213
+ // the outcome and settle with it once the subagents are done —
2214
+ // at their followup's terminal result (see the deferred-settle
2215
+ // block above the subtype switch) or at an idle with none of
2216
+ // them left — so the subagents' streamed output, their
2217
+ // permission requests, and the model's promised summary all land
2218
+ // inside the turn. `session/cancel` and the next prompt's echo
2219
+ // hand-off still settle a deferred turn early, so a long-running
2220
+ // subagent never holds the prompt hostage.
2221
+ //
2222
+ // is_error/auth already settled via failActive (activeTurn is null
2223
+ // then, so both branches no-op); cancellation is left to the
2224
+ // idle/abort path. settleActive is idempotent, so a duplicate
2225
+ // idle is a no-op.
2226
+ if (!session.cancelled) {
2227
+ settleOrDefer({ stopReason, usage: sessionUsage(session) });
2228
+ }
1424
2229
  }
1425
- // Settle the user turn at its terminal result so the client unlocks
1426
- // as soon as the answer is done, rather than waiting for the SDK's
1427
- // trailing `idle` (which can lag while background work runs — issue
1428
- // #773). The consumer keeps draining afterward (absorbing idle and
1429
- // forwarding any background output). is_error/auth already settled
1430
- // via failActive; cancellation is left to the idle/abort path.
1431
- // settleActive is idempotent, so a duplicate idle is a no-op.
1432
- if (!isTaskNotification && !session.cancelled) {
1433
- settleActive({ stopReason, usage: sessionUsage(session) });
2230
+ finally {
2231
+ if (!isAutonomousResult) {
2232
+ session.emittedAssistantText = false;
2233
+ }
1434
2234
  }
1435
2235
  break;
1436
2236
  }
@@ -1493,11 +2293,10 @@ export class ClaudeAcpAgent {
1493
2293
  const model = message.event.message.model;
1494
2294
  if (model && model !== "<synthetic>") {
1495
2295
  lastAssistantModel = model;
1496
- // Only upgrade from the default — once a `result` has given
1497
- // us an authoritative window, trust it over the heuristic.
1498
- // Model switches invalidate the cached window via
1499
- // `syncSessionConfigState`, which resets us back to the
1500
- // default so this branch runs again for the new model.
2296
+ // Only upgrade from the default — once the SDK has given us
2297
+ // an authoritative window (seeded at session creation,
2298
+ // refreshed on model switches in `applyConfigOptionValue`,
2299
+ // confirmed by each `result`), trust it over the heuristic.
1501
2300
  if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
1502
2301
  const inferred = inferContextWindowFromModel(model);
1503
2302
  if (inferred !== null) {
@@ -1523,7 +2322,7 @@ export class ClaudeAcpAgent {
1523
2322
  const nextUsage = totalTokens(lastAssistantUsage);
1524
2323
  if (nextUsage !== lastAssistantTotalUsage) {
1525
2324
  lastAssistantTotalUsage = nextUsage;
1526
- await this.client.sessionUpdate({
2325
+ await sendUpdate({
1527
2326
  sessionId: params.sessionId,
1528
2327
  update: {
1529
2328
  sessionUpdate: "usage_update",
@@ -1539,8 +2338,11 @@ export class ClaudeAcpAgent {
1539
2338
  taskState: session.taskState,
1540
2339
  emittedToolCalls: session.emittedToolCalls,
1541
2340
  messageId: currentStreamMessageId,
2341
+ streamedToolInputs,
1542
2342
  })) {
1543
- await this.client.sessionUpdate(notification);
2343
+ // sendUpdate records delivery; a subagent stream's chunks carry
2344
+ // the stamped parentToolUseId meta and are excluded there.
2345
+ await sendUpdate(notification);
1544
2346
  }
1545
2347
  break;
1546
2348
  }
@@ -1561,7 +2363,7 @@ export class ClaudeAcpAgent {
1561
2363
  // is still promoted — activateTurn() clears the flag. The turn's own
1562
2364
  // echo is then dropped from the feed (the client already shows it).
1563
2365
  if (message.type === "user" && "uuid" in message && message.uuid) {
1564
- const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
2366
+ const queued = findUnsettledTurn(message.uuid);
1565
2367
  if (queued) {
1566
2368
  // Only (re)activate if this isn't already the active turn — a
1567
2369
  // turn promoted early (e.g. by a result that preceded its echo)
@@ -1579,15 +2381,38 @@ export class ClaudeAcpAgent {
1579
2381
  // debt so that lagged idle is absorbed rather than read
1580
2382
  // as the freshly-activated turn ending without a result
1581
2383
  // (which would false-fail a healthy turn — issue #825).
1582
- owedTrailingIdles++;
2384
+ // Counted for a DEFERRED turn too, even though its own
2385
+ // result already recorded a debt that may still be
2386
+ // outstanding: the interrupt can produce a trailer of
2387
+ // its own, and over-counting is benign (absorbs one
2388
+ // future idle) while under-counting risks the false
2389
+ // fail this debt exists to prevent.
2390
+ session.owedTrailingIdles++;
1583
2391
  // Before activateTurn resets the accumulator, so the
1584
2392
  // usage still belongs to the cancelled turn.
1585
2393
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1586
2394
  }
2395
+ else if (isHeldOpen(session.activeTurn)) {
2396
+ // A turn held open for its background subagents (see
2397
+ // Turn.deferredSettle) hands off with the real outcome
2398
+ // its result recorded, not a guessed end_turn — the
2399
+ // user moving on must not block behind a long-running
2400
+ // subagent, but it must not rewrite the stop reason
2401
+ // either. Its trailing-idle debt stands and is absorbed
2402
+ // when the drain idle eventually arrives.
2403
+ settleActive(session.activeTurn.deferredSettle);
2404
+ }
1587
2405
  else {
1588
2406
  settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
1589
2407
  }
1590
2408
  }
2409
+ // Unlike the no-result teardown lanes, this hand-off must
2410
+ // NOT clear emittedAssistantText for a NON-held previous
2411
+ // turn (a held one's settleActive above closes its own
2412
+ // stretch): the echo can land
2413
+ // mid-message, so deltas already streamed belong to the turn
2414
+ // being activated — clearing would forget them and let its
2415
+ // result re-emit the answer.
1591
2416
  activateTurn(queued);
1592
2417
  }
1593
2418
  break;
@@ -1640,7 +2465,7 @@ export class ClaudeAcpAgent {
1640
2465
  taskState: session.taskState,
1641
2466
  messageId: messageIdForGrouping(message),
1642
2467
  })) {
1643
- await this.client.sessionUpdate(notification);
2468
+ await sendUpdate(notification);
1644
2469
  }
1645
2470
  }
1646
2471
  else {
@@ -1664,12 +2489,7 @@ export class ClaudeAcpAgent {
1664
2489
  if (message.message.role === "system") {
1665
2490
  break;
1666
2491
  }
1667
- if (message.type === "assistant" &&
1668
- message.message.model === "<synthetic>" &&
1669
- Array.isArray(message.message.content) &&
1670
- message.message.content.length === 1 &&
1671
- message.message.content[0].type === "text" &&
1672
- message.message.content[0].text.includes("Please run /login")) {
2492
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
1673
2493
  failActive(RequestError.authRequired());
1674
2494
  break;
1675
2495
  }
@@ -1752,13 +2572,18 @@ export class ClaudeAcpAgent {
1752
2572
  taskState: session.taskState,
1753
2573
  emittedToolCalls: session.emittedToolCalls,
1754
2574
  messageId: messageIdForGrouping(message),
2575
+ toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
1755
2576
  })) {
1756
- await this.client.sessionUpdate(notification);
2577
+ // sendUpdate records delivery. Subagent text/thinking is
2578
+ // filtered out of `content` above; blocks that do pass through
2579
+ // (e.g. a subagent image) carry the stamped parentToolUseId
2580
+ // meta and are excluded there.
2581
+ await sendUpdate(notification);
1757
2582
  }
1758
2583
  break;
1759
2584
  }
1760
2585
  case "tool_progress": {
1761
- await this.client.sessionUpdate({
2586
+ await sendUpdate({
1762
2587
  sessionId: message.session_id,
1763
2588
  update: {
1764
2589
  sessionUpdate: "tool_call_update",
@@ -1776,7 +2601,7 @@ export class ClaudeAcpAgent {
1776
2601
  }
1777
2602
  case "rate_limit_event": {
1778
2603
  if (lastAssistantTotalUsage !== null) {
1779
- await this.client.sessionUpdate({
2604
+ await sendUpdate({
1780
2605
  sessionId: message.session_id,
1781
2606
  update: {
1782
2607
  sessionUpdate: "usage_update",
@@ -1791,16 +2616,13 @@ export class ClaudeAcpAgent {
1791
2616
  // `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
1792
2617
  // flows) is safe to drop: turn lifecycle here is driven by
1793
2618
  // results/idle, and the client owns its own transcript view.
1794
- // `control_request_progress` only reports on side_question control
1795
- // requests, which this adapter never issues.
1796
2619
  case "tool_use_summary":
1797
2620
  case "auth_status":
1798
2621
  case "prompt_suggestion":
1799
2622
  case "conversation_reset":
1800
- case "control_request_progress":
1801
2623
  break;
1802
2624
  default:
1803
- unreachable(message);
2625
+ unreachable(message, this.logger);
1804
2626
  break;
1805
2627
  }
1806
2628
  }
@@ -1837,6 +2659,30 @@ export class ClaudeAcpAgent {
1837
2659
  }
1838
2660
  }
1839
2661
  }
2662
+ /** Route one orphaned command into the session's orphan-accounting lane:
2663
+ * the per-uuid map on msg_lifecycle_v1 CLIs (drained by the command's own
2664
+ * terminal lifecycle frame and the echo-less-result skip), the plain count
2665
+ * elsewhere (the count lane can't express per-command states, so `state`
2666
+ * only matters on the map lane). Both orphan-producing paths — cancel()'s
2667
+ * queued-turn sweep and the consumer's force-cancel wedge path — must seed
2668
+ * through here so the lane split stays a single mechanism.
2669
+ *
2670
+ * Known window: `msgLifecycleV1` is only learnable from the stream's first
2671
+ * `system`/init (the control-channel initialize carries no capabilities),
2672
+ * so a cancel that beats that drain seeds the COUNT lane on a
2673
+ * lifecycle-capable CLI — where command coalescing can leave the count
2674
+ * stale by N-1 (the pre-map bug, confined to this sub-second window and
2675
+ * still healed by the next activation's reset). Structural until the SDK
2676
+ * exposes capabilities before the stream starts. */
2677
+ trackOrphanCommand(session, uuid, state) {
2678
+ if (session.msgLifecycleV1) {
2679
+ session.orphanCommands ??= new Map();
2680
+ session.orphanCommands.set(uuid, state);
2681
+ }
2682
+ else {
2683
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
2684
+ }
2685
+ }
1840
2686
  async cancel(params) {
1841
2687
  const session = this.sessions[params.sessionId];
1842
2688
  if (!session) {
@@ -1850,28 +2696,117 @@ export class ClaudeAcpAgent {
1850
2696
  return;
1851
2697
  }
1852
2698
  session.cancelled = true;
2699
+ // Capture the orphan-accounting lane before anything can await: the
2700
+ // consumer latches msgLifecycleV1 when it drains the first system/init,
2701
+ // which can happen DURING the awaited interrupt() below — the receipt
2702
+ // reconciliation must act on the same lane the seeding used, or a
2703
+ // count-lane orphan would be left for the map-lane receipt path (which
2704
+ // never decrements the count) to miss.
2705
+ const lifecycleLane = session.msgLifecycleV1 === true;
1853
2706
  // Settle queued turns that haven't started yet (no echo seen) right away —
1854
2707
  // they have no in-flight SDK work to interrupt. The active turn is settled
1855
2708
  // by the consumer when it observes the interrupt's trailing idle (or via the
1856
2709
  // backstop below). Mirrors the old pendingMessages cancellation.
2710
+ const orphanedTurns = [];
1857
2711
  if (session.turnQueue) {
1858
- let orphaned = 0;
1859
2712
  for (const turn of session.turnQueue) {
1860
2713
  if (turn !== session.activeTurn && !turn.settled) {
1861
2714
  turn.settled = true;
1862
2715
  // Deliberately no `usage`: a queued turn never ran, so the session
1863
2716
  // accumulator (the active turn's tally) is not its spend.
1864
2717
  turn.resolve({ stopReason: "cancelled" });
1865
- orphaned++;
2718
+ orphanedTurns.push(turn);
1866
2719
  }
1867
2720
  }
1868
2721
  // Each removed queued turn's user message was already pushed to the SDK,
1869
2722
  // which processes input FIFO and will still emit a result for it with no
1870
- // uuid to match. Count those so the consumer skips them (see
2723
+ // uuid to match. Track those so the consumer skips them (see
1871
2724
  // ensureActiveTurn) rather than misattributing them to the head.
1872
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
2725
+ // msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
2726
+ // own terminal lifecycle frame — exact under command coalescing, where
2727
+ // N queued commands fold into ONE turn emitting one result and a plain
2728
+ // count would go stale by N-1 and swallow a later echo-less result.
2729
+ // Older CLIs keep the count and its activation-time self-heal (they
2730
+ // never see lifecycle frames, so commandStarted/commandFinished stay
2731
+ // unset and every turn takes the plain-seed path below).
2732
+ for (const turn of orphanedTurns) {
2733
+ if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
2734
+ // The command already finished SDK-side and its terminal frame was
2735
+ // consumed while the turn sat queued — nothing is left to skip, and
2736
+ // a seeded entry would never drain.
2737
+ continue;
2738
+ }
2739
+ if (turn.commandFinished === "cancelled") {
2740
+ // Terminal frame already consumed. Dispatched-then-aborted: the
2741
+ // dead turn's late result may still come — seed the zombie the
2742
+ // frame handler would have made — unless that result already
2743
+ // passed pre-cancel (commandResultSeen: e.g. the command folded
2744
+ // into the active turn and their shared result was attributed
2745
+ // there), in which case a zombie would be a phantom that swallows
2746
+ // an unrelated later result. Never dispatched: dropped, no result
2747
+ // coming, nothing to track.
2748
+ if (turn.commandStarted && !turn.commandResultSeen) {
2749
+ this.trackOrphanCommand(session, turn.promptUuid, "zombie");
2750
+ }
2751
+ continue;
2752
+ }
2753
+ if (turn.commandStarted && turn.commandResultSeen) {
2754
+ // Dispatched and its turn's result already passed; only its
2755
+ // terminal frame is outstanding, which no-ops with no entry.
2756
+ continue;
2757
+ }
2758
+ this.trackOrphanCommand(session, turn.promptUuid, turn.commandStarted ? "started" : "pending");
2759
+ }
1873
2760
  session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
1874
2761
  }
2762
+ // A deferred active turn (see Turn.deferredSettle) already has its
2763
+ // result — it is only held open for its background subagents, which the
2764
+ // interrupt below tears down. Settle it "cancelled" NOW: during the hold
2765
+ // the session is typically already in state idle (the CLI's trailer
2766
+ // fired at the result), so the interrupt may produce no fresh idle for
2767
+ // the consumer's cancelled-settle path to run on, and the cancel would
2768
+ // otherwise stall until the force-cancel backstop. Any outstanding
2769
+ // trailer debt is absorbed by the idle handler when its idle does come.
2770
+ // The turn's own usage snapshot is reported per the cancelled-usage
2771
+ // contract (issue #844).
2772
+ {
2773
+ const active = session.activeTurn;
2774
+ if (isHeldOpen(active)) {
2775
+ active.settled = true;
2776
+ // Mirror settleActive's invariants (it is consumer-scoped and
2777
+ // unreachable from here): disarm the backstop — none should be
2778
+ // armed for a held turn, but a drift here must not leave a timer
2779
+ // firing on a settled turn — and drop the turn from the queue.
2780
+ disarmForceCancel(session);
2781
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== active);
2782
+ session.activeTurn = null;
2783
+ // Settling a held turn closes its delivery stretch: any streamed
2784
+ // text since the last boundary was its followups', and left latched
2785
+ // it would suppress a following replayed turn's issue-#453 fallback.
2786
+ session.emittedAssistantText = false;
2787
+ // When the interrupt below pre-empts a live cycle — running, or
2788
+ // blocked on a permission request (requires_action, the #866 shape
2789
+ // users cancel out of) — it produces a trailer idle with no counted
2790
+ // result; with the hold's own trailer typically already absorbed,
2791
+ // that idle would be un-owed and could lag past the next prompt's
2792
+ // echo — read as the fresh turn ending without a result (issue #825
2793
+ // false-fail). Pre-count it unless the session sits idle: there the
2794
+ // interrupt emits nothing, and a debt that never drains would mask
2795
+ // one future #825 detection. (lastSessionState is last-CONSUMED, so
2796
+ // both stale reads exist and both are accepted one-cycle windows: a
2797
+ // running transition still in the backlog reads as stale idle and
2798
+ // under-counts — that false-fail additionally needs the trailer to
2799
+ // lag past the next echo — while a cycle already completed into the
2800
+ // backlog reads as stale non-idle and over-counts, masking one
2801
+ // future #825 detection. Undefined — no state event consumed —
2802
+ // pre-counts; that only occurs on CLIs whose missing idle events
2803
+ // also disable the detector the debt could mask.)
2804
+ if (session.lastSessionState !== "idle") {
2805
+ session.owedTrailingIdles++;
2806
+ }
2807
+ active.resolve({ stopReason: "cancelled", usage: active.deferredSettle.usage });
2808
+ }
2809
+ }
1875
2810
  // Arm a backstop before interrupting: if a turn is actively consuming the
1876
2811
  // query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
1877
2812
  // block — issue #680), force the consumer to settle the active turn
@@ -1893,7 +2828,48 @@ export class ClaudeAcpAgent {
1893
2828
  cancelController.abort();
1894
2829
  }, this.forceCancelGraceMs);
1895
2830
  }
1896
- await session.query.interrupt();
2831
+ const receipt = await session.query.interrupt();
2832
+ // On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
2833
+ // lists exactly which queued messages survive the interrupt and will still
2834
+ // run. An orphaned turn whose uuid is absent was dropped by the interrupt
2835
+ // and will never emit a result — uncount it now instead of leaving a stale
2836
+ // skip that activateTurn's reset only clears once a later live ECHO
2837
+ // arrives: an echo-less result in between (a local-only command like
2838
+ // `/context`) would be wrongly swallowed by the leftover count. Subtracting
2839
+ // a count (rather than tracking uuids) stays race-safe against the
2840
+ // consumer draining concurrently: dropped uuids produce no results, so the
2841
+ // consumer's decrements only ever consume the still-queued share. Unknown
2842
+ // uuids in the receipt (internally-enqueued messages) are ignored, per its
2843
+ // contract. Older CLIs resolve `undefined` (guard the FIELD, not just the
2844
+ // receipt, so a bare `{}` success from a gateway can't read as "everything
2845
+ // was dropped") — keep the count-everything behavior and its
2846
+ // activation-time self-heal.
2847
+ if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
2848
+ const stillQueued = new Set(receipt.still_queued);
2849
+ if (lifecycleLane) {
2850
+ // Lifecycle lane: forget dropped orphans by uuid. Only entries still
2851
+ // "pending" — an orphan absent from `still_queued` because it was
2852
+ // DISPATCHED before the interrupt (not dropped) has usually been
2853
+ // promoted to "started" by its lifecycle frame by now, and its own
2854
+ // terminal frame must stay in charge of its fate. (If that frame is
2855
+ // still in the consumer's backlog we mis-forget — the same exposure
2856
+ // the count lane has always had for a dropped-then-run command.)
2857
+ // Mostly redundant with the "cancelled"-frame removal, but a receipt
2858
+ // survives paths where that frame was never emitted.
2859
+ for (const turn of orphanedTurns) {
2860
+ if (!stillQueued.has(turn.promptUuid) &&
2861
+ session.orphanCommands?.get(turn.promptUuid) === "pending") {
2862
+ session.orphanCommands.delete(turn.promptUuid);
2863
+ }
2864
+ }
2865
+ }
2866
+ else {
2867
+ const dropped = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid)).length;
2868
+ if (dropped > 0) {
2869
+ session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
2870
+ }
2871
+ }
2872
+ }
1897
2873
  }
1898
2874
  /** Mark a session's SDK query stream as permanently ended and release the
1899
2875
  * resources tied to it: drop the consumer handle, dispose the settings
@@ -1939,10 +2915,7 @@ export class ClaudeAcpAgent {
1939
2915
  // after the floor, and clear the timer so it can't outlive the deleted
1940
2916
  // session (it isn't unref'd and would otherwise keep the event loop alive
1941
2917
  // until it fires).
1942
- if (session.forceCancelTimer) {
1943
- clearTimeout(session.forceCancelTimer);
1944
- session.forceCancelTimer = undefined;
1945
- }
2918
+ disarmForceCancel(session);
1946
2919
  session.cancelController?.abort();
1947
2920
  this.closeQueryStream(session);
1948
2921
  // Abort the SDK abort signal only on explicit destroy. closeQueryStream
@@ -2122,6 +3095,12 @@ export class ClaudeAcpAgent {
2122
3095
  if (replaySession && replayMessageId && message.uuid) {
2123
3096
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
2124
3097
  }
3098
+ // The live prompt loop converts the synthetic "Please run /login"
3099
+ // assistant message into an authRequired error instead of showing its
3100
+ // TUI-specific text; skip it on replay too (issue #863).
3101
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3102
+ continue;
3103
+ }
2125
3104
  // @ts-expect-error - untyped in SDK but we handle all of these
2126
3105
  let content = message.message.content;
2127
3106
  // @ts-expect-error - untyped in SDK but we handle all of these
@@ -2160,14 +3139,14 @@ export class ClaudeAcpAgent {
2160
3139
  * outcome or a `requestCancelled` rejection). Either way we surface the same
2161
3140
  * "Tool use aborted" the callers already expect, so a cancelled dialog no
2162
3141
  * longer leaves the `await` hanging. */
2163
- async requestPermissionFromClient(params, toolName, signal) {
3142
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
2164
3143
  // The SDK may invoke `canUseTool` (and therefore this permission request)
2165
3144
  // before the assistant message's tool_use block streams to us. Some ACP clients
2166
3145
  // expect the `tool_call` a permission request references to already exist,
2167
3146
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
2168
3147
  // later refines it with a `tool_call_update` rather than emitting a
2169
3148
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
2170
- await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
3149
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
2171
3150
  try {
2172
3151
  return await this.client.requestPermission(params, signal);
2173
3152
  }
@@ -2184,10 +3163,15 @@ export class ClaudeAcpAgent {
2184
3163
  * instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
2185
3164
  * `toolCallNotification` helper as the streamed path so the two are identical.
2186
3165
  * Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
2187
- * skipped so a permission prompt for them never surfaces a stray tool_call. */
2188
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput) {
3166
+ * emitted too: a permission request referencing a tool call the client has
3167
+ * never seen can trip strict clients (issue #851), so the reference must
3168
+ * always resolve. Since the streamed path never completes those calls, they
3169
+ * are resolved at tool_result time instead (see `toAcpNotifications`).
3170
+ * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
3171
+ * that spawned it, matching the streamed path's `_meta`. */
3172
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
2189
3173
  const session = this.sessions[sessionId];
2190
- if (!session || !shouldEmitToolCall(toolName)) {
3174
+ if (!session) {
2191
3175
  return;
2192
3176
  }
2193
3177
  if (session.emittedToolCalls.has(toolCallId)) {
@@ -2195,13 +3179,20 @@ export class ClaudeAcpAgent {
2195
3179
  }
2196
3180
  session.emittedToolCalls.add(toolCallId);
2197
3181
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2198
- await this.client.sessionUpdate({
2199
- sessionId,
2200
- update: toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd),
2201
- });
3182
+ const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
3183
+ if (parentToolUseId) {
3184
+ update._meta = {
3185
+ ...update._meta,
3186
+ claudeCode: {
3187
+ ...(update._meta?.claudeCode || {}),
3188
+ parentToolUseId,
3189
+ },
3190
+ };
3191
+ }
3192
+ await this.client.sessionUpdate({ sessionId, update });
2202
3193
  }
2203
3194
  canUseTool(sessionId) {
2204
- return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
3195
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID }) => {
2205
3196
  const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
2206
3197
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2207
3198
  const session = this.sessions[sessionId];
@@ -2211,6 +3202,23 @@ export class ClaudeAcpAgent {
2211
3202
  message: "Session not found",
2212
3203
  };
2213
3204
  }
3205
+ // When the tool call originates inside a subagent, attribute the eagerly
3206
+ // emitted tool_call (and the permission request itself) to the Agent/Task
3207
+ // tool call that spawned the subagent, mirroring the streamed subagent
3208
+ // path's `_meta.claudeCode.parentToolUseId` (see `liveBackgroundTasks`).
3209
+ const parentToolUseId = agentID
3210
+ ? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
3211
+ : undefined;
3212
+ if (agentID && !parentToolUseId) {
3213
+ // The attribution rests on an undocumented SDK invariant
3214
+ // (task_started.task_id === canUseTool's agentID for subagent tasks;
3215
+ // verified against the bundled CLI). Should an SDK bump break it — or
3216
+ // the consumer lose the race with task_started — the lookup misses and
3217
+ // the request goes out unattributed; log it so the regression is
3218
+ // observable rather than silent.
3219
+ this.logger.log(`[claude-agent-acp] No parent tool_use recorded for subagent ${agentID}; ` +
3220
+ `sending the ${toolName} permission request unattributed`);
3221
+ }
2214
3222
  // AskUserQuestion is surfaced to us as a normal permission check (the SDK
2215
3223
  // routes it through canUseTool whenever a callback is registered, rather
2216
3224
  // than the interactive dialog). Present it as an ACP form elicitation and
@@ -2218,7 +3226,7 @@ export class ClaudeAcpAgent {
2218
3226
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
2219
3227
  // Like permission requests, the elicitation references this toolUseID, so
2220
3228
  // make sure the tool_call has surfaced to the client before we send it.
2221
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
3229
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
2222
3230
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
2223
3231
  }
2224
3232
  if (toolName === "ExitPlanMode") {
@@ -2252,8 +3260,13 @@ export class ClaudeAcpAgent {
2252
3260
  toolCallId: toolUseID,
2253
3261
  rawInput: toolInput,
2254
3262
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3263
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3264
+ // so clients can rely on one shape everywhere.
3265
+ ...(parentToolUseId
3266
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3267
+ : {}),
2255
3268
  },
2256
- }, toolName, signal);
3269
+ }, toolName, signal, parentToolUseId);
2257
3270
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2258
3271
  throw new Error("Tool use aborted");
2259
3272
  }
@@ -2311,8 +3324,13 @@ export class ClaudeAcpAgent {
2311
3324
  toolCallId: toolUseID,
2312
3325
  rawInput: toolInput,
2313
3326
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3327
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3328
+ // so clients can rely on one shape everywhere.
3329
+ ...(parentToolUseId
3330
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3331
+ : {}),
2314
3332
  },
2315
- }, toolName, signal);
3333
+ }, toolName, signal, parentToolUseId);
2316
3334
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2317
3335
  throw new Error("Tool use aborted");
2318
3336
  }
@@ -2488,12 +3506,17 @@ export class ClaudeAcpAgent {
2488
3506
  // carries no "1m" token.
2489
3507
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
2490
3508
  if (session.models.currentModelId !== value) {
2491
- // The cached context window was learned for the previous model; reset
2492
- // to the new model's heuristic so mid-stream updates between now and
2493
- // the next `result` reflect the user's selection instead of the old
2494
- // model's window.
3509
+ // The cached context window was learned for the previous model. The
3510
+ // SDK is already running the new model here (user-driven switches call
3511
+ // `query.setModel` before this, and the refusal-fallback sync only
3512
+ // reconciles a switch the SDK already made), so ask it for the new
3513
+ // window; fall back to the text heuristic so mid-stream updates
3514
+ // between now and the next `result` reflect the user's selection
3515
+ // instead of the old model's window.
2495
3516
  session.contextWindowSize =
2496
- inferContextWindowFromModel(value, newModelInfo?.displayName, newModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW;
3517
+ (await fetchContextWindowSize(session.query, this.logger)) ??
3518
+ inferContextWindowFromModel(value, newModelInfo?.resolvedModel, newModelInfo?.displayName, newModelInfo?.description) ??
3519
+ DEFAULT_CONTEXT_WINDOW;
2497
3520
  }
2498
3521
  session.models = { ...session.models, currentModelId: value };
2499
3522
  // Recompute availableModes for the new model and clamp the current
@@ -2841,7 +3864,10 @@ export class ClaudeAcpAgent {
2841
3864
  env: {
2842
3865
  ...process.env,
2843
3866
  ...userProvidedOptions?.env,
2844
- ...createEnvForGateway(this.gatewayAuthRequest),
3867
+ // Client-managed LLM routing: `providers/set` config wins, else the
3868
+ // legacy gateway auth request. Baked into the query at creation, so it
3869
+ // only affects sessions started after the change (matching the RFD).
3870
+ ...createEnvForProvider(this.resolveProviderConfig()),
2845
3871
  // Opt-in to session state events like when the agent is idle
2846
3872
  CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
2847
3873
  },
@@ -3091,6 +4117,22 @@ export class ClaudeAcpAgent {
3091
4117
  effortLevel: initialEffort.currentValue,
3092
4118
  });
3093
4119
  }
4120
+ // Seed the context window from the SDK's authoritative report. Text
4121
+ // inference alone misses aliases that resolve to extended-context models
4122
+ // with no "1m" token anywhere in their id or description (e.g. `sonnet` →
4123
+ // claude-sonnet-5, natively ~1M): those streamed `usage_update.size:
4124
+ // 200000` until the first result's modelUsage corrected it — again on
4125
+ // every process restart or session re-creation, since the learned window
4126
+ // lives only on the Session (issue #596).
4127
+ //
4128
+ // The inference fallback is deliberately keyed to the allowlisted entry: a
4129
+ // fallback-resolved sibling's resolvedModel/displayName/description can
4130
+ // describe a different context lane than the verbatim live id (e.g. an
4131
+ // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
4132
+ // the id itself is a trustworthy window signal.
4133
+ const contextWindowSize = (await fetchContextWindowSize(q, this.logger)) ??
4134
+ inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.resolvedModel, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ??
4135
+ DEFAULT_CONTEXT_WINDOW;
3094
4136
  this.sessions[sessionId] = {
3095
4137
  query: q,
3096
4138
  input: input,
@@ -3113,16 +4155,13 @@ export class ClaudeAcpAgent {
3113
4155
  fastModeEnabled,
3114
4156
  abortController,
3115
4157
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
3116
- contextWindowSize:
3117
- // Deliberately keyed to the allowlisted entry: a fallback-resolved
3118
- // sibling's displayName/description can describe a different context
3119
- // lane than the verbatim live id (e.g. an "opus[1m]" row matched for
3120
- // a bare 200k id), so on the fallback path only the id itself is a
3121
- // trustworthy window signal.
3122
- inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
4158
+ contextWindowSize,
3123
4159
  taskState,
3124
4160
  toolUseCache: {},
3125
4161
  emittedToolCalls: new Set(),
4162
+ liveBackgroundTasks: new Map(),
4163
+ emittedAssistantText: false,
4164
+ owedTrailingIdles: 0,
3126
4165
  messageIdToUuid: new Map(),
3127
4166
  };
3128
4167
  return {
@@ -3192,27 +4231,76 @@ function snapshotFromUsage(usage) {
3192
4231
  cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
3193
4232
  };
3194
4233
  }
3195
- function createEnvForGateway(request) {
4234
+ /**
4235
+ * Adapt a legacy gateway `authenticate` request into the shared
4236
+ * {@link ProviderConfig} shape. Returns `null` when no gateway request is
4237
+ * present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
4238
+ * otherwise anthropic.
4239
+ */
4240
+ function gatewayRequestToProviderConfig(request) {
3196
4241
  if (!request?._meta) {
4242
+ return null;
4243
+ }
4244
+ return {
4245
+ apiType: request.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
4246
+ baseUrl: request._meta.gateway.baseUrl,
4247
+ headers: request._meta.gateway.headers,
4248
+ };
4249
+ }
4250
+ /**
4251
+ * Map a resolved provider config into the Claude Code env vars that redirect API
4252
+ * traffic and inject headers. Returns an empty object when routing is
4253
+ * unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
4254
+ * skips its normal login/credential checks when a gateway is in use.
4255
+ */
4256
+ function createEnvForProvider(config) {
4257
+ if (!config) {
3197
4258
  return {};
3198
4259
  }
3199
- const customHeaders = Object.entries(request._meta.gateway.headers)
4260
+ const customHeaders = Object.entries(config.headers)
3200
4261
  .map(([key, value]) => `${key}: ${value}`)
3201
4262
  .join("\n");
3202
- if (request.methodId === "gateway-bedrock") {
4263
+ if (config.apiType === "bedrock") {
3203
4264
  return {
3204
4265
  CLAUDE_CODE_USE_BEDROCK: "1",
3205
4266
  AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
3206
- ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
4267
+ ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
4268
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
4269
+ };
4270
+ }
4271
+ if (config.apiType === "vertex") {
4272
+ // `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
4273
+ // validation; fall back to empty strings defensively.
4274
+ return {
4275
+ CLAUDE_CODE_USE_VERTEX: "1",
4276
+ ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
4277
+ ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
4278
+ CLOUD_ML_REGION: config.vertex?.region ?? "",
3207
4279
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3208
4280
  };
3209
4281
  }
3210
4282
  return {
3211
- ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
4283
+ ANTHROPIC_BASE_URL: config.baseUrl,
3212
4284
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3213
4285
  ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
3214
4286
  };
3215
4287
  }
4288
+ /**
4289
+ * Validate a provider base URL: must be a non-empty absolute http(s) URL.
4290
+ */
4291
+ function isValidBaseUrl(baseUrl) {
4292
+ if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
4293
+ return false;
4294
+ }
4295
+ let parsed;
4296
+ try {
4297
+ parsed = new URL(baseUrl);
4298
+ }
4299
+ catch {
4300
+ return false;
4301
+ }
4302
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
4303
+ }
3216
4304
  /**
3217
4305
  * Build the list of permission modes the agent will advertise for the given
3218
4306
  * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
@@ -3974,10 +5062,12 @@ function isTaskTool(toolName) {
3974
5062
  toolName === "TaskList" ||
3975
5063
  toolName === "TaskGet");
3976
5064
  }
3977
- /** Whether a tool's tool_use surfaces to the client as a standalone
5065
+ /** Whether the streamed tool_use path surfaces this tool as a standalone
3978
5066
  * `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
3979
5067
  * suppressed (their plan snapshot is emitted at tool_result time), so neither
3980
- * produces a tool_call. */
5068
+ * produces a streamed tool_call/tool_call_update — which means a
5069
+ * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
5070
+ * resolved explicitly at tool_result time. */
3981
5071
  function shouldEmitToolCall(toolName) {
3982
5072
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
3983
5073
  }
@@ -4012,6 +5102,28 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
4012
5102
  ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
4013
5103
  };
4014
5104
  }
5105
+ /** Refine a pending tool call from the complete top-level fields recovered
5106
+ * from its still-streaming input. Shares `toolInfoFromToolUse` with the
5107
+ * consolidated path but never carries `content`: content built from partial
5108
+ * input is misleading (an Edit missing its `new_string` renders as a pure
5109
+ * deletion) or invalid (a Write diff without `content` lacks the required
5110
+ * `newText`), and the consolidated message supplies it moments later. */
5111
+ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
5112
+ // TodoWrite/Task* never surfaced a tool_call to refine (plan lane).
5113
+ if (!shouldEmitToolCall(toolUse.name)) {
5114
+ return undefined;
5115
+ }
5116
+ const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
5117
+ return {
5118
+ _meta: { claudeCode: { toolName: toolUse.name } },
5119
+ toolCallId: toolUse.id,
5120
+ sessionUpdate: "tool_call_update",
5121
+ rawInput: input,
5122
+ title,
5123
+ kind,
5124
+ ...(locations ? { locations } : {}),
5125
+ };
5126
+ }
4015
5127
  /**
4016
5128
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
4017
5129
  * Only handles text, image, and thinking chunks for now.
@@ -4043,6 +5155,15 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4043
5155
  }
4044
5156
  return [{ sessionId, update }];
4045
5157
  }
5158
+ // `tool_use_result` is message-level and carries no tool_use_id of its own:
5159
+ // it describes "the" tool_result block of the message it rode in on. If
5160
+ // several tool_result blocks were ever batched into one message it couldn't
5161
+ // be attributed, so it is only honored when the message carries exactly one.
5162
+ const toolUseResult = options?.toolUseResult !== undefined &&
5163
+ content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
5164
+ .length === 1
5165
+ ? options.toolUseResult
5166
+ : undefined;
4046
5167
  const output = [];
4047
5168
  // Only handle the first chunk for streaming; extend as needed for batching
4048
5169
  for (const chunk of content) {
@@ -4184,12 +5305,59 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4184
5305
  case "bash_code_execution_tool_result":
4185
5306
  case "text_editor_code_execution_tool_result":
4186
5307
  case "mcp_tool_result": {
5308
+ const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
4187
5309
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
4188
5310
  const toolUse = toolUseCache[chunk.tool_use_id];
4189
5311
  if (!toolUse) {
5312
+ // The permission flow may have surfaced this tool_call even though
5313
+ // its tool_use never reached the cache (e.g. the assistant message
5314
+ // carrying it was dropped by the cancelled-turn guard and a straggler
5315
+ // result landed later). Resolve the surfaced call anyway so it can't
5316
+ // stay pending in the client forever; without the cache entry the
5317
+ // tool name is unknown, so no claudeCode meta is attached.
5318
+ if (wasEmitted) {
5319
+ output.push({
5320
+ sessionId,
5321
+ update: {
5322
+ toolCallId: chunk.tool_use_id,
5323
+ sessionUpdate: "tool_call_update",
5324
+ status: "is_error" in chunk && chunk.is_error
5325
+ ? "failed"
5326
+ : "completed",
5327
+ rawOutput: chunk.content,
5328
+ },
5329
+ });
5330
+ }
4190
5331
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
4191
5332
  break;
4192
5333
  }
5334
+ // A permission request may have surfaced a plan-rendered (TodoWrite) or
5335
+ // suppressed (Task*) tool as a real tool_call so the request referenced
5336
+ // a tool call the client knows about (see `ensureToolCallEmitted`,
5337
+ // issue #851). The branches below never emit a tool_call_update for
5338
+ // those tools, which would leave the surfaced call pending in the
5339
+ // client forever — resolve it here. `wasEmitted` is only ever true for
5340
+ // these tools via the permission flow: the streamed plan/suppressed
5341
+ // branches don't record emissions.
5342
+ if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
5343
+ output.push({
5344
+ sessionId,
5345
+ update: {
5346
+ _meta: {
5347
+ claudeCode: {
5348
+ toolName: toolUse.name,
5349
+ ...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
5350
+ },
5351
+ },
5352
+ toolCallId: chunk.tool_use_id,
5353
+ sessionUpdate: "tool_call_update",
5354
+ status: "is_error" in chunk && chunk.is_error
5355
+ ? "failed"
5356
+ : "completed",
5357
+ rawOutput: chunk.content,
5358
+ },
5359
+ });
5360
+ }
4193
5361
  if (isTaskTool(toolUse.name)) {
4194
5362
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
4195
5363
  // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
@@ -4213,7 +5381,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4213
5381
  }
4214
5382
  }
4215
5383
  else if (toolUse.name !== "TodoWrite") {
4216
- const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
5384
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, toolUseResult);
4217
5385
  // When terminal output is supported, send terminal_output as a
4218
5386
  // separate notification to match codex-acp's streaming lifecycle:
4219
5387
  // 1. tool_call → _meta.terminal_info (already sent above)
@@ -4290,35 +5458,105 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4290
5458
  }
4291
5459
  export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
4292
5460
  const event = message.event;
5461
+ const streamKey = message.parent_tool_use_id ?? "";
5462
+ const streamedToolInputs = options?.streamedToolInputs;
5463
+ const forwardedOptions = {
5464
+ clientCapabilities: options?.clientCapabilities,
5465
+ parentToolUseId: message.parent_tool_use_id,
5466
+ cwd: options?.cwd,
5467
+ taskState: options?.taskState,
5468
+ emittedToolCalls: options?.emittedToolCalls,
5469
+ messageId: options?.messageId,
5470
+ };
4293
5471
  switch (event.type) {
4294
- case "content_block_start":
4295
- return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
4296
- clientCapabilities: options?.clientCapabilities,
4297
- parentToolUseId: message.parent_tool_use_id,
4298
- cwd: options?.cwd,
4299
- taskState: options?.taskState,
4300
- emittedToolCalls: options?.emittedToolCalls,
4301
- messageId: options?.messageId,
4302
- });
4303
- case "content_block_delta":
4304
- return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
4305
- clientCapabilities: options?.clientCapabilities,
4306
- parentToolUseId: message.parent_tool_use_id,
4307
- cwd: options?.cwd,
4308
- taskState: options?.taskState,
4309
- emittedToolCalls: options?.emittedToolCalls,
4310
- messageId: options?.messageId,
4311
- });
5472
+ case "content_block_start": {
5473
+ const block = event.content_block;
5474
+ if (streamedToolInputs &&
5475
+ (block.type === "tool_use" ||
5476
+ block.type === "server_tool_use" ||
5477
+ block.type === "mcp_tool_use")) {
5478
+ let inputsForMessage = streamedToolInputs.get(streamKey);
5479
+ if (!inputsForMessage) {
5480
+ inputsForMessage = new Map();
5481
+ streamedToolInputs.set(streamKey, inputsForMessage);
5482
+ }
5483
+ inputsForMessage.set(event.index, {
5484
+ id: block.id,
5485
+ name: block.name,
5486
+ partialJson: "",
5487
+ scannedTo: 0,
5488
+ inString: false,
5489
+ escaped: false,
5490
+ objectDepth: 0,
5491
+ arrayDepth: 0,
5492
+ lastTopLevelComma: -1,
5493
+ emittedThroughComma: -1,
5494
+ });
5495
+ }
5496
+ return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
5497
+ }
5498
+ case "content_block_delta": {
5499
+ if (event.delta.type === "input_json_delta") {
5500
+ const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
5501
+ if (!streamedInput)
5502
+ return [];
5503
+ streamedInput.partialJson += event.delta.partial_json;
5504
+ if (scanStreamedToolInput(streamedInput)) {
5505
+ // Input complete: the consolidated assistant message replays the
5506
+ // block with its full input and refines the call there; emitting
5507
+ // here too would send a duplicate identical update.
5508
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
5509
+ inputsForMessage?.delete(event.index);
5510
+ if (inputsForMessage?.size === 0)
5511
+ streamedToolInputs?.delete(streamKey);
5512
+ return [];
5513
+ }
5514
+ if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
5515
+ return [];
5516
+ }
5517
+ streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
5518
+ const input = recoveredToolInput(streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma));
5519
+ if (!input)
5520
+ return [];
5521
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
5522
+ const update = streamedInputRefinement(streamedInput, input, supportsTerminalOutput, options?.cwd);
5523
+ if (!update)
5524
+ return [];
5525
+ if (message.parent_tool_use_id) {
5526
+ update._meta = {
5527
+ ...update._meta,
5528
+ claudeCode: {
5529
+ ...(update._meta?.claudeCode || {}),
5530
+ parentToolUseId: message.parent_tool_use_id,
5531
+ },
5532
+ };
5533
+ }
5534
+ applyMessageId(update, options?.messageId);
5535
+ return [{ sessionId, update }];
5536
+ }
5537
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
5538
+ }
4312
5539
  // No content. `ping` is a Messages-API keep-alive event that the SDK's
4313
5540
  // `BetaRawMessageStreamEvent` union doesn't include even though the
4314
5541
  // wire format emits it; the `as never` cast lets us no-op it here
4315
5542
  // instead of letting it fall through to `unreachable`.
4316
5543
  case "ping":
4317
- case "message_start":
4318
5544
  case "message_delta":
5545
+ return [];
5546
+ // A message boundary ends every input stream on this lane: message_stop is
5547
+ // the normal end, and a message_start clears anything a prior message on
5548
+ // the lane left behind (e.g. a stream cut short mid-block).
5549
+ case "message_start":
4319
5550
  case "message_stop":
4320
- case "content_block_stop":
5551
+ streamedToolInputs?.delete(streamKey);
5552
+ return [];
5553
+ case "content_block_stop": {
5554
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
5555
+ inputsForMessage?.delete(event.index);
5556
+ if (inputsForMessage?.size === 0)
5557
+ streamedToolInputs?.delete(streamKey);
4321
5558
  return [];
5559
+ }
4322
5560
  default:
4323
5561
  unreachable(event, logger);
4324
5562
  return [];
@@ -4376,6 +5614,9 @@ export function runAcp() {
4376
5614
  .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
4377
5615
  .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
4378
5616
  .onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
5617
+ .onRequest(methods.agent.providers.list, (ctx) => agent.unstable_listProviders(ctx.params))
5618
+ .onRequest(methods.agent.providers.set, (ctx) => agent.unstable_setProvider(ctx.params))
5619
+ .onRequest(methods.agent.providers.disable, (ctx) => agent.unstable_disableProvider(ctx.params))
4379
5620
  .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
4380
5621
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
4381
5622
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
@@ -4397,12 +5638,14 @@ function commonPrefixLength(a, b) {
4397
5638
  * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
4398
5639
  * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
4399
5640
  * matching things like "10m" or embedded substrings. Semantic aliases like
4400
- * `default` carry no such token in the ID, but the SDK's human-facing
4401
- * `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
4402
- * pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
4403
- * `ModelInfo` exposes no structured context-window field, so this text scan is
4404
- * the only pre-`result` signal available. A miss falls back to the default
4405
- * window and is corrected by `result.modelUsage` within one turn. */
5641
+ * `default` carry no such token in the ID, but their `resolvedModel` and the
5642
+ * SDK's human-facing `displayName`/`description` can (e.g.
5643
+ * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
5644
+ * This text scan can't catch every model — some resolve to extended-context
5645
+ * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
5646
+ * ~1M) — which is why `fetchContextWindowSize` is preferred wherever a live
5647
+ * query is available. A miss falls back to the default window and is
5648
+ * corrected by `result.modelUsage` within one turn. */
4406
5649
  function inferContextWindowFromModel(...texts) {
4407
5650
  if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
4408
5651
  return 1_000_000;
@@ -4412,12 +5655,7 @@ function inferContextWindowFromModel(...texts) {
4412
5655
  * `getContextUsage` control request. Unlike the per-message API usage numbers
4413
5656
  * (which only count message tokens), this `totalTokens` includes the system
4414
5657
  * prompt, tool schemas, MCP tools, and memory-file overhead — the real
4415
- * occupancy the user sees. Returns `null` on any control-request failure.
4416
- *
4417
- * Note: we deliberately do NOT use this response's window fields for `size`.
4418
- * They have been observed to under-report extended (1M) context windows, so
4419
- * the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
4420
- * which handle the 1M variants correctly. */
5658
+ * occupancy the user sees. Returns `null` on any control-request failure. */
4421
5659
  async function fetchContextUsedTokens(query, logger) {
4422
5660
  try {
4423
5661
  const usage = await query.getContextUsage();
@@ -4428,6 +5666,28 @@ async function fetchContextUsedTokens(query, logger) {
4428
5666
  return null;
4429
5667
  }
4430
5668
  }
5669
+ /** Fetch the current model's full context window (`rawMaxTokens`) via the
5670
+ * `getContextUsage` control request — the same source `/context` prints.
5671
+ * This is the only pre-`result` signal that covers semantic aliases whose
5672
+ * text carries no "1m" token (e.g. `sonnet` → claude-sonnet-5, natively
5673
+ * ~1M), so it's the primary window source at session creation and on model
5674
+ * switches; `inferContextWindowFromModel` remains the fallback. A returned
5675
+ * window is still superseded by each `result.modelUsage.contextWindow`.
5676
+ *
5677
+ * (Older CLIs under-reported extended 1M windows here — commit 20ef663
5678
+ * dropped the field for that reason — but the CLI vendored by the pinned
5679
+ * SDK reports them correctly again.) Returns `null` on any control-request
5680
+ * failure or a nonsensical (non-positive) window. */
5681
+ async function fetchContextWindowSize(query, logger) {
5682
+ try {
5683
+ const usage = await query.getContextUsage();
5684
+ return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null;
5685
+ }
5686
+ catch (error) {
5687
+ logger.error("Failed to fetch context window size from SDK:", error);
5688
+ return null;
5689
+ }
5690
+ }
4431
5691
  /** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
4432
5692
  * option. The `maxThinkingTokens` option it used to feed is deprecated and
4433
5693
  * reduced to on/off on current models, so map the value to explicit thinking