@agentclientprotocol/claude-agent-acp 0.58.1 → 0.60.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,100 @@ 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;
791
+ // Learned context windows are per-account state too: 1M-context
792
+ // entitlement is gated per org/tier, and an OAuth re-login is invisible to
793
+ // the env-derived provider cache key, so windows learned under the old
794
+ // login must not seed sessions under the next. Worst case of clearing is
795
+ // re-learning on each model's next turn.
796
+ contextWindowCache.clear();
557
797
  // For the Claude/Console login methods the credentials live in the native
558
798
  // CLI's store (keychain or config dir), which only the binary can clear.
559
799
  // `claude auth logout` is non-interactive and idempotent.
@@ -666,23 +906,34 @@ export class ClaudeAcpAgent {
666
906
  // gateways that don't carry a stable/matching id across the stream and the
667
907
  // consolidated message. Reset after each consolidated message consumes it.
668
908
  const streamedBlocks = [];
909
+ // Tool-use blocks start streaming before their JSON input. Keep the
910
+ // partial input per parent message and block index so completed top-level
911
+ // fields can refine the pending tool call while it streams. Entries are
912
+ // dropped at block/message boundaries; the whole map is swept when a turn
913
+ // settles, since an interrupted subagent stream (keyed by a
914
+ // parent_tool_use_id that never recurs) has no boundary event of its own.
915
+ const streamedToolInputs = new Map();
669
916
  // Stop reason accumulated for the active turn (result subtype, refusal,
670
917
  // max_tokens, …). Reset per turn; read when the turn settles at idle.
671
918
  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;
919
+ /** The consumer's single send chokepoint: every `sessionUpdate` in this
920
+ * loop goes through here (never `this.client.sessionUpdate` directly) so
921
+ * answer-delivery tracking is a property of sending, not something each
922
+ * emission site must remember. A top-level `agent_message_chunk` marks
923
+ * the stretch's answer as delivered; subagent-attributed chunks are
924
+ * recognizable by the `parentToolUseId` meta that toAcpNotifications
925
+ * stamps from `parent_tool_use_id`, and never reach the top-level feed
926
+ * as the turn's answer. */
927
+ const sendUpdate = async (notification) => {
928
+ const { update } = notification;
929
+ if (update.sessionUpdate === "agent_message_chunk") {
930
+ const claudeMeta = update._meta?.claudeCode;
931
+ if (!claudeMeta?.parentToolUseId) {
932
+ session.emittedAssistantText = true;
933
+ }
934
+ }
935
+ await this.client.sessionUpdate(notification);
936
+ };
686
937
  const resetTurnScratch = () => {
687
938
  lastAssistantTotalUsage = null;
688
939
  lastAssistantUsage = null;
@@ -718,6 +969,31 @@ export class ClaudeAcpAgent {
718
969
  session.activeTurn = turn;
719
970
  session.cancelled = false;
720
971
  session.pendingOrphanResults = 0;
972
+ session.orphanCommands?.clear();
973
+ // Two-phase sweep of registry entries the level signal ended (see
974
+ // the endedPerLevel field doc): armed at the first activation,
975
+ // deleted at the second — the same activation-time self-heal as the
976
+ // orphan lanes, and the growth bound for leaked entries whose settle
977
+ // bookends never arrive. The one-activation grace lets a corrective
978
+ // inclusive level rescue a live async agent that a racing payload
979
+ // absent-marked (deletion is irreversible: levels never ADD entries).
980
+ // Local-only commands don't advance the clock: two quick /context
981
+ // calls would otherwise burn the whole grace in seconds of wall time
982
+ // while the corrective level is still in flight, and they interact
983
+ // with no tasks — a later real turn still bounds growth.
984
+ if (!turn.isLocalOnlyCommand) {
985
+ for (const [taskId, record] of session.liveBackgroundTasks) {
986
+ if (!record.endedPerLevel) {
987
+ continue;
988
+ }
989
+ if (record.endedPerLevel === "sweep-armed") {
990
+ session.liveBackgroundTasks.delete(taskId);
991
+ }
992
+ else {
993
+ record.endedPerLevel = "sweep-armed";
994
+ }
995
+ }
996
+ }
721
997
  resetTurnScratch();
722
998
  };
723
999
  /** Ensure there is an active turn before a user-turn result that carries no
@@ -737,18 +1013,165 @@ export class ClaudeAcpAgent {
737
1013
  * result), so we skip those and only promote once the count is drained. */
738
1014
  const ensureActiveTurn = () => {
739
1015
  if (session.activeTurn) {
740
- return;
741
- }
742
- const head = (session.turnQueue ?? []).find((t) => !t.settled);
743
- if (!head) {
744
- return;
1016
+ if (!isHeldOpen(session.activeTurn)) {
1017
+ return;
1018
+ }
1019
+ // A held turn (Turn.deferredSettle) already produced its result, so
1020
+ // this incoming user-turn result cannot be its — it belongs to the
1021
+ // next queued command (an echo-less one, e.g. `/context` sent while
1022
+ // the hold drains; a normal prompt's echo would have handed the held
1023
+ // turn off before its result). Settle the held turn with its
1024
+ // recorded outcome — the user moving on outranks the hold, same
1025
+ // contract as the echo hand-off — and fall through to promote the
1026
+ // queue head, which this result belongs to. Without this, the head
1027
+ // would never be promoted (echo-less turns have no other promotion
1028
+ // path) and its prompt would hang, while this result's outcome
1029
+ // overwrote the held turn's. Orphan lanes below are necessarily
1030
+ // empty while a turn is held: orphans are seeded by cancel(), which
1031
+ // inline-settles a held turn, and activation cleared older ones.
1032
+ // settleActive also closes the held turn's delivery stretch, so the
1033
+ // promoted command's own delivery decision is not judged against the
1034
+ // held turn's followup text (issue #453) — the caller snapshots the
1035
+ // flag AFTER this runs.
1036
+ settleActive(session.activeTurn.deferredSettle);
745
1037
  }
1038
+ // Orphan accounting runs BEFORE the head check: an orphan's echo-less
1039
+ // result can arrive with an EMPTY queue (the common post-cancel
1040
+ // timeline — the active turn settled at the interrupt's idle and the
1041
+ // user hasn't typed yet), and it must still be consumed here. Skipping
1042
+ // the bookkeeping when there is nothing to promote would leave a
1043
+ // phantom entry/count that swallows the next live echo-less result
1044
+ // (e.g. /compact) instead.
746
1045
  if ((session.pendingOrphanResults ?? 0) > 0) {
747
1046
  session.pendingOrphanResults--;
748
1047
  return;
749
1048
  }
1049
+ // msg_lifecycle_v1 lane. Attribute this echo-less result using the
1050
+ // entries' states — turns run sequentially and frames arrive in stream
1051
+ // order, so at any result: every "zombie" is from an already-dead turn
1052
+ // whose own result already passed before the frame that created the
1053
+ // newest entry (or never existed), every "started" entry was dispatched
1054
+ // into THE turn that emitted this result (an older turn's entries got
1055
+ // their terminal frames before a newer turn's "started" frames), and a
1056
+ // "pending" entry was not dispatched before it. One result therefore
1057
+ // covers ALL started and zombie entries at once (N coalesced commands
1058
+ // share ONE result); their outstanding terminal frames then no-op on
1059
+ // the missing entries. NOTE this ordering argument is asserted from
1060
+ // observed CLI behavior, not a documented wire contract — if a dead
1061
+ // turn's late result could lag past the NEXT turn's dispatch frames,
1062
+ // deleting a zombie and a started entry on one result would
1063
+ // double-consume it. The unexpected-transition logging in the frame
1064
+ // handler is the tripwire for that class of drift.
1065
+ if (session.orphanCommands?.size) {
1066
+ let consumedOrphanResult = false;
1067
+ let oldestPending;
1068
+ for (const [uuid, state] of session.orphanCommands) {
1069
+ if (state === "started" || state === "zombie") {
1070
+ consumedOrphanResult = true;
1071
+ session.orphanCommands.delete(uuid);
1072
+ }
1073
+ else {
1074
+ oldestPending ??= uuid;
1075
+ }
1076
+ }
1077
+ if (consumedOrphanResult) {
1078
+ return;
1079
+ }
1080
+ if (oldestPending !== undefined) {
1081
+ // No dispatch was seen before this result, so it is very likely a
1082
+ // live turn's — but a lost "started" frame would mean it IS the
1083
+ // orphan's (dup-over-loss: prefer one wrong skip over
1084
+ // misattributing a dead turn's outcome to a live prompt). Grant
1085
+ // each pending entry exactly one skip, like the count lane did.
1086
+ session.orphanCommands.delete(oldestPending);
1087
+ return;
1088
+ }
1089
+ }
1090
+ const head = firstUnsettledQueuedTurn();
1091
+ if (!head) {
1092
+ return;
1093
+ }
750
1094
  activateTurn(head);
751
1095
  };
1096
+ /** Result-time bookkeeping that must run whether or not the result can be
1097
+ * attributed to a turn. (1) Latch `commandResultSeen` on every queued
1098
+ * turn whose command is known dispatched with no terminal frame yet —
1099
+ * the emitting turn is the one it was dispatched (possibly folded) into,
1100
+ * so its result has now passed; a later cancel() must not seed an orphan
1101
+ * entry that waits for it (see Turn.commandResultSeen). (2) When a turn
1102
+ * is ACTIVE, the result is attributed to it and never reaches
1103
+ * ensureActiveTurn — but it still covers the map's started entries
1104
+ * (commands folded into the active turn share its result) and zombies
1105
+ * (their late results have already passed or never existed), so drain
1106
+ * them here or they would zombify/linger and swallow a later live
1107
+ * echo-less result. */
1108
+ const recordResultForOrphanCommands = () => {
1109
+ for (const turn of session.turnQueue ?? []) {
1110
+ if (!turn.settled && turn.commandStarted && !turn.commandFinished) {
1111
+ turn.commandResultSeen = true;
1112
+ }
1113
+ }
1114
+ if (session.activeTurn && session.orphanCommands?.size) {
1115
+ for (const [uuid, state] of session.orphanCommands) {
1116
+ if (state === "started" || state === "zombie") {
1117
+ session.orphanCommands.delete(uuid);
1118
+ }
1119
+ }
1120
+ }
1121
+ };
1122
+ /** The unsettled in-flight turn owning this prompt uuid, if any. */
1123
+ const findUnsettledTurn = (uuid) => (session.turnQueue ?? []).find((t) => t.promptUuid === uuid && !t.settled);
1124
+ /** The first queued turn still awaiting its outcome, if any — the single
1125
+ * spelling of "a prompt is pending" shared by the head promotion and
1126
+ * the autonomous stretch-close guard. */
1127
+ const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
1128
+ /** Whether any background subagent this turn spawned is still live —
1129
+ * while true, the turn's settlement stays deferred so the subagent's
1130
+ * output and permission requests land inside it (see
1131
+ * Turn.deferredSettle). */
1132
+ const turnAwaitingSubagents = (turn) => {
1133
+ if (!turn.spawnedTaskIds?.size) {
1134
+ return false;
1135
+ }
1136
+ for (const taskId of turn.spawnedTaskIds) {
1137
+ const record = session.liveBackgroundTasks.get(taskId);
1138
+ // The isSubagent read is defense in depth for the shells-never-defer
1139
+ // contract: spawnedTaskIds only ever holds subagent ids today, but a
1140
+ // future add site must not silently let a long-lived shell hold a
1141
+ // prompt open. endedPerLevel entries are kept for attribution only —
1142
+ // the level signal says the task is gone (or its bookends were
1143
+ // lost), so a hold must not wait on them.
1144
+ if (record?.isSubagent && !record.endedPerLevel) {
1145
+ return true;
1146
+ }
1147
+ }
1148
+ return false;
1149
+ };
1150
+ /** Settle the active turn's stored deferred outcome once none of its
1151
+ * spawned subagents is live. The single drain rule shared by the
1152
+ * followup-result and idle settle sites, so the two lanes can't drift. */
1153
+ const settleDeferredIfDrained = () => {
1154
+ const turn = session.activeTurn;
1155
+ if (isHeldOpen(turn) && !turnAwaitingSubagents(turn)) {
1156
+ settleActive(turn.deferredSettle);
1157
+ }
1158
+ };
1159
+ /** Settle the active turn with `outcome` now — unless subagents it
1160
+ * spawned are still live, in which case store the outcome and hold the
1161
+ * turn open (see Turn.deferredSettle). Every result-time settle of a
1162
+ * turn that can have spawned subagents must route through here: a site
1163
+ * calling settleActive directly bypasses the hold and re-opens the
1164
+ * out-of-turn permission deadlock (issue #866) through its lane. */
1165
+ const settleOrDefer = (outcome) => {
1166
+ if (session.activeTurn &&
1167
+ !session.activeTurn.settled &&
1168
+ turnAwaitingSubagents(session.activeTurn)) {
1169
+ session.activeTurn.deferredSettle = outcome;
1170
+ }
1171
+ else {
1172
+ settleActive(outcome);
1173
+ }
1174
+ };
752
1175
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
753
1176
  * backstop (the turn is over), and drop it from the queue. */
754
1177
  const settleActive = (result) => {
@@ -756,22 +1179,34 @@ export class ClaudeAcpAgent {
756
1179
  if (!turn || turn.settled) {
757
1180
  return;
758
1181
  }
1182
+ // Captured before the settled flip below (isHeldOpen tests !settled).
1183
+ const wasHeld = isHeldOpen(turn);
759
1184
  turn.settled = true;
760
- if (session.forceCancelTimer) {
761
- clearTimeout(session.forceCancelTimer);
762
- session.forceCancelTimer = undefined;
763
- }
1185
+ disarmForceCancel(session);
764
1186
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
765
1187
  session.activeTurn = null;
1188
+ streamedToolInputs.clear();
1189
+ if (wasHeld) {
1190
+ // Settling a held turn is its delivery-stretch boundary: the turn's
1191
+ // answer finished long ago, so text streamed since the last boundary
1192
+ // is normally its followups' — left latched it would suppress a
1193
+ // following replayed turn's issue-#453 result-text fallback (the
1194
+ // common post-hold sequence). Known trade: at the echo hand-off an
1195
+ // incoming turn's pre-echo deltas share this one boolean, so a
1196
+ // STREAMING replay on a usage-omitting backend could re-emit its
1197
+ // answer — the flag cannot attribute text to a turn before its
1198
+ // echo, and the suppression direction is the common one, so the
1199
+ // clear wins. Every held-settle lane inherits this: the drain
1200
+ // settle, both hand-offs, and stream-done; cancel()'s inline mirror
1201
+ // carries its own copy.
1202
+ session.emittedAssistantText = false;
1203
+ }
766
1204
  turn.resolve(result);
767
1205
  };
768
1206
  /** Reject the active turn (auth required, error result, …) without tearing
769
1207
  * down the consumer: the stream continues to idle and later turns proceed. */
770
1208
  const failActive = (error) => {
771
- if (session.forceCancelTimer) {
772
- clearTimeout(session.forceCancelTimer);
773
- session.forceCancelTimer = undefined;
774
- }
1209
+ disarmForceCancel(session);
775
1210
  const turn = session.activeTurn;
776
1211
  if (!turn || turn.settled) {
777
1212
  return;
@@ -779,14 +1214,17 @@ export class ClaudeAcpAgent {
779
1214
  turn.settled = true;
780
1215
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
781
1216
  session.activeTurn = null;
1217
+ streamedToolInputs.clear();
1218
+ // A failed turn's stretch is over, and some failure lanes (the issue
1219
+ // #825 idle-fail) never see the result whose `finally` would close it —
1220
+ // start the next stretch clean, or its stale delivery record would
1221
+ // suppress the next turn's issue-#453 result-text fallback.
1222
+ session.emittedAssistantText = false;
782
1223
  turn.reject(error);
783
1224
  };
784
1225
  /** Reject every in-flight turn — used when the stream dies. */
785
1226
  const failAllTurns = (error) => {
786
- if (session.forceCancelTimer) {
787
- clearTimeout(session.forceCancelTimer);
788
- session.forceCancelTimer = undefined;
789
- }
1227
+ disarmForceCancel(session);
790
1228
  const turns = session.activeTurn
791
1229
  ? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
792
1230
  : [...(session.turnQueue ?? [])];
@@ -794,8 +1232,18 @@ export class ClaudeAcpAgent {
794
1232
  session.turnQueue = [];
795
1233
  for (const turn of turns) {
796
1234
  if (!turn.settled) {
1235
+ const wasHeld = isHeldOpen(turn);
797
1236
  turn.settled = true;
798
- turn.reject(error);
1237
+ if (wasHeld) {
1238
+ // A held turn's answer already streamed and its outcome is
1239
+ // recorded — a stream death during the post-answer hold is a
1240
+ // background failure, not the turn's. Resolve with the real
1241
+ // outcome, mirroring the stream-done path.
1242
+ turn.resolve(turn.deferredSettle);
1243
+ }
1244
+ else {
1245
+ turn.reject(error);
1246
+ }
799
1247
  }
800
1248
  }
801
1249
  };
@@ -836,9 +1284,45 @@ export class ClaudeAcpAgent {
836
1284
  // turn being abandoned. Stale counts self-heal: activation resets
837
1285
  // them (see activateTurn).
838
1286
  if (session.activeTurn && !session.activeTurn.settled) {
839
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
1287
+ // Seed by what the frames already told us, mirroring cancel()'s
1288
+ // queued-turn sweep — the consumer may have drained the wedged
1289
+ // turn's result and/or terminal frame before the backstop fired,
1290
+ // and an entry seeded for a result or frame that is already
1291
+ // spent would never drain (it would swallow an unrelated later
1292
+ // echo-less result instead).
1293
+ const active = session.activeTurn;
1294
+ if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
1295
+ // Finished SDK-side; any result already passed. Nothing to
1296
+ // track.
1297
+ }
1298
+ else if (active.commandFinished === "cancelled") {
1299
+ // Aborted after dispatch: its late result may still come —
1300
+ // unless it already did.
1301
+ if (!active.commandResultSeen) {
1302
+ this.trackOrphanCommand(session, active.promptUuid, "zombie");
1303
+ }
1304
+ }
1305
+ else if (active.commandResultSeen) {
1306
+ // Its result was already consumed (dropped at the cancelled
1307
+ // guard); only the terminal frame is outstanding, which no-ops
1308
+ // with no entry. Nothing to track.
1309
+ }
1310
+ else {
1311
+ // The wedged turn WAS dispatched (it's active), so track it
1312
+ // "started": its late result (if the SDK recovers) is skipped
1313
+ // echo-less, and its terminal frame — or that skip plus
1314
+ // activation's clear when the frame is lost to the wedge — is
1315
+ // what drains it.
1316
+ this.trackOrphanCommand(session, active.promptUuid, "started");
1317
+ }
840
1318
  }
841
1319
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1320
+ // The cancelled turn's result may never come (that's why the
1321
+ // backstop fired) — close its delivery stretch here so partial
1322
+ // streamed text can't suppress the next turn's issue-#453 fallback.
1323
+ // If a late orphan result does arrive, its `finally` clears again;
1324
+ // FIFO ordering means no live turn's text can have streamed yet.
1325
+ session.emittedAssistantText = false;
842
1326
  // If the session is being torn down, abandon the in-flight next()
843
1327
  // (swallowing any later rejection so it can't surface as unhandled)
844
1328
  // and stop; otherwise re-arm and keep consuming — `pendingNext`
@@ -862,11 +1346,15 @@ export class ClaudeAcpAgent {
862
1346
  // turn's real outcome.
863
1347
  //
864
1348
  // 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
- });
1349
+ // cancelled if a cancel is pending, otherwise the outcome a
1350
+ // deferred turn already recorded (see Turn.deferredSettle) or the
1351
+ // accumulated scratch outcome. The scratch currently still equals
1352
+ // a deferred turn's stored outcome (followup results never mutate
1353
+ // it), but the stored one is the authoritative source.
1354
+ const inFlight = session.activeTurn;
1355
+ settleActive(session.cancelled
1356
+ ? { stopReason: "cancelled", usage: sessionUsage(session) }
1357
+ : (inFlight?.deferredSettle ?? { stopReason, usage: sessionUsage(session) }));
870
1358
  // Queued turns the SDK never started never ran, so reject them rather
871
1359
  // than reporting a success (end_turn) — or a misleading "cancelled" —
872
1360
  // for a prompt that produced no output. (A cancel already settled the
@@ -893,19 +1381,116 @@ export class ClaudeAcpAgent {
893
1381
  message: message,
894
1382
  });
895
1383
  }
1384
+ // CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
1385
+ // uuid-stamped queued command (queued/started/completed/cancelled/
1386
+ // discarded) as `command_lifecycle` frames — 2-3 per prompt, since
1387
+ // prompt() stamps a uuid on every message. The frame is @internal and
1388
+ // absent from the SDKMessage union, so handle it BEFORE the exhaustive
1389
+ // switch: it must not reach `unreachable`'s error log, and a `case`
1390
+ // for it wouldn't typecheck. It feeds only the orphan accounting (see
1391
+ // Session.orphanCommands); turn settlement stays driven by
1392
+ // echoes/results/idle. (Raw-mode emission above still forwards these
1393
+ // frames.)
1394
+ if (message.type === "command_lifecycle") {
1395
+ const frame = message;
1396
+ switch (frame.state) {
1397
+ case "started": {
1398
+ // Remember dispatch on the live turn so a cancel() that orphans
1399
+ // it seeds the right state (see Turn.commandStarted)...
1400
+ const queued = findUnsettledTurn(frame.command_uuid);
1401
+ if (queued) {
1402
+ queued.commandStarted = true;
1403
+ }
1404
+ // ...and promote an already-orphaned command: once dispatched,
1405
+ // a bare `cancelled` no longer means "dropped without running".
1406
+ const state = session.orphanCommands?.get(frame.command_uuid);
1407
+ if (state === "pending") {
1408
+ session.orphanCommands.set(frame.command_uuid, "started");
1409
+ }
1410
+ else if (state === "zombie") {
1411
+ // "started" after the command's terminal frame: the ordering
1412
+ // the whole lane rests on has been violated (frames are
1413
+ // per-uuid FIFO). Surface it — a silent drift here degrades
1414
+ // into swallowed or misattributed results.
1415
+ 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.`);
1416
+ }
1417
+ break;
1418
+ }
1419
+ case "completed":
1420
+ case "discarded":
1421
+ case "cancelled": {
1422
+ // Terminal frames. Latch the fate on a still-queued turn so a
1423
+ // later cancel() doesn't seed an orphan entry for a command
1424
+ // whose one-and-only terminal frame has already been consumed
1425
+ // (nothing would ever drain that entry).
1426
+ const queued = findUnsettledTurn(frame.command_uuid);
1427
+ if (queued) {
1428
+ queued.commandFinished = frame.state;
1429
+ }
1430
+ if (frame.state === "cancelled") {
1431
+ // Ambiguous by design (dup-over-loss): dropped before
1432
+ // dispatch (no result will ever come — safe to forget) vs
1433
+ // consumed into a turn that was aborted/failed. For the
1434
+ // latter, any result the dead turn managed to emit has
1435
+ // already deleted the entry (see
1436
+ // recordResultForOrphanCommands / ensureActiveTurn), so a
1437
+ // still-"started" entry means no result was seen since
1438
+ // dispatch — it becomes a zombie for the next
1439
+ // echo-less-result skip.
1440
+ const state = session.orphanCommands?.get(frame.command_uuid);
1441
+ if (state === "pending") {
1442
+ session.orphanCommands?.delete(frame.command_uuid);
1443
+ }
1444
+ else if (state === "started") {
1445
+ session.orphanCommands?.set(frame.command_uuid, "zombie");
1446
+ }
1447
+ break;
1448
+ }
1449
+ // Exactly-one-terminal: the command is finished. "completed" is
1450
+ // emitted after any result its turn produced (fresh turn) or the
1451
+ // command folded into another turn whose result is attributed
1452
+ // elsewhere — either way no echo-less result remains to skip.
1453
+ // "discarded" = session ended with it still queued; no result.
1454
+ session.orphanCommands?.delete(frame.command_uuid);
1455
+ break;
1456
+ }
1457
+ default:
1458
+ // "queued" carries no fate information. Anything else is a
1459
+ // state this adapter doesn't know — likely a CLI that grew the
1460
+ // v1 vocabulary. The entry still drains by result coverage or
1461
+ // activation's clear (bounded damage), but log it so the
1462
+ // degradation is visible instead of silent.
1463
+ if (frame.state !== "queued") {
1464
+ this.logger.error(`Session ${params.sessionId}: unknown command_lifecycle state "${frame.state}" for ${frame.command_uuid}; treating as uninformative.`);
1465
+ }
1466
+ break;
1467
+ }
1468
+ continue;
1469
+ }
896
1470
  switch (message.type) {
897
1471
  case "system":
898
1472
  switch (message.subtype) {
899
1473
  case "init":
1474
+ // Latch the lifecycle capability so cancel() routes orphan
1475
+ // accounting through `orphanCommands` (per-uuid, exact)
1476
+ // instead of the coalescing-blind count. Never unlatch: init
1477
+ // re-emits per turn and the capability can't be lost mid-CLI.
1478
+ if (message.capabilities?.includes("msg_lifecycle_v1")) {
1479
+ session.msgLifecycleV1 = true;
1480
+ }
900
1481
  // A fresh `system`/init (e.g. after reinitialize) can carry an
901
1482
  // updated Fast mode state; reconcile it with what we seeded at
902
1483
  // session creation.
903
1484
  await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
904
1485
  break;
905
1486
  case "status": {
1487
+ // These banners count as delivered text (via sendUpdate), so
1488
+ // an echo-less turn that only ever emits them (e.g. `/compact`,
1489
+ // promoted at its own result) doesn't have its result text
1490
+ // re-emitted by the issue-#453 fallback.
906
1491
  if (message.status === "compacting") {
907
1492
  compactionInProgress = true;
908
- await this.client.sessionUpdate({
1493
+ await sendUpdate({
909
1494
  sessionId: message.session_id,
910
1495
  update: {
911
1496
  sessionUpdate: "agent_message_chunk",
@@ -918,7 +1503,7 @@ export class ClaudeAcpAgent {
918
1503
  // message carrying `compact_result`, not the `compact_boundary`
919
1504
  // message (which only fires when there's content to compact).
920
1505
  compactionInProgress = false;
921
- await this.client.sessionUpdate({
1506
+ await sendUpdate({
922
1507
  sessionId: message.session_id,
923
1508
  update: {
924
1509
  sessionUpdate: "agent_message_chunk",
@@ -929,7 +1514,7 @@ export class ClaudeAcpAgent {
929
1514
  else if (message.compact_result === "failed" && compactionInProgress) {
930
1515
  compactionInProgress = false;
931
1516
  const reason = message.compact_error ? `: ${message.compact_error}` : ".";
932
- await this.client.sessionUpdate({
1517
+ await sendUpdate({
933
1518
  sessionId: message.session_id,
934
1519
  update: {
935
1520
  sessionUpdate: "agent_message_chunk",
@@ -954,9 +1539,9 @@ export class ClaudeAcpAgent {
954
1539
  // dropped dramatically) and replaced within seconds by the next
955
1540
  // result message.
956
1541
  //
957
- // `size` keeps coming from session.contextWindowSize (learned
958
- // from modelUsage / the model heuristic) — getContextUsage's
959
- // window field under-reports extended 1M windows.
1542
+ // `size` keeps coming from session.contextWindowSize —
1543
+ // compaction frees occupancy, it doesn't change the model's
1544
+ // window.
960
1545
  //
961
1546
  // The "Compacting completed." text is emitted from the `status`
962
1547
  // handler (keyed on `compact_result`), not here, so the failure
@@ -964,7 +1549,7 @@ export class ClaudeAcpAgent {
964
1549
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
965
1550
  lastAssistantUsage = null;
966
1551
  lastAssistantTotalUsage = usedTokens ?? 0;
967
- await this.client.sessionUpdate({
1552
+ await sendUpdate({
968
1553
  sessionId: message.session_id,
969
1554
  update: {
970
1555
  sessionUpdate: "usage_update",
@@ -975,7 +1560,7 @@ export class ClaudeAcpAgent {
975
1560
  break;
976
1561
  }
977
1562
  case "local_command_output": {
978
- await this.client.sessionUpdate({
1563
+ await sendUpdate({
979
1564
  sessionId: message.session_id,
980
1565
  update: {
981
1566
  sessionUpdate: "agent_message_chunk",
@@ -985,6 +1570,7 @@ export class ClaudeAcpAgent {
985
1570
  break;
986
1571
  }
987
1572
  case "session_state_changed": {
1573
+ session.lastSessionState = message.state;
988
1574
  if (message.state === "idle") {
989
1575
  // A non-cancelled turn normally settled at its terminal
990
1576
  // `result` already (issue #773), and that result recorded an
@@ -1017,14 +1603,38 @@ export class ClaudeAcpAgent {
1017
1603
  // when the cancel pre-empted the result (wedge/force-cancel).
1018
1604
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
1019
1605
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1606
+ // An interrupt can pre-empt the turn's result entirely
1607
+ // (nothing ran the result-case `finally`), so close the
1608
+ // delivery stretch here: idle is the SDK's authoritative
1609
+ // turn-over signal, and stale partial-text state would
1610
+ // suppress the next turn's issue-#453 fallback.
1611
+ session.emittedAssistantText = false;
1020
1612
  }
1021
- else if (owedTrailingIdles > 0) {
1613
+ else if (isHeldOpen(session.activeTurn)) {
1614
+ // A turn held open for its background subagents (see
1615
+ // Turn.deferredSettle). Idles keep their normal cadence
1616
+ // during the hold — the CLI emits one per processing
1617
+ // cycle (the turn's own trailer, then one per followup),
1618
+ // NOT one final "all drained" signal — so each one
1619
+ // absorbs an outstanding trailer debt, and the turn only
1620
+ // settles once none of its spawned subagents is left
1621
+ // (the followup-result settle usually got there first;
1622
+ // this is the fallback when no followup came). Mid-hold
1623
+ // idles never fall through: a held turn HAS its result,
1624
+ // so reading its idle as "turn abandoned without a
1625
+ // result" (issue #825) would fail a healthy prompt.
1626
+ if (session.owedTrailingIdles > 0) {
1627
+ session.owedTrailingIdles--;
1628
+ }
1629
+ settleDeferredIfDrained();
1630
+ }
1631
+ else if (session.owedTrailingIdles > 0) {
1022
1632
  // Absorb a settled turn's trailing idle. Also covers a
1023
1633
  // cancel that landed between a turn's counted result and
1024
1634
  // this lagged idle (no active turn to settle): the idle
1025
1635
  // still belongs to that settled turn, and skipping the
1026
1636
  // decrement would leak the debt permanently.
1027
- owedTrailingIdles--;
1637
+ session.owedTrailingIdles--;
1028
1638
  }
1029
1639
  else if (!session.cancelled &&
1030
1640
  session.activeTurn &&
@@ -1066,7 +1676,7 @@ export class ClaudeAcpAgent {
1066
1676
  const title = isSynthesis
1067
1677
  ? "Recalled synthesized memory"
1068
1678
  : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
1069
- await this.client.sessionUpdate({
1679
+ await sendUpdate({
1070
1680
  sessionId: message.session_id,
1071
1681
  update: {
1072
1682
  sessionUpdate: "tool_call",
@@ -1093,7 +1703,7 @@ export class ClaudeAcpAgent {
1093
1703
  // list with this payload: supportedCommands() is captured once
1094
1704
  // at initialize and never reflects mid-session changes, so we
1095
1705
  // forward message.commands directly rather than re-querying.
1096
- await this.client.sessionUpdate({
1706
+ await sendUpdate({
1097
1707
  sessionId: message.session_id,
1098
1708
  update: {
1099
1709
  sessionUpdate: "available_commands_update",
@@ -1117,7 +1727,7 @@ export class ClaudeAcpAgent {
1117
1727
  // rejection reason — otherwise the client shows a tool call
1118
1728
  // that silently never resolves.
1119
1729
  const reason = message.decision_reason ?? message.message;
1120
- await this.client.sessionUpdate({
1730
+ await sendUpdate({
1121
1731
  sessionId: message.session_id,
1122
1732
  update: {
1123
1733
  sessionUpdate: "tool_call_update",
@@ -1149,10 +1759,14 @@ export class ClaudeAcpAgent {
1149
1759
  // instead of a silent stop. ACP's agent_message_chunk has no
1150
1760
  // severity field, so fold the level into the text for the more
1151
1761
  // prominent levels ('info' is transcript-only noise — leave plain).
1762
+ // Sending via sendUpdate also marks the notice as this stretch's
1763
+ // delivered text: a hook-blocked turn's result repeats the block
1764
+ // reason with zero output tokens, and the issue-#453 fallback
1765
+ // must not emit it a second time.
1152
1766
  const text = message.level === "info"
1153
1767
  ? message.content
1154
1768
  : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
1155
- await this.client.sessionUpdate({
1769
+ await sendUpdate({
1156
1770
  sessionId: message.session_id,
1157
1771
  update: {
1158
1772
  sessionUpdate: "agent_message_chunk",
@@ -1165,10 +1779,51 @@ export class ClaudeAcpAgent {
1165
1779
  case "hook_progress":
1166
1780
  case "hook_response":
1167
1781
  case "files_persisted":
1782
+ case "task_progress":
1783
+ break;
1168
1784
  case "task_started":
1785
+ // For subagent tasks `task_id` is the subagent's agent id (the
1786
+ // SDK keys its task registry by agent id) and `tool_use_id` is
1787
+ // the Agent/Task tool_use that spawned it — recorded so the
1788
+ // subagent's permission requests, which reach canUseTool with
1789
+ // only `agentID`, can attribute their eagerly-emitted
1790
+ // tool_call to the parent tool call. Non-subagent tasks (e.g.
1791
+ // background Bash) land here too; their task_ids never match
1792
+ // an agentID, so those entries are inert for attribution.
1793
+ //
1794
+ // `isSubagent` marks Task/Agent-tool subagents — the tasks
1795
+ // whose completion wakes the model for a followup, so the
1796
+ // ones worth deferring turn settlement for. A sync subagent
1797
+ // is pruned (terminal task_updated) before its turn's result
1798
+ // can arrive, so registry membership at result time means an
1799
+ // async subagent. Their spawn is also recorded on the active
1800
+ // turn: a turn only ever waits on its own subagents, and a
1801
+ // spawn during a held-open drain window (an agent chain)
1802
+ // extends that turn's hold.
1803
+ session.liveBackgroundTasks.set(message.task_id, {
1804
+ parentToolUseId: message.tool_use_id,
1805
+ isSubagent: !!message.subagent_type,
1806
+ });
1807
+ if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
1808
+ (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
1809
+ }
1810
+ break;
1169
1811
  case "task_notification":
1170
- case "task_progress":
1812
+ // The task settled — no further tool calls can originate
1813
+ // from it, so its registry entry can be dropped.
1814
+ session.liveBackgroundTasks.delete(message.task_id);
1815
+ break;
1171
1816
  case "task_updated":
1817
+ // terminal-status task_updated patch and a (deduplicated)
1818
+ // task_notification when a task settles, but only the patch is
1819
+ // guaranteed per transition — prune on it too so the registry
1820
+ // can't grow for the session's lifetime if a notification is
1821
+ // skipped.
1822
+ if (message.patch.status === "completed" ||
1823
+ message.patch.status === "failed" ||
1824
+ message.patch.status === "killed") {
1825
+ session.liveBackgroundTasks.delete(message.task_id);
1826
+ }
1172
1827
  break;
1173
1828
  case "worker_shutting_down":
1174
1829
  // A Remote Control worker announced a graceful teardown. This is a
@@ -1220,7 +1875,7 @@ export class ClaudeAcpAgent {
1220
1875
  const outcome = persistent
1221
1876
  ? `The session will continue on ${message.fallback_model}.`
1222
1877
  : `The session stays on ${message.original_model}.`;
1223
- await this.client.sessionUpdate({
1878
+ await sendUpdate({
1224
1879
  sessionId: message.session_id,
1225
1880
  update: {
1226
1881
  sessionUpdate: "agent_message_chunk",
@@ -1256,12 +1911,50 @@ export class ClaudeAcpAgent {
1256
1911
  break;
1257
1912
  // `control_request_progress` only reports on side_question
1258
1913
  // control requests, which this adapter never issues.
1259
- // `background_tasks_changed` is a level signal (the full live
1260
- // background-task set on every membership change) for surfaces
1261
- // that render a background-activity indicator; turn lifecycle
1262
- // here is driven by results/idle, so there is nothing to track.
1263
1914
  case "control_request_progress":
1915
+ break;
1264
1916
  case "background_tasks_changed":
1917
+ // A level signal: the full live background-task set on every
1918
+ // membership change, with REPLACE semantics. Used only to
1919
+ // reconcile `liveBackgroundTasks` — dropping (or, for
1920
+ // subagent entries, unpinning) any entry whose settle
1921
+ // bookend (task_notification / terminal task_updated) was
1922
+ // lost, so a leaked subagent entry can't defer its spawning
1923
+ // turn's settlement forever. Growth of retained
1924
+ // (endedPerLevel) subagent entries is bounded by the
1925
+ // activation-time sweep in activateTurn, not here. It never
1926
+ // ADDS entries (the payload carries no attribution or
1927
+ // subagent marker), so the unspecified ordering vs. the edge
1928
+ // bookends is safe: a level that precedes its task_started
1929
+ // simply no-ops here.
1930
+ if (session.liveBackgroundTasks.size > 0) {
1931
+ const live = new Set(message.tasks.map((t) => t.task_id));
1932
+ for (const [taskId, record] of session.liveBackgroundTasks) {
1933
+ if (live.has(taskId)) {
1934
+ // The level proves the task live in the background
1935
+ // universe (e.g. a foreground agent was backgrounded
1936
+ // after an earlier absent-marking, or that marking was
1937
+ // a racing payload built before the task registered) —
1938
+ // un-end it so a hold waits on it again, and disarm
1939
+ // the activation sweep.
1940
+ record.endedPerLevel = undefined;
1941
+ continue;
1942
+ }
1943
+ if (record.isSubagent) {
1944
+ // The level's universe is BACKGROUND tasks only, so a
1945
+ // live sync (foreground) subagent is legitimately
1946
+ // absent — deleting its entry would strand its
1947
+ // permission attribution (#859). Keep the entry but
1948
+ // stop any hold from waiting on the id: an absent id
1949
+ // can equally be a leaked async entry whose settle
1950
+ // bookends were lost.
1951
+ record.endedPerLevel ??= "ended";
1952
+ }
1953
+ else {
1954
+ session.liveBackgroundTasks.delete(taskId);
1955
+ }
1956
+ }
1957
+ }
1265
1958
  break;
1266
1959
  default:
1267
1960
  unreachable(message, this.logger);
@@ -1269,177 +1962,304 @@ export class ClaudeAcpAgent {
1269
1962
  }
1270
1963
  break;
1271
1964
  case "result": {
1272
- // Task-notification followups are autonomous work triggered by a
1273
- // task-notification system message, not by the user's prompt.
1274
- // They should not influence the user-turn lifecycle (stop reason,
1275
- // slash-command output forwarding) but their cost is real.
1276
- const isTaskNotification = message.origin?.kind === "task-notification";
1277
- // Reconcile the Fast mode toggle with the SDK's reported state.
1278
- // Gated to user-driven turns like every other side effect below; a
1279
- // background followup's state lands on the next user turn's result.
1280
- // Runs even when the turn errors or was cancelled.
1281
- if (!isTaskNotification) {
1282
- await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1283
- }
1284
- // A user-turn result needs an active turn so its stop reason is
1285
- // attributed and the turn settles at idle. Local-only commands carry
1286
- // no user-message echo to promote them, so do it here from the head.
1287
- // Promote BEFORE accumulating usage, since activation resets the
1288
- // accumulator — promoting after would discard this result's tokens.
1289
- if (!isTaskNotification) {
1290
- ensureActiveTurn();
1291
- }
1292
- // Every user-turn result terminates a turn (settle, reject, or
1293
- // orphan skip) and the SDK follows it with a trailing
1294
- // `session_state_changed: idle` — record the debt so the idle
1295
- // handler absorbs that idle rather than reading it as a turn the
1296
- // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
1297
- // turn's own result. It is dropped at the `session.cancelled`
1298
- // guard, and either the idle itself settles the turn (consuming
1299
- // the trailer) or the next echo's hand-off does (which records
1300
- // the debt there instead) — counting here too would double it.
1301
- // Results skipped while cancelled with NO active turn — orphaned
1302
- // queued turns the SDK still ran, or a force-cancelled turn's
1303
- // late result after the backstop settled it — get no such settle,
1304
- // so their trailers must be counted here or they'd later be read
1305
- // as the next healthy turn being abandoned and false-fail it.
1306
- if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
1307
- owedTrailingIdles++;
1308
- }
1309
- // Accumulate usage into the user turn's tally. Skip task-notification
1310
- // followups: their cost is real but is reported separately via the
1311
- // usage_update below, and `session.accumulatedUsage` is only reset on
1312
- // turn activation — so folding a task-notification result that lands
1313
- // after the next turn is active (but before it settles) would leak
1314
- // those tokens into that turn's PromptResponse.usage.
1315
- if (!isTaskNotification) {
1316
- session.accumulatedUsage.inputTokens += message.usage.input_tokens;
1317
- session.accumulatedUsage.outputTokens += message.usage.output_tokens;
1318
- session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
1319
- session.accumulatedUsage.cachedWriteTokens +=
1320
- message.usage.cache_creation_input_tokens;
1321
- }
1322
- const matchingModelUsage = lastAssistantModel
1323
- ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
1324
- : null;
1325
- // Only overwrite when we have an authoritative value — a miss
1326
- // (e.g. a turn with no top-level assistant message) would
1327
- // otherwise discard the window learned on a prior turn and
1328
- // leave the next prompt's mid-stream updates reporting 200k.
1329
- if (matchingModelUsage) {
1330
- session.contextWindowSize = matchingModelUsage.contextWindow;
1331
- }
1332
- // Send usage_update notification
1333
- if (lastAssistantTotalUsage !== null) {
1334
- await this.client.sessionUpdate({
1335
- sessionId: params.sessionId,
1336
- update: {
1337
- sessionUpdate: "usage_update",
1338
- used: lastAssistantTotalUsage,
1339
- size: session.contextWindowSize,
1340
- cost: {
1341
- amount: message.total_cost_usd,
1342
- currency: "USD",
1343
- },
1344
- ...(message.origin && {
1345
- _meta: { "_claude/origin": message.origin },
1346
- }),
1347
- },
1348
- });
1349
- }
1350
- if (session.cancelled) {
1351
- if (!isTaskNotification) {
1352
- stopReason = "cancelled";
1965
+ // A result from an autonomous cycle — a task-notification
1966
+ // followup, or a peer/coordinator/observer message the model
1967
+ // handled on its own (see AUTONOMOUS_RESULT_ORIGINS) — is not
1968
+ // the user's prompt's. Autonomous results must never touch the
1969
+ // user-turn lifecycle (stop reason, settles, failActive,
1970
+ // slash-command output forwarding), though their cost is real.
1971
+ const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
1972
+ try {
1973
+ // Reconcile the Fast mode toggle with the SDK's reported state.
1974
+ // Gated to user-driven turns like every other side effect below;
1975
+ // an autonomous cycle's state lands on the next user turn's
1976
+ // result. Runs even when the turn errors or was cancelled.
1977
+ if (!isAutonomousResult) {
1978
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1353
1979
  }
1354
- break;
1355
- }
1356
- // A refusal can arrive on any result subtype (and may even set
1357
- // is_error), so handle it before the subtype switch — otherwise the
1358
- // is_error throw below would surface it as an internal error. The
1359
- // refused assistant message carries no visible content, so surface
1360
- // the classifier's explanation (when available) and report ACP's
1361
- // dedicated `refusal` stop reason.
1362
- if (message.stop_reason === "refusal" && !isTaskNotification) {
1363
- if (lastRefusalExplanation) {
1364
- await this.client.sessionUpdate({
1980
+ // A user-turn result needs an active turn so its stop reason is
1981
+ // attributed and the turn settles at idle. Local-only commands carry
1982
+ // no user-message echo to promote them, so do it here from the head.
1983
+ // Promote BEFORE accumulating usage, since activation resets the
1984
+ // accumulator — promoting after would discard this result's tokens.
1985
+ // The orphan bookkeeping runs first: it covers folded/zombie
1986
+ // commands whose shared or late result this is, even when the
1987
+ // result is the ACTIVE turn's (ensureActiveTurn never looks at
1988
+ // the map in that case).
1989
+ if (!isAutonomousResult) {
1990
+ recordResultForOrphanCommands();
1991
+ ensureActiveTurn();
1992
+ }
1993
+ // A result closes the stretch of output it terminates: snapshot
1994
+ // the delivery record — AFTER ensureActiveTurn, whose held-turn
1995
+ // hand-off closes the held stretch, so an echo-less command
1996
+ // promoted here is judged on its own delivery, not on the held
1997
+ // turn's followup text — and before the handling below can emit
1998
+ // anything of its own; the `finally` then clears it so every
1999
+ // exit from this case (the cancelled-guard and refusal breaks
2000
+ // included) starts the next stretch clean. Clearing up front
2001
+ // instead would let result-time emissions (refusal explanation,
2002
+ // result-text forwarding) taint the next stretch and suppress a
2003
+ // following replayed turn's fallback. Autonomous cycles run
2004
+ // alongside a user turn and must not clear its flag (they exit
2005
+ // through the early break below, which the gated `finally`
2006
+ // leaves alone).
2007
+ const deliveredAssistantText = session.emittedAssistantText;
2008
+ // Every user-turn result terminates a turn (settle, reject, or
2009
+ // orphan skip) and the SDK follows it with a trailing
2010
+ // `session_state_changed: idle` — record the debt so the idle
2011
+ // handler absorbs that idle rather than reading it as a turn the
2012
+ // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
2013
+ // turn's own result. It is dropped at the `session.cancelled`
2014
+ // guard, and either the idle itself settles the turn (consuming
2015
+ // the trailer) or the next echo's hand-off does (which records
2016
+ // the debt there instead) — counting here too would double it.
2017
+ // Results skipped while cancelled with NO active turn — orphaned
2018
+ // queued turns the SDK still ran, or a force-cancelled turn's
2019
+ // late result after the backstop settled it — get no such settle,
2020
+ // so their trailers must be counted here or they'd later be read
2021
+ // as the next healthy turn being abandoned and false-fail it.
2022
+ // Autonomous results (followups, peer/channel/coordinator
2023
+ // cycles) are counted too: each is its own processing cycle with
2024
+ // its own trailing idle, and that idle can lag past the next
2025
+ // prompt's echo — which, un-owed, would be read as the fresh
2026
+ // turn being abandoned (#825 false-fail). That lag was mostly
2027
+ // unreachable when such cycles only ran with no pending turn,
2028
+ // but a held turn settling AT a followup result unblocks the
2029
+ // client at exactly that point, making the race the common
2030
+ // case.
2031
+ // The cancelled-ACTIVE-turn exclusion applies only to that
2032
+ // turn's OWN result — a followup result arriving inside the
2033
+ // cancel window still gets its own trailer and must be counted,
2034
+ // or that idle would later false-fail the next prompt.
2035
+ if (isAutonomousResult || !session.cancelled || !session.activeTurn) {
2036
+ session.owedTrailingIdles++;
2037
+ }
2038
+ // Accumulate usage into the user turn's tally. Skip autonomous
2039
+ // results: their cost is real but is reported separately via the
2040
+ // usage_update below, and `session.accumulatedUsage` is only reset on
2041
+ // turn activation — so folding an autonomous result that lands
2042
+ // after the next turn is active (but before it settles) would leak
2043
+ // those tokens into that turn's PromptResponse.usage.
2044
+ if (!isAutonomousResult) {
2045
+ session.accumulatedUsage.inputTokens += message.usage.input_tokens;
2046
+ session.accumulatedUsage.outputTokens += message.usage.output_tokens;
2047
+ session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
2048
+ session.accumulatedUsage.cachedWriteTokens +=
2049
+ message.usage.cache_creation_input_tokens;
2050
+ }
2051
+ const matchingModelUsage = lastAssistantModel
2052
+ ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
2053
+ : null;
2054
+ // Only overwrite when we have an authoritative, sane value. A miss
2055
+ // (e.g. a turn with no top-level assistant message), or a
2056
+ // nonsensical non-positive/NaN window (observed from third-party
2057
+ // backends), would otherwise discard the window learned on a prior
2058
+ // turn and leave the next prompt's mid-stream updates reporting a
2059
+ // wrong size. `cacheContextWindow` applies the same `> 0` guard, so
2060
+ // a bad value never reaches the cross-session cache either.
2061
+ if (matchingModelUsage &&
2062
+ typeof matchingModelUsage.usage.contextWindow === "number" &&
2063
+ matchingModelUsage.usage.contextWindow > 0) {
2064
+ session.contextWindowSize = matchingModelUsage.usage.contextWindow;
2065
+ session.contextWindowAuthoritative = true;
2066
+ // Authoritative: fold it into the cross-session cache keyed on
2067
+ // (this session's provider, the resolved model id —
2068
+ // matchingModelUsage.key, e.g. "claude-sonnet-5[1m]") so a later
2069
+ // session/new or switch on the same provider that resolves to
2070
+ // this model seeds the correct window synchronously, with no
2071
+ // getContextUsage IPC.
2072
+ cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, matchingModelUsage.key), matchingModelUsage.usage.contextWindow);
2073
+ // Also cache under the assistant message's own (bare) spelling.
2074
+ // Seed-time reads fall back to a picker value / verbatim live id
2075
+ // when a row carries no resolvedModel (the synthesized
2076
+ // out-of-allowlist resume row sets it undefined on purpose), and
2077
+ // those spellings match `.model` from the assistant message, not
2078
+ // the decorated modelUsage key — without this entry such rows
2079
+ // could never hit the cache.
2080
+ if (lastAssistantModel && lastAssistantModel !== matchingModelUsage.key) {
2081
+ cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, lastAssistantModel), matchingModelUsage.usage.contextWindow);
2082
+ }
2083
+ }
2084
+ // Send usage_update notification
2085
+ if (lastAssistantTotalUsage !== null) {
2086
+ await sendUpdate({
1365
2087
  sessionId: params.sessionId,
1366
2088
  update: {
1367
- sessionUpdate: "agent_message_chunk",
1368
- content: { type: "text", text: lastRefusalExplanation },
2089
+ sessionUpdate: "usage_update",
2090
+ used: lastAssistantTotalUsage,
2091
+ size: session.contextWindowSize,
2092
+ cost: {
2093
+ amount: message.total_cost_usd,
2094
+ currency: "USD",
2095
+ },
2096
+ ...(message.origin && {
2097
+ _meta: { "_claude/origin": message.origin },
2098
+ }),
1369
2099
  },
1370
2100
  });
1371
2101
  }
1372
- stopReason = "refusal";
1373
- settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
1374
- break;
1375
- }
1376
- switch (message.subtype) {
1377
- case "success": {
1378
- if (message.result.includes("Please run /login")) {
1379
- failActive(RequestError.authRequired());
1380
- break;
2102
+ if (session.cancelled) {
2103
+ if (!isAutonomousResult) {
2104
+ stopReason = "cancelled";
1381
2105
  }
1382
- if (message.stop_reason === "max_tokens") {
1383
- if (!isTaskNotification) {
1384
- stopReason = "max_tokens";
1385
- }
1386
- break;
1387
- }
1388
- if (message.is_error) {
1389
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
1390
- break;
2106
+ break;
2107
+ }
2108
+ // A held turn (see Turn.deferredSettle) settles at its
2109
+ // followup's terminal result: this is the earliest point at which
2110
+ // the promised summary has fully streamed — the trailing idle
2111
+ // would work too, but a client should not wait out another idle
2112
+ // round-trip for a response whose content is already complete.
2113
+ // (While the turn still awaits another of its subagents —
2114
+ // parallel spawns — the helper holds; the next notification's
2115
+ // followup settles it instead. Other autonomous origins — peer/
2116
+ // channel/coordinator cycles — reach here too: settling a
2117
+ // drained hold at their results is as good as the idle
2118
+ // fallback.) Then stop: everything below is user-turn
2119
+ // lifecycle, and an autonomous outcome must never touch it —
2120
+ // its is_error or "Please run /login" text would otherwise
2121
+ // failActive a live turn (the held one, or the user's next
2122
+ // prompt) whose own result recorded a different outcome.
2123
+ if (isAutonomousResult) {
2124
+ settleDeferredIfDrained();
2125
+ // With no turn in flight OR QUEUED (also after the settle
2126
+ // above), the stretch holds only autonomous prose — close
2127
+ // it, so a replayed next prompt isn't silently suppressed by
2128
+ // the issue-#453 delivery check. A live turn's flag may
2129
+ // guard the USER's already-streamed text — and so may a
2130
+ // QUEUED turn's: with mid-message echo lag its deltas stream
2131
+ // before the echo activates it (activeTurn still null), and
2132
+ // clearing then would re-emit that answer via the fallback,
2133
+ // the duplicate direction the flag's doc forbids.
2134
+ if (!session.activeTurn && !firstUnsettledQueuedTurn()) {
2135
+ session.emittedAssistantText = false;
1391
2136
  }
1392
- // For local-only commands (no model invocation), the result
1393
- // text is the command output — forward it to the client.
1394
- // Task-notification followups never originate from a user
1395
- // slash command, so skip the forwarding for them.
1396
- if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
1397
- for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
1398
- await this.client.sessionUpdate(notification);
1399
- }
2137
+ break;
2138
+ }
2139
+ // A refusal can arrive on any result subtype (and may even set
2140
+ // is_error), so handle it before the subtype switch — otherwise the
2141
+ // is_error throw below would surface it as an internal error. The
2142
+ // refused assistant message carries no visible content, so surface
2143
+ // the classifier's explanation (when available) and report ACP's
2144
+ // dedicated `refusal` stop reason.
2145
+ if (message.stop_reason === "refusal") {
2146
+ if (lastRefusalExplanation) {
2147
+ await sendUpdate({
2148
+ sessionId: params.sessionId,
2149
+ update: {
2150
+ sessionUpdate: "agent_message_chunk",
2151
+ content: { type: "text", text: lastRefusalExplanation },
2152
+ },
2153
+ });
1400
2154
  }
2155
+ stopReason = "refusal";
2156
+ // Through the deferral gate, not settleActive: a refusal can
2157
+ // land on a turn whose spawned subagents are still live, and
2158
+ // settling it out from under them would strand their output
2159
+ // and permission requests out-of-turn (issue #866's deadlock,
2160
+ // through the refusal lane).
2161
+ settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
1401
2162
  break;
1402
2163
  }
1403
- case "error_during_execution": {
1404
- if (message.stop_reason === "max_tokens") {
1405
- if (!isTaskNotification) {
2164
+ switch (message.subtype) {
2165
+ case "success": {
2166
+ if (message.result.includes("Please run /login")) {
2167
+ failActive(RequestError.authRequired());
2168
+ break;
2169
+ }
2170
+ if (message.stop_reason === "max_tokens") {
1406
2171
  stopReason = "max_tokens";
2172
+ break;
2173
+ }
2174
+ if (message.is_error) {
2175
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2176
+ break;
2177
+ }
2178
+ // The result text is forwarded in two cases. Local-only
2179
+ // commands (no model invocation): the result IS the command
2180
+ // output. Otherwise the result is normally a trailing copy of
2181
+ // text that already streamed — but a cache-replayed turn
2182
+ // generates no tokens, and some CLIs then skip streaming
2183
+ // entirely and answer on the `result` alone: no `stream_event`
2184
+ // deltas, no consolidated `assistant` message (issue #453).
2185
+ // Forward it rather than end the turn silently:
2186
+ // `deliveredAssistantText` covers whatever already reached the
2187
+ // client (a turn that showed its answer cannot emit it twice),
2188
+ // and the output-token check keeps the fallback to the
2189
+ // replayed turns it was reported for. `?? 0`: typed non-null,
2190
+ // but third-party backends have been observed omitting usage
2191
+ // token fields (see snapshotFromUsage), and the replay lane
2192
+ // was reported from exactly such a backend — treat a missing
2193
+ // count as the replay signature rather than silently disabling
2194
+ // the fallback there. (Autonomous results never get here —
2195
+ // they exit at the early break above — so no background
2196
+ // prose can be injected into the feed.)
2197
+ if (session.activeTurn?.isLocalOnlyCommand ||
2198
+ (!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
2199
+ for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
2200
+ await sendUpdate(notification);
2201
+ }
1407
2202
  }
1408
2203
  break;
1409
2204
  }
1410
- if (message.is_error) {
1411
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1412
- break;
1413
- }
1414
- if (!isTaskNotification) {
2205
+ case "error_during_execution": {
2206
+ if (message.stop_reason === "max_tokens") {
2207
+ stopReason = "max_tokens";
2208
+ break;
2209
+ }
2210
+ if (message.is_error) {
2211
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2212
+ break;
2213
+ }
1415
2214
  stopReason = "end_turn";
1416
- }
1417
- break;
1418
- }
1419
- case "error_max_budget_usd":
1420
- case "error_max_turns":
1421
- case "error_max_structured_output_retries":
1422
- if (message.is_error) {
1423
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1424
2215
  break;
1425
2216
  }
1426
- if (!isTaskNotification) {
2217
+ case "error_max_budget_usd":
2218
+ case "error_max_turns":
2219
+ case "error_max_structured_output_retries":
2220
+ if (message.is_error) {
2221
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2222
+ break;
2223
+ }
1427
2224
  stopReason = "max_turn_requests";
1428
- }
1429
- break;
1430
- default:
1431
- unreachable(message, this.logger);
1432
- break;
2225
+ break;
2226
+ default:
2227
+ unreachable(message, this.logger);
2228
+ break;
2229
+ }
2230
+ // Settle the user turn at its terminal result so the client unlocks
2231
+ // as soon as the answer is done, rather than waiting for the SDK's
2232
+ // trailing `idle` (which can lag while background work runs — issue
2233
+ // #773). The consumer keeps draining afterward (absorbing idle and
2234
+ // forwarding any background output).
2235
+ //
2236
+ // One exception: while background subagents this turn spawned are
2237
+ // still live, settling now would strand their remaining work
2238
+ // outside any turn — ACP allows out-of-turn session/update, but
2239
+ // many clients stop consuming at the prompt response, and a
2240
+ // subagent's permission request would block on an RPC nobody
2241
+ // answers (issues #864/#866). Hold the turn open instead: store
2242
+ // the outcome and settle with it once the subagents are done —
2243
+ // at their followup's terminal result (see the deferred-settle
2244
+ // block above the subtype switch) or at an idle with none of
2245
+ // them left — so the subagents' streamed output, their
2246
+ // permission requests, and the model's promised summary all land
2247
+ // inside the turn. `session/cancel` and the next prompt's echo
2248
+ // hand-off still settle a deferred turn early, so a long-running
2249
+ // subagent never holds the prompt hostage.
2250
+ //
2251
+ // is_error/auth already settled via failActive (activeTurn is null
2252
+ // then, so both branches no-op); cancellation is left to the
2253
+ // idle/abort path. settleActive is idempotent, so a duplicate
2254
+ // idle is a no-op.
2255
+ if (!session.cancelled) {
2256
+ settleOrDefer({ stopReason, usage: sessionUsage(session) });
2257
+ }
1433
2258
  }
1434
- // Settle the user turn at its terminal result so the client unlocks
1435
- // as soon as the answer is done, rather than waiting for the SDK's
1436
- // trailing `idle` (which can lag while background work runs — issue
1437
- // #773). The consumer keeps draining afterward (absorbing idle and
1438
- // forwarding any background output). is_error/auth already settled
1439
- // via failActive; cancellation is left to the idle/abort path.
1440
- // settleActive is idempotent, so a duplicate idle is a no-op.
1441
- if (!isTaskNotification && !session.cancelled) {
1442
- settleActive({ stopReason, usage: sessionUsage(session) });
2259
+ finally {
2260
+ if (!isAutonomousResult) {
2261
+ session.emittedAssistantText = false;
2262
+ }
1443
2263
  }
1444
2264
  break;
1445
2265
  }
@@ -1502,12 +2322,16 @@ export class ClaudeAcpAgent {
1502
2322
  const model = message.event.message.model;
1503
2323
  if (model && model !== "<synthetic>") {
1504
2324
  lastAssistantModel = model;
1505
- // Only upgrade from the default — once a `result` has given
1506
- // us an authoritative window, trust it over the heuristic.
1507
- // Model switches invalidate the cached window via
1508
- // `syncSessionConfigState`, which resets us back to the
1509
- // default so this branch runs again for the new model.
1510
- if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
2325
+ // Only upgrade from the heuristic default — once we have an
2326
+ // authoritative window (cache-seeded at session creation or
2327
+ // on a model switch, read from the resumed session on
2328
+ // session/load, confirmed by each `result`), trust it over
2329
+ // the heuristic. The flag, not the value, is the sentinel: an
2330
+ // authoritative window can legitimately equal
2331
+ // DEFAULT_CONTEXT_WINDOW (e.g. a backend serving a 200k lane
2332
+ // under a "[1m]"-spelled id) and must not be clobbered.
2333
+ if (!session.contextWindowAuthoritative &&
2334
+ session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
1511
2335
  const inferred = inferContextWindowFromModel(model);
1512
2336
  if (inferred !== null) {
1513
2337
  session.contextWindowSize = inferred;
@@ -1532,7 +2356,7 @@ export class ClaudeAcpAgent {
1532
2356
  const nextUsage = totalTokens(lastAssistantUsage);
1533
2357
  if (nextUsage !== lastAssistantTotalUsage) {
1534
2358
  lastAssistantTotalUsage = nextUsage;
1535
- await this.client.sessionUpdate({
2359
+ await sendUpdate({
1536
2360
  sessionId: params.sessionId,
1537
2361
  update: {
1538
2362
  sessionUpdate: "usage_update",
@@ -1548,8 +2372,11 @@ export class ClaudeAcpAgent {
1548
2372
  taskState: session.taskState,
1549
2373
  emittedToolCalls: session.emittedToolCalls,
1550
2374
  messageId: currentStreamMessageId,
2375
+ streamedToolInputs,
1551
2376
  })) {
1552
- await this.client.sessionUpdate(notification);
2377
+ // sendUpdate records delivery; a subagent stream's chunks carry
2378
+ // the stamped parentToolUseId meta and are excluded there.
2379
+ await sendUpdate(notification);
1553
2380
  }
1554
2381
  break;
1555
2382
  }
@@ -1570,7 +2397,7 @@ export class ClaudeAcpAgent {
1570
2397
  // is still promoted — activateTurn() clears the flag. The turn's own
1571
2398
  // echo is then dropped from the feed (the client already shows it).
1572
2399
  if (message.type === "user" && "uuid" in message && message.uuid) {
1573
- const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
2400
+ const queued = findUnsettledTurn(message.uuid);
1574
2401
  if (queued) {
1575
2402
  // Only (re)activate if this isn't already the active turn — a
1576
2403
  // turn promoted early (e.g. by a result that preceded its echo)
@@ -1588,15 +2415,38 @@ export class ClaudeAcpAgent {
1588
2415
  // debt so that lagged idle is absorbed rather than read
1589
2416
  // as the freshly-activated turn ending without a result
1590
2417
  // (which would false-fail a healthy turn — issue #825).
1591
- owedTrailingIdles++;
2418
+ // Counted for a DEFERRED turn too, even though its own
2419
+ // result already recorded a debt that may still be
2420
+ // outstanding: the interrupt can produce a trailer of
2421
+ // its own, and over-counting is benign (absorbs one
2422
+ // future idle) while under-counting risks the false
2423
+ // fail this debt exists to prevent.
2424
+ session.owedTrailingIdles++;
1592
2425
  // Before activateTurn resets the accumulator, so the
1593
2426
  // usage still belongs to the cancelled turn.
1594
2427
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1595
2428
  }
2429
+ else if (isHeldOpen(session.activeTurn)) {
2430
+ // A turn held open for its background subagents (see
2431
+ // Turn.deferredSettle) hands off with the real outcome
2432
+ // its result recorded, not a guessed end_turn — the
2433
+ // user moving on must not block behind a long-running
2434
+ // subagent, but it must not rewrite the stop reason
2435
+ // either. Its trailing-idle debt stands and is absorbed
2436
+ // when the drain idle eventually arrives.
2437
+ settleActive(session.activeTurn.deferredSettle);
2438
+ }
1596
2439
  else {
1597
2440
  settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
1598
2441
  }
1599
2442
  }
2443
+ // Unlike the no-result teardown lanes, this hand-off must
2444
+ // NOT clear emittedAssistantText for a NON-held previous
2445
+ // turn (a held one's settleActive above closes its own
2446
+ // stretch): the echo can land
2447
+ // mid-message, so deltas already streamed belong to the turn
2448
+ // being activated — clearing would forget them and let its
2449
+ // result re-emit the answer.
1600
2450
  activateTurn(queued);
1601
2451
  }
1602
2452
  break;
@@ -1649,7 +2499,7 @@ export class ClaudeAcpAgent {
1649
2499
  taskState: session.taskState,
1650
2500
  messageId: messageIdForGrouping(message),
1651
2501
  })) {
1652
- await this.client.sessionUpdate(notification);
2502
+ await sendUpdate(notification);
1653
2503
  }
1654
2504
  }
1655
2505
  else {
@@ -1673,12 +2523,7 @@ export class ClaudeAcpAgent {
1673
2523
  if (message.message.role === "system") {
1674
2524
  break;
1675
2525
  }
1676
- if (message.type === "assistant" &&
1677
- message.message.model === "<synthetic>" &&
1678
- Array.isArray(message.message.content) &&
1679
- message.message.content.length === 1 &&
1680
- message.message.content[0].type === "text" &&
1681
- message.message.content[0].text.includes("Please run /login")) {
2526
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
1682
2527
  failActive(RequestError.authRequired());
1683
2528
  break;
1684
2529
  }
@@ -1761,13 +2606,18 @@ export class ClaudeAcpAgent {
1761
2606
  taskState: session.taskState,
1762
2607
  emittedToolCalls: session.emittedToolCalls,
1763
2608
  messageId: messageIdForGrouping(message),
2609
+ toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
1764
2610
  })) {
1765
- await this.client.sessionUpdate(notification);
2611
+ // sendUpdate records delivery. Subagent text/thinking is
2612
+ // filtered out of `content` above; blocks that do pass through
2613
+ // (e.g. a subagent image) carry the stamped parentToolUseId
2614
+ // meta and are excluded there.
2615
+ await sendUpdate(notification);
1766
2616
  }
1767
2617
  break;
1768
2618
  }
1769
2619
  case "tool_progress": {
1770
- await this.client.sessionUpdate({
2620
+ await sendUpdate({
1771
2621
  sessionId: message.session_id,
1772
2622
  update: {
1773
2623
  sessionUpdate: "tool_call_update",
@@ -1776,7 +2626,20 @@ export class ClaudeAcpAgent {
1776
2626
  _meta: {
1777
2627
  claudeCode: {
1778
2628
  toolName: message.tool_name,
1779
- toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
2629
+ toolResponse: {
2630
+ elapsedTimeSeconds: message.elapsed_time_seconds,
2631
+ // For Agent/Task calls: the subagent's type, and — when
2632
+ // the subagent is waiting out an API rate-limit retry —
2633
+ // the SDK's retry counters (attempt, max_retries,
2634
+ // retry_delay_ms, …), forwarded verbatim so clients can
2635
+ // show why a spawn looks stalled.
2636
+ ...(message.subagent_type !== undefined && {
2637
+ subagentType: message.subagent_type,
2638
+ }),
2639
+ ...(message.subagent_retry !== undefined && {
2640
+ subagentRetry: message.subagent_retry,
2641
+ }),
2642
+ },
1780
2643
  },
1781
2644
  },
1782
2645
  },
@@ -1785,7 +2648,7 @@ export class ClaudeAcpAgent {
1785
2648
  }
1786
2649
  case "rate_limit_event": {
1787
2650
  if (lastAssistantTotalUsage !== null) {
1788
- await this.client.sessionUpdate({
2651
+ await sendUpdate({
1789
2652
  sessionId: message.session_id,
1790
2653
  update: {
1791
2654
  sessionUpdate: "usage_update",
@@ -1806,7 +2669,7 @@ export class ClaudeAcpAgent {
1806
2669
  case "conversation_reset":
1807
2670
  break;
1808
2671
  default:
1809
- unreachable(message);
2672
+ unreachable(message, this.logger);
1810
2673
  break;
1811
2674
  }
1812
2675
  }
@@ -1843,6 +2706,30 @@ export class ClaudeAcpAgent {
1843
2706
  }
1844
2707
  }
1845
2708
  }
2709
+ /** Route one orphaned command into the session's orphan-accounting lane:
2710
+ * the per-uuid map on msg_lifecycle_v1 CLIs (drained by the command's own
2711
+ * terminal lifecycle frame and the echo-less-result skip), the plain count
2712
+ * elsewhere (the count lane can't express per-command states, so `state`
2713
+ * only matters on the map lane). Both orphan-producing paths — cancel()'s
2714
+ * queued-turn sweep and the consumer's force-cancel wedge path — must seed
2715
+ * through here so the lane split stays a single mechanism.
2716
+ *
2717
+ * Known window: `msgLifecycleV1` is only learnable from the stream's first
2718
+ * `system`/init (the control-channel initialize carries no capabilities),
2719
+ * so a cancel that beats that drain seeds the COUNT lane on a
2720
+ * lifecycle-capable CLI — where command coalescing can leave the count
2721
+ * stale by N-1 (the pre-map bug, confined to this sub-second window and
2722
+ * still healed by the next activation's reset). Structural until the SDK
2723
+ * exposes capabilities before the stream starts. */
2724
+ trackOrphanCommand(session, uuid, state) {
2725
+ if (session.msgLifecycleV1) {
2726
+ session.orphanCommands ??= new Map();
2727
+ session.orphanCommands.set(uuid, state);
2728
+ }
2729
+ else {
2730
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
2731
+ }
2732
+ }
1846
2733
  async cancel(params) {
1847
2734
  const session = this.sessions[params.sessionId];
1848
2735
  if (!session) {
@@ -1856,11 +2743,18 @@ export class ClaudeAcpAgent {
1856
2743
  return;
1857
2744
  }
1858
2745
  session.cancelled = true;
2746
+ // Capture the orphan-accounting lane before anything can await: the
2747
+ // consumer latches msgLifecycleV1 when it drains the first system/init,
2748
+ // which can happen DURING the awaited interrupt() below — the receipt
2749
+ // reconciliation must act on the same lane the seeding used, or a
2750
+ // count-lane orphan would be left for the map-lane receipt path (which
2751
+ // never decrements the count) to miss.
2752
+ const lifecycleLane = session.msgLifecycleV1 === true;
1859
2753
  // Settle queued turns that haven't started yet (no echo seen) right away —
1860
2754
  // they have no in-flight SDK work to interrupt. The active turn is settled
1861
2755
  // by the consumer when it observes the interrupt's trailing idle (or via the
1862
2756
  // backstop below). Mirrors the old pendingMessages cancellation.
1863
- const orphanedUuids = [];
2757
+ const orphanedTurns = [];
1864
2758
  if (session.turnQueue) {
1865
2759
  for (const turn of session.turnQueue) {
1866
2760
  if (turn !== session.activeTurn && !turn.settled) {
@@ -1868,16 +2762,98 @@ export class ClaudeAcpAgent {
1868
2762
  // Deliberately no `usage`: a queued turn never ran, so the session
1869
2763
  // accumulator (the active turn's tally) is not its spend.
1870
2764
  turn.resolve({ stopReason: "cancelled" });
1871
- orphanedUuids.push(turn.promptUuid);
2765
+ orphanedTurns.push(turn);
1872
2766
  }
1873
2767
  }
1874
2768
  // Each removed queued turn's user message was already pushed to the SDK,
1875
2769
  // which processes input FIFO and will still emit a result for it with no
1876
- // uuid to match. Count those so the consumer skips them (see
2770
+ // uuid to match. Track those so the consumer skips them (see
1877
2771
  // ensureActiveTurn) rather than misattributing them to the head.
1878
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphanedUuids.length;
2772
+ // msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
2773
+ // own terminal lifecycle frame — exact under command coalescing, where
2774
+ // N queued commands fold into ONE turn emitting one result and a plain
2775
+ // count would go stale by N-1 and swallow a later echo-less result.
2776
+ // Older CLIs keep the count and its activation-time self-heal (they
2777
+ // never see lifecycle frames, so commandStarted/commandFinished stay
2778
+ // unset and every turn takes the plain-seed path below).
2779
+ for (const turn of orphanedTurns) {
2780
+ if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
2781
+ // The command already finished SDK-side and its terminal frame was
2782
+ // consumed while the turn sat queued — nothing is left to skip, and
2783
+ // a seeded entry would never drain.
2784
+ continue;
2785
+ }
2786
+ if (turn.commandFinished === "cancelled") {
2787
+ // Terminal frame already consumed. Dispatched-then-aborted: the
2788
+ // dead turn's late result may still come — seed the zombie the
2789
+ // frame handler would have made — unless that result already
2790
+ // passed pre-cancel (commandResultSeen: e.g. the command folded
2791
+ // into the active turn and their shared result was attributed
2792
+ // there), in which case a zombie would be a phantom that swallows
2793
+ // an unrelated later result. Never dispatched: dropped, no result
2794
+ // coming, nothing to track.
2795
+ if (turn.commandStarted && !turn.commandResultSeen) {
2796
+ this.trackOrphanCommand(session, turn.promptUuid, "zombie");
2797
+ }
2798
+ continue;
2799
+ }
2800
+ if (turn.commandStarted && turn.commandResultSeen) {
2801
+ // Dispatched and its turn's result already passed; only its
2802
+ // terminal frame is outstanding, which no-ops with no entry.
2803
+ continue;
2804
+ }
2805
+ this.trackOrphanCommand(session, turn.promptUuid, turn.commandStarted ? "started" : "pending");
2806
+ }
1879
2807
  session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
1880
2808
  }
2809
+ // A deferred active turn (see Turn.deferredSettle) already has its
2810
+ // result — it is only held open for its background subagents, which the
2811
+ // interrupt below tears down. Settle it "cancelled" NOW: during the hold
2812
+ // the session is typically already in state idle (the CLI's trailer
2813
+ // fired at the result), so the interrupt may produce no fresh idle for
2814
+ // the consumer's cancelled-settle path to run on, and the cancel would
2815
+ // otherwise stall until the force-cancel backstop. Any outstanding
2816
+ // trailer debt is absorbed by the idle handler when its idle does come.
2817
+ // The turn's own usage snapshot is reported per the cancelled-usage
2818
+ // contract (issue #844).
2819
+ {
2820
+ const active = session.activeTurn;
2821
+ if (isHeldOpen(active)) {
2822
+ active.settled = true;
2823
+ // Mirror settleActive's invariants (it is consumer-scoped and
2824
+ // unreachable from here): disarm the backstop — none should be
2825
+ // armed for a held turn, but a drift here must not leave a timer
2826
+ // firing on a settled turn — and drop the turn from the queue.
2827
+ disarmForceCancel(session);
2828
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== active);
2829
+ session.activeTurn = null;
2830
+ // Settling a held turn closes its delivery stretch: any streamed
2831
+ // text since the last boundary was its followups', and left latched
2832
+ // it would suppress a following replayed turn's issue-#453 fallback.
2833
+ session.emittedAssistantText = false;
2834
+ // When the interrupt below pre-empts a live cycle — running, or
2835
+ // blocked on a permission request (requires_action, the #866 shape
2836
+ // users cancel out of) — it produces a trailer idle with no counted
2837
+ // result; with the hold's own trailer typically already absorbed,
2838
+ // that idle would be un-owed and could lag past the next prompt's
2839
+ // echo — read as the fresh turn ending without a result (issue #825
2840
+ // false-fail). Pre-count it unless the session sits idle: there the
2841
+ // interrupt emits nothing, and a debt that never drains would mask
2842
+ // one future #825 detection. (lastSessionState is last-CONSUMED, so
2843
+ // both stale reads exist and both are accepted one-cycle windows: a
2844
+ // running transition still in the backlog reads as stale idle and
2845
+ // under-counts — that false-fail additionally needs the trailer to
2846
+ // lag past the next echo — while a cycle already completed into the
2847
+ // backlog reads as stale non-idle and over-counts, masking one
2848
+ // future #825 detection. Undefined — no state event consumed —
2849
+ // pre-counts; that only occurs on CLIs whose missing idle events
2850
+ // also disable the detector the debt could mask.)
2851
+ if (session.lastSessionState !== "idle") {
2852
+ session.owedTrailingIdles++;
2853
+ }
2854
+ active.resolve({ stopReason: "cancelled", usage: active.deferredSettle.usage });
2855
+ }
2856
+ }
1881
2857
  // Arm a backstop before interrupting: if a turn is actively consuming the
1882
2858
  // query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
1883
2859
  // block — issue #680), force the consumer to settle the active turn
@@ -1915,11 +2891,30 @@ export class ClaudeAcpAgent {
1915
2891
  // receipt, so a bare `{}` success from a gateway can't read as "everything
1916
2892
  // was dropped") — keep the count-everything behavior and its
1917
2893
  // activation-time self-heal.
1918
- if (Array.isArray(receipt?.still_queued) && orphanedUuids.length > 0) {
2894
+ if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
1919
2895
  const stillQueued = new Set(receipt.still_queued);
1920
- const dropped = orphanedUuids.filter((uuid) => !stillQueued.has(uuid)).length;
1921
- if (dropped > 0) {
1922
- session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
2896
+ if (lifecycleLane) {
2897
+ // Lifecycle lane: forget dropped orphans by uuid. Only entries still
2898
+ // "pending" — an orphan absent from `still_queued` because it was
2899
+ // DISPATCHED before the interrupt (not dropped) has usually been
2900
+ // promoted to "started" by its lifecycle frame by now, and its own
2901
+ // terminal frame must stay in charge of its fate. (If that frame is
2902
+ // still in the consumer's backlog we mis-forget — the same exposure
2903
+ // the count lane has always had for a dropped-then-run command.)
2904
+ // Mostly redundant with the "cancelled"-frame removal, but a receipt
2905
+ // survives paths where that frame was never emitted.
2906
+ for (const turn of orphanedTurns) {
2907
+ if (!stillQueued.has(turn.promptUuid) &&
2908
+ session.orphanCommands?.get(turn.promptUuid) === "pending") {
2909
+ session.orphanCommands.delete(turn.promptUuid);
2910
+ }
2911
+ }
2912
+ }
2913
+ else {
2914
+ const dropped = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid)).length;
2915
+ if (dropped > 0) {
2916
+ session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
2917
+ }
1923
2918
  }
1924
2919
  }
1925
2920
  }
@@ -1967,10 +2962,7 @@ export class ClaudeAcpAgent {
1967
2962
  // after the floor, and clear the timer so it can't outlive the deleted
1968
2963
  // session (it isn't unref'd and would otherwise keep the event loop alive
1969
2964
  // until it fires).
1970
- if (session.forceCancelTimer) {
1971
- clearTimeout(session.forceCancelTimer);
1972
- session.forceCancelTimer = undefined;
1973
- }
2965
+ disarmForceCancel(session);
1974
2966
  session.cancelController?.abort();
1975
2967
  this.closeQueryStream(session);
1976
2968
  // Abort the SDK abort signal only on explicit destroy. closeQueryStream
@@ -2150,6 +3142,12 @@ export class ClaudeAcpAgent {
2150
3142
  if (replaySession && replayMessageId && message.uuid) {
2151
3143
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
2152
3144
  }
3145
+ // The live prompt loop converts the synthetic "Please run /login"
3146
+ // assistant message into an authRequired error instead of showing its
3147
+ // TUI-specific text; skip it on replay too (issue #863).
3148
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3149
+ continue;
3150
+ }
2153
3151
  // @ts-expect-error - untyped in SDK but we handle all of these
2154
3152
  let content = message.message.content;
2155
3153
  // @ts-expect-error - untyped in SDK but we handle all of these
@@ -2188,14 +3186,14 @@ export class ClaudeAcpAgent {
2188
3186
  * outcome or a `requestCancelled` rejection). Either way we surface the same
2189
3187
  * "Tool use aborted" the callers already expect, so a cancelled dialog no
2190
3188
  * longer leaves the `await` hanging. */
2191
- async requestPermissionFromClient(params, toolName, signal) {
3189
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
2192
3190
  // The SDK may invoke `canUseTool` (and therefore this permission request)
2193
3191
  // before the assistant message's tool_use block streams to us. Some ACP clients
2194
3192
  // expect the `tool_call` a permission request references to already exist,
2195
3193
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
2196
3194
  // later refines it with a `tool_call_update` rather than emitting a
2197
3195
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
2198
- await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
3196
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
2199
3197
  try {
2200
3198
  return await this.client.requestPermission(params, signal);
2201
3199
  }
@@ -2212,10 +3210,15 @@ export class ClaudeAcpAgent {
2212
3210
  * instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
2213
3211
  * `toolCallNotification` helper as the streamed path so the two are identical.
2214
3212
  * Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
2215
- * skipped so a permission prompt for them never surfaces a stray tool_call. */
2216
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput) {
3213
+ * emitted too: a permission request referencing a tool call the client has
3214
+ * never seen can trip strict clients (issue #851), so the reference must
3215
+ * always resolve. Since the streamed path never completes those calls, they
3216
+ * are resolved at tool_result time instead (see `toAcpNotifications`).
3217
+ * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
3218
+ * that spawned it, matching the streamed path's `_meta`. */
3219
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
2217
3220
  const session = this.sessions[sessionId];
2218
- if (!session || !shouldEmitToolCall(toolName)) {
3221
+ if (!session) {
2219
3222
  return;
2220
3223
  }
2221
3224
  if (session.emittedToolCalls.has(toolCallId)) {
@@ -2223,13 +3226,20 @@ export class ClaudeAcpAgent {
2223
3226
  }
2224
3227
  session.emittedToolCalls.add(toolCallId);
2225
3228
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2226
- await this.client.sessionUpdate({
2227
- sessionId,
2228
- update: toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd),
2229
- });
3229
+ const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
3230
+ if (parentToolUseId) {
3231
+ update._meta = {
3232
+ ...update._meta,
3233
+ claudeCode: {
3234
+ ...(update._meta?.claudeCode || {}),
3235
+ parentToolUseId,
3236
+ },
3237
+ };
3238
+ }
3239
+ await this.client.sessionUpdate({ sessionId, update });
2230
3240
  }
2231
3241
  canUseTool(sessionId) {
2232
- return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
3242
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
2233
3243
  const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
2234
3244
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2235
3245
  const session = this.sessions[sessionId];
@@ -2239,6 +3249,23 @@ export class ClaudeAcpAgent {
2239
3249
  message: "Session not found",
2240
3250
  };
2241
3251
  }
3252
+ // When the tool call originates inside a subagent, attribute the eagerly
3253
+ // emitted tool_call (and the permission request itself) to the Agent/Task
3254
+ // tool call that spawned the subagent, mirroring the streamed subagent
3255
+ // path's `_meta.claudeCode.parentToolUseId` (see `liveBackgroundTasks`).
3256
+ const parentToolUseId = agentID
3257
+ ? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
3258
+ : undefined;
3259
+ if (agentID && !parentToolUseId) {
3260
+ // The attribution rests on an undocumented SDK invariant
3261
+ // (task_started.task_id === canUseTool's agentID for subagent tasks;
3262
+ // verified against the bundled CLI). Should an SDK bump break it — or
3263
+ // the consumer lose the race with task_started — the lookup misses and
3264
+ // the request goes out unattributed; log it so the regression is
3265
+ // observable rather than silent.
3266
+ this.logger.log(`[claude-agent-acp] No parent tool_use recorded for subagent ${agentID}; ` +
3267
+ `sending the ${toolName} permission request unattributed`);
3268
+ }
2242
3269
  // AskUserQuestion is surfaced to us as a normal permission check (the SDK
2243
3270
  // routes it through canUseTool whenever a callback is registered, rather
2244
3271
  // than the interactive dialog). Present it as an ACP form elicitation and
@@ -2246,7 +3273,7 @@ export class ClaudeAcpAgent {
2246
3273
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
2247
3274
  // Like permission requests, the elicitation references this toolUseID, so
2248
3275
  // make sure the tool_call has surfaced to the client before we send it.
2249
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
3276
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
2250
3277
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
2251
3278
  }
2252
3279
  if (toolName === "ExitPlanMode") {
@@ -2280,8 +3307,13 @@ export class ClaudeAcpAgent {
2280
3307
  toolCallId: toolUseID,
2281
3308
  rawInput: toolInput,
2282
3309
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3310
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3311
+ // so clients can rely on one shape everywhere.
3312
+ ...(parentToolUseId
3313
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3314
+ : {}),
2283
3315
  },
2284
- }, toolName, signal);
3316
+ }, toolName, signal, parentToolUseId);
2285
3317
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2286
3318
  throw new Error("Tool use aborted");
2287
3319
  }
@@ -2315,7 +3347,14 @@ export class ClaudeAcpAgent {
2315
3347
  };
2316
3348
  }
2317
3349
  }
2318
- if (session.modes.currentModeId === "bypassPermissions") {
3350
+ // In bypass mode the CLI skips permission checks itself; the asks that
3351
+ // still reach canUseTool are the ones it insists on prompting for even
3352
+ // under --dangerously-skip-permissions. Keep auto-allowing those —
3353
+ // bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
3354
+ // user explicitly configured a permissions.ask rule for this tool, and
3355
+ // the SDK's guidance is that hosts running auto-approval must treat such
3356
+ // asks as a human prompt. Fall through to the normal request below.
3357
+ if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
2319
3358
  return {
2320
3359
  behavior: "allow",
2321
3360
  updatedInput: toolInput,
@@ -2339,8 +3378,13 @@ export class ClaudeAcpAgent {
2339
3378
  toolCallId: toolUseID,
2340
3379
  rawInput: toolInput,
2341
3380
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3381
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3382
+ // so clients can rely on one shape everywhere.
3383
+ ...(parentToolUseId
3384
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3385
+ : {}),
2342
3386
  },
2343
- }, toolName, signal);
3387
+ }, toolName, signal, parentToolUseId);
2344
3388
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2345
3389
  throw new Error("Tool use aborted");
2346
3390
  }
@@ -2516,12 +3560,21 @@ export class ClaudeAcpAgent {
2516
3560
  // carries no "1m" token.
2517
3561
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
2518
3562
  if (session.models.currentModelId !== value) {
2519
- // The cached context window was learned for the previous model; reset
2520
- // to the new model's heuristic so mid-stream updates between now and
2521
- // the next `result` reflect the user's selection instead of the old
2522
- // model's window.
2523
- session.contextWindowSize =
2524
- inferContextWindowFromModel(value, newModelInfo?.displayName, newModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW;
3563
+ // Seed the new model's context window WITHOUT any IPC on the switch
3564
+ // path: cached authoritative value if we've already learned it (from a
3565
+ // prior turn's `result.modelUsage`), else the text heuristic, else the
3566
+ // default. We deliberately do NOT call `getContextUsage` here — before
3567
+ // a fresh session's first prompt turn that control request is not
3568
+ // serviced (~15s stall, issues #886/#880), and (because SDK control
3569
+ // requests are serialized over one channel) it would drag the awaited
3570
+ // `setModel` down with it. The authoritative window arrives on the
3571
+ // first `result.modelUsage` for the model and is cached from there;
3572
+ // until then a switched-to alias that has never run a turn shows the
3573
+ // heuristic/default window, which self-corrects on its first response
3574
+ // (matches pre-0.59.0 behavior).
3575
+ const seeded = immediateContextWindow(session.providerCacheKey, value, newModelInfo);
3576
+ session.contextWindowSize = seeded.size;
3577
+ session.contextWindowAuthoritative = seeded.authoritative;
2525
3578
  }
2526
3579
  session.models = { ...session.models, currentModelId: value };
2527
3580
  // Recompute availableModes for the new model and clamp the current
@@ -2853,6 +3906,28 @@ export class ClaudeAcpAgent {
2853
3906
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
2854
3907
  // the same Map that the streaming message handler will read from.
2855
3908
  const taskState = new Map();
3909
+ // The exact env the query will be created with. Built (and the provider
3910
+ // cache key derived from it, below) in one place so the key always
3911
+ // describes the backend this query actually talks to: `providers/set`,
3912
+ // `providers/disable`, and `logout` mutate the process-wide provider
3913
+ // config concurrently, so re-resolving it after any of the awaits between
3914
+ // here and the session registration could disagree with the env baked
3915
+ // into the query.
3916
+ const env = {
3917
+ ...process.env,
3918
+ ...userProvidedOptions?.env,
3919
+ // Client-managed LLM routing: `providers/set` config wins, else the
3920
+ // legacy gateway auth request. Baked into the query at creation, so it
3921
+ // only affects sessions started after the change (matching the RFD).
3922
+ ...createEnvForProvider(this.resolveProviderConfig()),
3923
+ // Opt-in to session state events like when the agent is idle
3924
+ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
3925
+ };
3926
+ // Scopes the context-window cache to this query's backend (see
3927
+ // `contextWindowCache`). Derived from the same `env` object handed to the
3928
+ // SDK, so per-session `_meta` env routing and ambient process-env routing
3929
+ // are distinguished exactly as the CLI will see them.
3930
+ const providerCacheKey = providerCacheKeyFor(env);
2856
3931
  const options = {
2857
3932
  systemPrompt,
2858
3933
  settingSources: ["user", "project", "local"],
@@ -2869,13 +3944,7 @@ export class ClaudeAcpAgent {
2869
3944
  ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
2870
3945
  },
2871
3946
  }),
2872
- env: {
2873
- ...process.env,
2874
- ...userProvidedOptions?.env,
2875
- ...createEnvForGateway(this.gatewayAuthRequest),
2876
- // Opt-in to session state events like when the agent is idle
2877
- CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
2878
- },
3947
+ env,
2879
3948
  // Override certain fields that must be controlled by ACP
2880
3949
  cwd: params.cwd,
2881
3950
  includePartialMessages: true,
@@ -2917,7 +3986,7 @@ export class ClaudeAcpAgent {
2917
3986
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
2918
3987
  {
2919
3988
  hooks: [
2920
- createPostToolUseHook(this.logger, {
3989
+ createPostToolUseHook({
2921
3990
  onEnterPlanMode: async () => {
2922
3991
  await this.client.sessionUpdate({
2923
3992
  sessionId,
@@ -3023,7 +4092,7 @@ export class ClaudeAcpAgent {
3023
4092
  const allowedModels = Array.isArray(settingsAvailableModels)
3024
4093
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
3025
4094
  : initializationResult.models;
3026
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
4095
+ const { modelState: models, resumedContextWindow } = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
3027
4096
  // Gate `auto` (and future model-specific modes) on the resolved model's
3028
4097
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
3029
4098
  // A resumed session can be running a model outside the `availableModels`
@@ -3119,9 +4188,39 @@ export class ClaudeAcpAgent {
3119
4188
  typeof initialEffort.currentValue === "string" &&
3120
4189
  initialEffort.currentValue !== "default") {
3121
4190
  await q.applyFlagSettings({
3122
- effortLevel: initialEffort.currentValue,
4191
+ effortLevel: toSdkEffortLevel(initialEffort.currentValue),
3123
4192
  });
3124
4193
  }
4194
+ // Seed the context window WITHOUT any extra IPC on the session/new path.
4195
+ // On session/load, the resumed session's own `getContextUsage` report — a
4196
+ // response `getAvailableModels` already awaited to learn the live model
4197
+ // (resumed sessions ARE serviced pre-turn, unlike fresh ones) — is
4198
+ // authoritative and wins. Otherwise: the cached authoritative window if a
4199
+ // prior turn has learned it for this model (`result.modelUsage`,
4200
+ // cross-session), else the text heuristic, else the default. We
4201
+ // deliberately do NOT issue a getContextUsage call here: on a fresh
4202
+ // session that control request is not serviced until the first prompt
4203
+ // turn runs, so awaiting it — as 0.59.0 did — made session/new take ~15s
4204
+ // (issues #886/#880). The authoritative window arrives on the first
4205
+ // `result.modelUsage` and is cached from there.
4206
+ //
4207
+ // Text inference alone misses aliases that resolve to extended-context
4208
+ // models with no "1m" token anywhere in their id or description (e.g.
4209
+ // `sonnet` → claude-sonnet-5, natively ~1M): those stream
4210
+ // `usage_update.size: 200000` until the first result's modelUsage corrects
4211
+ // it — but the cache means only the FIRST session to ever run a turn on such
4212
+ // a model eats that window, not every fresh session after a process
4213
+ // restart (issue #596; a post-restart session/load is covered by the
4214
+ // resumed report above).
4215
+ //
4216
+ // The inference fallback is deliberately keyed to the allowlisted entry: a
4217
+ // fallback-resolved sibling's resolvedModel/displayName/description can
4218
+ // describe a different context lane than the verbatim live id (e.g. an
4219
+ // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
4220
+ // the id itself is a trustworthy window signal.
4221
+ const seededWindow = resumedContextWindow !== null
4222
+ ? { size: resumedContextWindow, authoritative: true }
4223
+ : immediateContextWindow(providerCacheKey, models.currentModelId, allowlistedModelInfo);
3125
4224
  this.sessions[sessionId] = {
3126
4225
  query: q,
3127
4226
  input: input,
@@ -3144,16 +4243,15 @@ export class ClaudeAcpAgent {
3144
4243
  fastModeEnabled,
3145
4244
  abortController,
3146
4245
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
3147
- contextWindowSize:
3148
- // Deliberately keyed to the allowlisted entry: a fallback-resolved
3149
- // sibling's displayName/description can describe a different context
3150
- // lane than the verbatim live id (e.g. an "opus[1m]" row matched for
3151
- // a bare 200k id), so on the fallback path only the id itself is a
3152
- // trustworthy window signal.
3153
- inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
4246
+ contextWindowSize: seededWindow.size,
4247
+ contextWindowAuthoritative: seededWindow.authoritative,
4248
+ providerCacheKey,
3154
4249
  taskState,
3155
4250
  toolUseCache: {},
3156
4251
  emittedToolCalls: new Set(),
4252
+ liveBackgroundTasks: new Map(),
4253
+ emittedAssistantText: false,
4254
+ owedTrailingIdles: 0,
3157
4255
  messageIdToUuid: new Map(),
3158
4256
  };
3159
4257
  return {
@@ -3223,27 +4321,76 @@ function snapshotFromUsage(usage) {
3223
4321
  cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
3224
4322
  };
3225
4323
  }
3226
- function createEnvForGateway(request) {
4324
+ /**
4325
+ * Adapt a legacy gateway `authenticate` request into the shared
4326
+ * {@link ProviderConfig} shape. Returns `null` when no gateway request is
4327
+ * present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
4328
+ * otherwise anthropic.
4329
+ */
4330
+ function gatewayRequestToProviderConfig(request) {
3227
4331
  if (!request?._meta) {
4332
+ return null;
4333
+ }
4334
+ return {
4335
+ apiType: request.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
4336
+ baseUrl: request._meta.gateway.baseUrl,
4337
+ headers: request._meta.gateway.headers,
4338
+ };
4339
+ }
4340
+ /**
4341
+ * Map a resolved provider config into the Claude Code env vars that redirect API
4342
+ * traffic and inject headers. Returns an empty object when routing is
4343
+ * unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
4344
+ * skips its normal login/credential checks when a gateway is in use.
4345
+ */
4346
+ function createEnvForProvider(config) {
4347
+ if (!config) {
3228
4348
  return {};
3229
4349
  }
3230
- const customHeaders = Object.entries(request._meta.gateway.headers)
4350
+ const customHeaders = Object.entries(config.headers)
3231
4351
  .map(([key, value]) => `${key}: ${value}`)
3232
4352
  .join("\n");
3233
- if (request.methodId === "gateway-bedrock") {
4353
+ if (config.apiType === "bedrock") {
3234
4354
  return {
3235
4355
  CLAUDE_CODE_USE_BEDROCK: "1",
3236
4356
  AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
3237
- ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
4357
+ ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
4358
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
4359
+ };
4360
+ }
4361
+ if (config.apiType === "vertex") {
4362
+ // `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
4363
+ // validation; fall back to empty strings defensively.
4364
+ return {
4365
+ CLAUDE_CODE_USE_VERTEX: "1",
4366
+ ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
4367
+ ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
4368
+ CLOUD_ML_REGION: config.vertex?.region ?? "",
3238
4369
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3239
4370
  };
3240
4371
  }
3241
4372
  return {
3242
- ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
4373
+ ANTHROPIC_BASE_URL: config.baseUrl,
3243
4374
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
3244
4375
  ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
3245
4376
  };
3246
4377
  }
4378
+ /**
4379
+ * Validate a provider base URL: must be a non-empty absolute http(s) URL.
4380
+ */
4381
+ function isValidBaseUrl(baseUrl) {
4382
+ if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
4383
+ return false;
4384
+ }
4385
+ let parsed;
4386
+ try {
4387
+ parsed = new URL(baseUrl);
4388
+ }
4389
+ catch {
4390
+ return false;
4391
+ }
4392
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
4393
+ }
3247
4394
  /**
3248
4395
  * Build the list of permission modes the agent will advertise for the given
3249
4396
  * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
@@ -3293,7 +4440,10 @@ function buildAvailableModes(modelInfo) {
3293
4440
  // and only clears a key when an explicit `null` is sent — see
3294
4441
  // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
3295
4442
  // `"default"` sentinel and `undefined` (effort option absent for the model) to
3296
- // `null` ensures any previously-applied flag is actually cleared.
4443
+ // `null` ensures any previously-applied flag is actually cleared. Typed as
4444
+ // `EffortLevel` (not `Settings["effortLevel"]`): the picker offers whatever
4445
+ // `supportedEffortLevels` reports, which includes the session-scoped `"max"`
4446
+ // that the persisted Settings shape deliberately excludes.
3297
4447
  function toSdkEffortLevel(value) {
3298
4448
  return value === undefined || value === "default" ? null : value;
3299
4449
  }
@@ -3745,23 +4895,37 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
3745
4895
  }
3746
4896
  /** Read the model a resumed session is actually running (via the
3747
4897
  * `getContextUsage` control request — the same source `/context` prints) and
3748
- * map it onto the picker. Best-effort: a control-request failure is logged
3749
- * and returns null so callers keep their current choice; failing the whole
3750
- * session/load over an unreadable report would be worse. */
4898
+ * map it onto the picker, along with the report's authoritative context
4899
+ * window (`rawMaxTokens`). Resumed sessions get this request serviced before
4900
+ * any turn runs in the new process — unlike fresh sessions, where it stalls
4901
+ * until the first prompt turn (issues #886/#880) — so the same response that
4902
+ * restores the live model (issue #845) also seeds the window for free,
4903
+ * covering post-restart reloads of models the text heuristic misses (issue
4904
+ * #596). Best-effort: a control-request failure is logged and returns nulls
4905
+ * so callers keep their current choice; failing the whole session/load over
4906
+ * an unreadable report would be worse. */
3751
4907
  async function readResumedLiveModel(query, models, logger) {
3752
4908
  try {
3753
- const liveModel = (await query.getContextUsage()).model;
3754
- return liveModel ? matchResumedModel(models, liveModel) : null;
4909
+ const usage = await query.getContextUsage();
4910
+ return {
4911
+ model: usage.model ? matchResumedModel(models, usage.model) : null,
4912
+ contextWindow: usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null,
4913
+ };
3755
4914
  }
3756
4915
  catch (error) {
3757
4916
  logger.error("Failed to read the resumed session's live model:", error);
3758
- return null;
4917
+ return { model: null, contextWindow: null };
3759
4918
  }
3760
4919
  }
3761
4920
  async function getAvailableModels(query, models, sdkModels, settingsManager, logger, isResumedSession) {
3762
4921
  const settings = settingsManager.getSettings();
3763
4922
  let currentModel = models[0];
3764
4923
  let resolvedFromInput;
4924
+ // The context window reported alongside a resumed session's live model.
4925
+ // Only ever non-null on the paths where `currentModel` IS the live model
4926
+ // (no override, or a failed override re-assert), so the window always
4927
+ // describes the model the session actually runs.
4928
+ let resumedContextWindow = null;
3765
4929
  // Model priority (highest to lowest):
3766
4930
  // 1. ANTHROPIC_MODEL environment variable
3767
4931
  // 2. settings.model (user configuration)
@@ -3789,7 +4953,9 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
3789
4953
  // the SDK is already running this model, and pushing a picker alias back
3790
4954
  // (e.g. "opus[1m]") could change the live model rather than describe it.
3791
4955
  if (resolvedFromInput === undefined && isResumedSession) {
3792
- currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
4956
+ const live = await readResumedLiveModel(query, models, logger);
4957
+ currentModel = live.model ?? currentModel;
4958
+ resumedContextWindow = live.contextWindow;
3793
4959
  }
3794
4960
  // Skip the setModel round-trip when we can prove the SDK has already landed
3795
4961
  // on the same model. Two cases qualify:
@@ -3822,16 +4988,21 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
3822
4988
  if (!isResumedSession)
3823
4989
  throw error;
3824
4990
  logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
3825
- currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
4991
+ const live = await readResumedLiveModel(query, models, logger);
4992
+ currentModel = live.model ?? currentModel;
4993
+ resumedContextWindow = live.contextWindow;
3826
4994
  }
3827
4995
  }
3828
4996
  return {
3829
- availableModels: models.map((model) => ({
3830
- modelId: model.value,
3831
- name: model.displayName,
3832
- description: model.description,
3833
- })),
3834
- currentModelId: currentModel.value,
4997
+ modelState: {
4998
+ availableModels: models.map((model) => ({
4999
+ modelId: model.value,
5000
+ name: model.displayName,
5001
+ description: model.description,
5002
+ })),
5003
+ currentModelId: currentModel.value,
5004
+ },
5005
+ resumedContextWindow,
3835
5006
  };
3836
5007
  }
3837
5008
  function getAvailableSlashCommands(commands) {
@@ -3958,6 +5129,12 @@ export function promptToClaude(prompt) {
3958
5129
  },
3959
5130
  session_id: prompt.sessionId,
3960
5131
  parent_tool_use_id: null,
5132
+ // ACP prompts are the user's own input relayed by the client. Stamp the
5133
+ // provenance explicitly: per the SDK, a host wrapping keyboard input must
5134
+ // send `{kind: "human"}` — an absent `origin` is treated as unattributed
5135
+ // and fails closed at the CLI's strict isHuman() trust gates (e.g. the
5136
+ // ultracode keyword opt-in honors only human-originated turns).
5137
+ origin: { kind: "human" },
3961
5138
  };
3962
5139
  }
3963
5140
  /**
@@ -4005,10 +5182,12 @@ function isTaskTool(toolName) {
4005
5182
  toolName === "TaskList" ||
4006
5183
  toolName === "TaskGet");
4007
5184
  }
4008
- /** Whether a tool's tool_use surfaces to the client as a standalone
5185
+ /** Whether the streamed tool_use path surfaces this tool as a standalone
4009
5186
  * `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
4010
5187
  * suppressed (their plan snapshot is emitted at tool_result time), so neither
4011
- * produces a tool_call. */
5188
+ * produces a streamed tool_call/tool_call_update — which means a
5189
+ * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
5190
+ * resolved explicitly at tool_result time. */
4012
5191
  function shouldEmitToolCall(toolName) {
4013
5192
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
4014
5193
  }
@@ -4043,6 +5222,28 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
4043
5222
  ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
4044
5223
  };
4045
5224
  }
5225
+ /** Refine a pending tool call from the complete top-level fields recovered
5226
+ * from its still-streaming input. Shares `toolInfoFromToolUse` with the
5227
+ * consolidated path but never carries `content`: content built from partial
5228
+ * input is misleading (an Edit missing its `new_string` renders as a pure
5229
+ * deletion) or invalid (a Write diff without `content` lacks the required
5230
+ * `newText`), and the consolidated message supplies it moments later. */
5231
+ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
5232
+ // TodoWrite/Task* never surfaced a tool_call to refine (plan lane).
5233
+ if (!shouldEmitToolCall(toolUse.name)) {
5234
+ return undefined;
5235
+ }
5236
+ const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
5237
+ return {
5238
+ _meta: { claudeCode: { toolName: toolUse.name } },
5239
+ toolCallId: toolUse.id,
5240
+ sessionUpdate: "tool_call_update",
5241
+ rawInput: input,
5242
+ title,
5243
+ kind,
5244
+ ...(locations ? { locations } : {}),
5245
+ };
5246
+ }
4046
5247
  /**
4047
5248
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
4048
5249
  * Only handles text, image, and thinking chunks for now.
@@ -4074,6 +5275,15 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4074
5275
  }
4075
5276
  return [{ sessionId, update }];
4076
5277
  }
5278
+ // `tool_use_result` is message-level and carries no tool_use_id of its own:
5279
+ // it describes "the" tool_result block of the message it rode in on. If
5280
+ // several tool_result blocks were ever batched into one message it couldn't
5281
+ // be attributed, so it is only honored when the message carries exactly one.
5282
+ const toolUseResult = options?.toolUseResult !== undefined &&
5283
+ content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
5284
+ .length === 1
5285
+ ? options.toolUseResult
5286
+ : undefined;
4077
5287
  const output = [];
4078
5288
  // Only handle the first chunk for streaming; extend as needed for batching
4079
5289
  for (const chunk of content) {
@@ -4215,12 +5425,59 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4215
5425
  case "bash_code_execution_tool_result":
4216
5426
  case "text_editor_code_execution_tool_result":
4217
5427
  case "mcp_tool_result": {
5428
+ const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
4218
5429
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
4219
5430
  const toolUse = toolUseCache[chunk.tool_use_id];
4220
5431
  if (!toolUse) {
5432
+ // The permission flow may have surfaced this tool_call even though
5433
+ // its tool_use never reached the cache (e.g. the assistant message
5434
+ // carrying it was dropped by the cancelled-turn guard and a straggler
5435
+ // result landed later). Resolve the surfaced call anyway so it can't
5436
+ // stay pending in the client forever; without the cache entry the
5437
+ // tool name is unknown, so no claudeCode meta is attached.
5438
+ if (wasEmitted) {
5439
+ output.push({
5440
+ sessionId,
5441
+ update: {
5442
+ toolCallId: chunk.tool_use_id,
5443
+ sessionUpdate: "tool_call_update",
5444
+ status: "is_error" in chunk && chunk.is_error
5445
+ ? "failed"
5446
+ : "completed",
5447
+ rawOutput: chunk.content,
5448
+ },
5449
+ });
5450
+ }
4221
5451
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
4222
5452
  break;
4223
5453
  }
5454
+ // A permission request may have surfaced a plan-rendered (TodoWrite) or
5455
+ // suppressed (Task*) tool as a real tool_call so the request referenced
5456
+ // a tool call the client knows about (see `ensureToolCallEmitted`,
5457
+ // issue #851). The branches below never emit a tool_call_update for
5458
+ // those tools, which would leave the surfaced call pending in the
5459
+ // client forever — resolve it here. `wasEmitted` is only ever true for
5460
+ // these tools via the permission flow: the streamed plan/suppressed
5461
+ // branches don't record emissions.
5462
+ if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
5463
+ output.push({
5464
+ sessionId,
5465
+ update: {
5466
+ _meta: {
5467
+ claudeCode: {
5468
+ toolName: toolUse.name,
5469
+ ...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
5470
+ },
5471
+ },
5472
+ toolCallId: chunk.tool_use_id,
5473
+ sessionUpdate: "tool_call_update",
5474
+ status: "is_error" in chunk && chunk.is_error
5475
+ ? "failed"
5476
+ : "completed",
5477
+ rawOutput: chunk.content,
5478
+ },
5479
+ });
5480
+ }
4224
5481
  if (isTaskTool(toolUse.name)) {
4225
5482
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
4226
5483
  // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
@@ -4244,7 +5501,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4244
5501
  }
4245
5502
  }
4246
5503
  else if (toolUse.name !== "TodoWrite") {
4247
- const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
5504
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, toolUseResult);
4248
5505
  // When terminal output is supported, send terminal_output as a
4249
5506
  // separate notification to match codex-acp's streaming lifecycle:
4250
5507
  // 1. tool_call → _meta.terminal_info (already sent above)
@@ -4321,35 +5578,105 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4321
5578
  }
4322
5579
  export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
4323
5580
  const event = message.event;
5581
+ const streamKey = message.parent_tool_use_id ?? "";
5582
+ const streamedToolInputs = options?.streamedToolInputs;
5583
+ const forwardedOptions = {
5584
+ clientCapabilities: options?.clientCapabilities,
5585
+ parentToolUseId: message.parent_tool_use_id,
5586
+ cwd: options?.cwd,
5587
+ taskState: options?.taskState,
5588
+ emittedToolCalls: options?.emittedToolCalls,
5589
+ messageId: options?.messageId,
5590
+ };
4324
5591
  switch (event.type) {
4325
- case "content_block_start":
4326
- return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
4327
- clientCapabilities: options?.clientCapabilities,
4328
- parentToolUseId: message.parent_tool_use_id,
4329
- cwd: options?.cwd,
4330
- taskState: options?.taskState,
4331
- emittedToolCalls: options?.emittedToolCalls,
4332
- messageId: options?.messageId,
4333
- });
4334
- case "content_block_delta":
4335
- return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
4336
- clientCapabilities: options?.clientCapabilities,
4337
- parentToolUseId: message.parent_tool_use_id,
4338
- cwd: options?.cwd,
4339
- taskState: options?.taskState,
4340
- emittedToolCalls: options?.emittedToolCalls,
4341
- messageId: options?.messageId,
4342
- });
5592
+ case "content_block_start": {
5593
+ const block = event.content_block;
5594
+ if (streamedToolInputs &&
5595
+ (block.type === "tool_use" ||
5596
+ block.type === "server_tool_use" ||
5597
+ block.type === "mcp_tool_use")) {
5598
+ let inputsForMessage = streamedToolInputs.get(streamKey);
5599
+ if (!inputsForMessage) {
5600
+ inputsForMessage = new Map();
5601
+ streamedToolInputs.set(streamKey, inputsForMessage);
5602
+ }
5603
+ inputsForMessage.set(event.index, {
5604
+ id: block.id,
5605
+ name: block.name,
5606
+ partialJson: "",
5607
+ scannedTo: 0,
5608
+ inString: false,
5609
+ escaped: false,
5610
+ objectDepth: 0,
5611
+ arrayDepth: 0,
5612
+ lastTopLevelComma: -1,
5613
+ emittedThroughComma: -1,
5614
+ });
5615
+ }
5616
+ return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
5617
+ }
5618
+ case "content_block_delta": {
5619
+ if (event.delta.type === "input_json_delta") {
5620
+ const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
5621
+ if (!streamedInput)
5622
+ return [];
5623
+ streamedInput.partialJson += event.delta.partial_json;
5624
+ if (scanStreamedToolInput(streamedInput)) {
5625
+ // Input complete: the consolidated assistant message replays the
5626
+ // block with its full input and refines the call there; emitting
5627
+ // here too would send a duplicate identical update.
5628
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
5629
+ inputsForMessage?.delete(event.index);
5630
+ if (inputsForMessage?.size === 0)
5631
+ streamedToolInputs?.delete(streamKey);
5632
+ return [];
5633
+ }
5634
+ if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
5635
+ return [];
5636
+ }
5637
+ streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
5638
+ const input = recoveredToolInput(streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma));
5639
+ if (!input)
5640
+ return [];
5641
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
5642
+ const update = streamedInputRefinement(streamedInput, input, supportsTerminalOutput, options?.cwd);
5643
+ if (!update)
5644
+ return [];
5645
+ if (message.parent_tool_use_id) {
5646
+ update._meta = {
5647
+ ...update._meta,
5648
+ claudeCode: {
5649
+ ...(update._meta?.claudeCode || {}),
5650
+ parentToolUseId: message.parent_tool_use_id,
5651
+ },
5652
+ };
5653
+ }
5654
+ applyMessageId(update, options?.messageId);
5655
+ return [{ sessionId, update }];
5656
+ }
5657
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
5658
+ }
4343
5659
  // No content. `ping` is a Messages-API keep-alive event that the SDK's
4344
5660
  // `BetaRawMessageStreamEvent` union doesn't include even though the
4345
5661
  // wire format emits it; the `as never` cast lets us no-op it here
4346
5662
  // instead of letting it fall through to `unreachable`.
4347
5663
  case "ping":
4348
- case "message_start":
4349
5664
  case "message_delta":
5665
+ return [];
5666
+ // A message boundary ends every input stream on this lane: message_stop is
5667
+ // the normal end, and a message_start clears anything a prior message on
5668
+ // the lane left behind (e.g. a stream cut short mid-block).
5669
+ case "message_start":
4350
5670
  case "message_stop":
4351
- case "content_block_stop":
5671
+ streamedToolInputs?.delete(streamKey);
4352
5672
  return [];
5673
+ case "content_block_stop": {
5674
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
5675
+ inputsForMessage?.delete(event.index);
5676
+ if (inputsForMessage?.size === 0)
5677
+ streamedToolInputs?.delete(streamKey);
5678
+ return [];
5679
+ }
4353
5680
  default:
4354
5681
  unreachable(event, logger);
4355
5682
  return [];
@@ -4407,6 +5734,9 @@ export function runAcp() {
4407
5734
  .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
4408
5735
  .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
4409
5736
  .onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
5737
+ .onRequest(methods.agent.providers.list, (ctx) => agent.unstable_listProviders(ctx.params))
5738
+ .onRequest(methods.agent.providers.set, (ctx) => agent.unstable_setProvider(ctx.params))
5739
+ .onRequest(methods.agent.providers.disable, (ctx) => agent.unstable_disableProvider(ctx.params))
4410
5740
  .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
4411
5741
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
4412
5742
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
@@ -4421,19 +5751,24 @@ function commonPrefixLength(a, b) {
4421
5751
  }
4422
5752
  return i;
4423
5753
  }
4424
- /** Best-effort first guess of a model's context window, used only as a
4425
- * fallback when the SDK's authoritative `getContextUsage` is unavailable (and
4426
- * until a `result` message arrives with the `modelUsage` value).
5754
+ /** Best-effort first guess of a model's context window, used to seed the
5755
+ * window synchronously (via `immediateContextWindow`) until a `result` message
5756
+ * arrives with the authoritative `modelUsage` value.
4427
5757
  *
4428
5758
  * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
4429
5759
  * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
4430
5760
  * matching things like "10m" or embedded substrings. Semantic aliases like
4431
- * `default` carry no such token in the ID, but the SDK's human-facing
4432
- * `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
4433
- * pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
4434
- * `ModelInfo` exposes no structured context-window field, so this text scan is
4435
- * the only pre-`result` signal available. A miss falls back to the default
4436
- * window and is corrected by `result.modelUsage` within one turn. */
5761
+ * `default` carry no such token in the ID, but their `resolvedModel` and the
5762
+ * SDK's human-facing `displayName`/`description` can (e.g.
5763
+ * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
5764
+ * This text scan can't catch every model — some resolve to extended-context
5765
+ * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
5766
+ * ~1M). Such a miss falls back to the default window and is corrected by
5767
+ * `result.modelUsage` (and cached) within one turn. We do NOT consult the
5768
+ * SDK's `getContextUsage` to close that gap: on a fresh session it is not
5769
+ * serviced before the first prompt turn (issues #886/#880, see
5770
+ * `contextWindowCache`; resumed sessions do get it, via
5771
+ * `readResumedLiveModel`). */
4437
5772
  function inferContextWindowFromModel(...texts) {
4438
5773
  if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
4439
5774
  return 1_000_000;
@@ -4443,12 +5778,7 @@ function inferContextWindowFromModel(...texts) {
4443
5778
  * `getContextUsage` control request. Unlike the per-message API usage numbers
4444
5779
  * (which only count message tokens), this `totalTokens` includes the system
4445
5780
  * prompt, tool schemas, MCP tools, and memory-file overhead — the real
4446
- * occupancy the user sees. Returns `null` on any control-request failure.
4447
- *
4448
- * Note: we deliberately do NOT use this response's window fields for `size`.
4449
- * They have been observed to under-report extended (1M) context windows, so
4450
- * the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
4451
- * which handle the 1M variants correctly. */
5781
+ * occupancy the user sees. Returns `null` on any control-request failure. */
4452
5782
  async function fetchContextUsedTokens(query, logger) {
4453
5783
  try {
4454
5784
  const usage = await query.getContextUsage();
@@ -4459,6 +5789,95 @@ async function fetchContextUsedTokens(query, logger) {
4459
5789
  return null;
4460
5790
  }
4461
5791
  }
5792
+ /** Cross-session cache of authoritative context windows, keyed by
5793
+ * `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
5794
+ * The window is a property of (model id, backend): the same resolved model id
5795
+ * (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys)
5796
+ * can name different context lanes behind different base URLs, routing
5797
+ * headers, or credentials, so the key carries both. Caching it module-level
5798
+ * lets a later session/new or switch that resolves to the same (backend,
5799
+ * model) — in this session or any other, within the adapter's lifetime — seed
5800
+ * the correct window synchronously with no IPC. Keying on the resolved id
5801
+ * (rather than the picker value) means aliases that resolve to the same
5802
+ * concrete model share one entry; the result handler additionally writes the
5803
+ * bare assistant-message spelling so seed-time reads that fall back to a
5804
+ * verbatim live id (rows without `resolvedModel`) can hit too.
5805
+ *
5806
+ * Populated authoritatively by each `result.modelUsage` a turn confirms (see
5807
+ * the consumer's result handler). We deliberately never populate it from a
5808
+ * fresh session's `getContextUsage`: before that session's first prompt turn
5809
+ * has run the control request is not serviced (it stalls ~15s, and serializes
5810
+ * ahead of an awaited `setModel` — issues #886/#880, regressed in 0.59.0), so
5811
+ * it can neither beat the first `result` nor be issued cheaply before one.
5812
+ * Resumed sessions are the exception — their report IS serviced pre-turn, and
5813
+ * the session/load path seeds (but does not cache) the window from the same
5814
+ * response that restores the live model, see `readResumedLiveModel`.
5815
+ * Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
5816
+ * windows learned under one login must not seed sessions under the next. */
5817
+ const contextWindowCache = new Map();
5818
+ /** The env vars that determine which LLM backend — and which context lane on
5819
+ * it — a query's API traffic reaches: endpoint selection (base URLs and the
5820
+ * Bedrock/Vertex switches with their project/region), routing/beta headers
5821
+ * (an `anthropic-beta: context-1m-…` header flips the same model id at the
5822
+ * same endpoint between context lanes), and credential identity (extended
5823
+ * context is entitlement-gated per account). Used to derive the
5824
+ * provider-cache key from the exact env a query is created with, so
5825
+ * `providers/set` config, per-session `_meta` env overrides, and ambient
5826
+ * process env are all distinguished exactly as the CLI will see them. */
5827
+ const PROVIDER_ROUTING_ENV_VARS = [
5828
+ "ANTHROPIC_BASE_URL",
5829
+ "ANTHROPIC_BEDROCK_BASE_URL",
5830
+ "ANTHROPIC_VERTEX_BASE_URL",
5831
+ "CLAUDE_CODE_USE_BEDROCK",
5832
+ "CLAUDE_CODE_USE_VERTEX",
5833
+ "ANTHROPIC_VERTEX_PROJECT_ID",
5834
+ "CLOUD_ML_REGION",
5835
+ "AWS_REGION",
5836
+ "ANTHROPIC_CUSTOM_HEADERS",
5837
+ "ANTHROPIC_API_KEY",
5838
+ "ANTHROPIC_AUTH_TOKEN",
5839
+ ];
5840
+ /** Stable identifier for the LLM backend a session's query is created against,
5841
+ * used to scope {@link contextWindowCache} per backend. Positional `\0`-join
5842
+ * of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade
5843
+ * as another and unset vars everywhere yield one stable "default" bucket.
5844
+ * Header/credential values can be secrets; the key only ever lives as an
5845
+ * in-memory Map key and is never logged or surfaced. Over-keying is the safe
5846
+ * side: a var change that didn't really change the backend costs one cache
5847
+ * miss (heuristic seed until the next result), while under-keying would serve
5848
+ * one backend's window for another's. */
5849
+ function providerCacheKeyFor(env) {
5850
+ return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0");
5851
+ }
5852
+ /** Compose the `contextWindowCache` key from a session's provider key and a
5853
+ * model id. `\0`-joined so the model segment can't collide with a provider
5854
+ * segment. */
5855
+ function contextWindowCacheKey(providerCacheKey, modelId) {
5856
+ return `${providerCacheKey}\0${modelId}`;
5857
+ }
5858
+ function cacheContextWindow(modelKey, window) {
5859
+ if (window > 0) {
5860
+ contextWindowCache.set(modelKey, window);
5861
+ }
5862
+ }
5863
+ /** The context window to report *right now* for a model, with NO IPC on the
5864
+ * critical path: the cached authoritative value if we've learned it (from a
5865
+ * prior turn's `result.modelUsage`, this or any session on the same backend),
5866
+ * else the text heuristic over the model row's identity strings, else the
5867
+ * default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`,
5868
+ * the same rule at every seed site — so read keys can't drift from the write
5869
+ * site's spelling. `authoritative` reports whether the value came from the
5870
+ * cache: an authoritative window can legitimately equal
5871
+ * DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */
5872
+ function immediateContextWindow(providerCacheKey, modelId, modelInfo) {
5873
+ const cached = contextWindowCache.get(contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId));
5874
+ if (cached !== undefined)
5875
+ return { size: cached, authoritative: true };
5876
+ return {
5877
+ size: inferContextWindowFromModel(modelId, modelInfo?.resolvedModel, modelInfo?.displayName, modelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
5878
+ authoritative: false,
5879
+ };
5880
+ }
4462
5881
  /** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
4463
5882
  * option. The `maxThinkingTokens` option it used to feed is deprecated and
4464
5883
  * reduced to on/off on current models, so map the value to explicit thinking
@@ -4500,6 +5919,12 @@ function getMatchingModelUsage(modelUsage, currentModel) {
4500
5919
  }
4501
5920
  }
4502
5921
  if (bestKey) {
4503
- return modelUsage[bestKey];
5922
+ // `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
5923
+ // the same spelling as ModelInfo.resolvedModel — the primary key the
5924
+ // window is cached under. `currentModel` (the assistant message's
5925
+ // `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
5926
+ // handler caches under that spelling too, for seed-time reads that fall
5927
+ // back to a bare id (rows without `resolvedModel`).
5928
+ return { key: bestKey, usage: modelUsage[bestKey] };
4504
5929
  }
4505
5930
  }