@agentclientprotocol/claude-agent-acp 0.59.0 → 0.61.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,38 @@ 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
+ /** Custom (extension) request method a client uses to steer the turn that is
52
+ * currently running: the message is injected into the in-flight turn rather
53
+ * than queued as a separate `session/prompt`. Named `_session/steering` per the
54
+ * agreed ACP steering wire protocol; advertised to clients via the top-level
55
+ * `InitializeResponse._meta.steering.supported`. */
56
+ const STEER_METHOD = "_session/steering";
57
+ /** How urgently the SDK delivers a steered message relative to the running
58
+ * turn — an internal Claude implementation detail, not part of the wire
59
+ * contract. `now` pre-empts the current generation and handles the message
60
+ * immediately (interrupting a single-shot response, or slotting in between a
61
+ * multi-step turn's tool calls). Maps to `SDKUserMessage.priority`; injected
62
+ * steering always uses `now` so the running turn adapts as soon as possible. */
63
+ const STEER_PRIORITY = "now";
64
+ /** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
65
+ * content blocks are handed to `promptToClaude`, which tolerates unknown block
66
+ * types — but `sessionId` and a non-empty `prompt` array are required. */
67
+ function parseSteerRequest(params) {
68
+ if (!params || typeof params !== "object") {
69
+ throw RequestError.invalidParams(undefined, "steer params must be an object");
70
+ }
71
+ const { sessionId, prompt } = params;
72
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
73
+ throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
74
+ }
75
+ if (!Array.isArray(prompt) || prompt.length === 0) {
76
+ throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
77
+ }
78
+ return {
79
+ sessionId,
80
+ prompt: prompt,
81
+ };
82
+ }
51
83
  /** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
52
84
  * model did on its own (a task-notification followup, a peer/coordinator/
53
85
  * observer message it handled) rather than the user's prompt. Absent,
@@ -97,6 +129,17 @@ function computeSessionFingerprint(params) {
97
129
  const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
98
130
  return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
99
131
  }
132
+ /**
133
+ * The single provider ID this agent exposes via `providers/*`. Claude Code has
134
+ * one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
135
+ * is exactly one configurable provider.
136
+ */
137
+ const PROVIDER_ID = "main";
138
+ /**
139
+ * Protocols the `main` provider can be configured with. These mirror the
140
+ * env-var mappings understood by {@link createEnvForProvider}.
141
+ */
142
+ const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
100
143
  /**
101
144
  * Advance the lexer state across the fragment appended since the last delta:
102
145
  * just enough JSON awareness (string/escape, nesting depth) to spot commas
@@ -433,6 +476,10 @@ export class ClaudeAcpAgent {
433
476
  clientCapabilities;
434
477
  logger;
435
478
  gatewayAuthRequest;
479
+ /** Client-managed LLM routing set via `providers/set`. Process-scoped and
480
+ * never persisted to disk (see the Configurable LLM Providers RFD). When
481
+ * set, it takes precedence over {@link gatewayAuthRequest}. */
482
+ providerConfig;
436
483
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
437
484
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
438
485
  * tests can shrink it. */
@@ -558,6 +605,10 @@ export class ClaudeAcpAgent {
558
605
  auth: {
559
606
  logout: {},
560
607
  },
608
+ // Client-managed LLM routing via `providers/list`, `providers/set`, and
609
+ // `providers/disable`. Advertised unconditionally; there is no client
610
+ // capability prerequisite for the provider methods.
611
+ providers: {},
561
612
  loadSession: true,
562
613
  sessionCapabilities: {
563
614
  additionalDirectories: {},
@@ -577,6 +628,15 @@ export class ClaudeAcpAgent {
577
628
  ...terminalAuthMethods,
578
629
  ...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
579
630
  ],
631
+ // Top-level `_meta` (sibling of `agentCapabilities`), per the ACP steering
632
+ // wire protocol: advertises the `_session/steering` extension request so
633
+ // clients know they may inject a follow-up into the running turn (see
634
+ // STEER_METHOD) instead of queuing it as a separate `session/prompt`.
635
+ _meta: {
636
+ steering: {
637
+ supported: true,
638
+ },
639
+ },
580
640
  };
581
641
  }
582
642
  async newSession(params) {
@@ -681,11 +741,100 @@ export class ClaudeAcpAgent {
681
741
  }
682
742
  throw new Error("Method not implemented.");
683
743
  }
744
+ /**
745
+ * `providers/list` — returns the single client-configurable custom gateway
746
+ * provider (`main`). `current` carries only non-secret routing (never headers,
747
+ * which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
748
+ * display, and is `null` when the provider is not configured/disabled. The
749
+ * provider is optional (`required: false`): while disabled/unconfigured the
750
+ * agent falls back to its own default routing (normal Claude login).
751
+ */
752
+ async unstable_listProviders(_params) {
753
+ const config = this.resolveProviderConfig();
754
+ const provider = {
755
+ providerId: PROVIDER_ID,
756
+ supported: SUPPORTED_PROTOCOLS,
757
+ required: false,
758
+ current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
759
+ };
760
+ return { providers: [provider] };
761
+ }
762
+ /**
763
+ * `providers/set` — replace the full configuration for the `main` provider.
764
+ * Rejects unknown IDs, unsupported protocols, and empty/invalid base URLs with
765
+ * `invalid_params`. Config is process-scoped and applies to sessions created or
766
+ * loaded after this call.
767
+ */
768
+ async unstable_setProvider(params) {
769
+ if (params.providerId !== PROVIDER_ID) {
770
+ throw RequestError.invalidParams({ providerId: params.providerId }, `Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`);
771
+ }
772
+ if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
773
+ throw RequestError.invalidParams({ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS }, `Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`);
774
+ }
775
+ if (!isValidBaseUrl(params.baseUrl)) {
776
+ throw RequestError.invalidParams({ baseUrl: params.baseUrl }, "baseUrl must be a non-empty absolute http(s) URL.");
777
+ }
778
+ const config = {
779
+ apiType: params.apiType,
780
+ baseUrl: params.baseUrl,
781
+ headers: params.headers ?? {},
782
+ };
783
+ // Vertex requires project + region, which the standard payload cannot
784
+ // carry, so they arrive via `_meta.claudeCode.vertex`.
785
+ if (params.apiType === "vertex") {
786
+ const vertex = params._meta?.claudeCode?.vertex;
787
+ if (!vertex ||
788
+ typeof vertex.projectId !== "string" ||
789
+ vertex.projectId.trim() === "" ||
790
+ typeof vertex.region !== "string" ||
791
+ vertex.region.trim() === "") {
792
+ throw RequestError.invalidParams(undefined, "vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.");
793
+ }
794
+ config.vertex = { projectId: vertex.projectId, region: vertex.region };
795
+ }
796
+ this.providerConfig = config;
797
+ return {};
798
+ }
799
+ /**
800
+ * `providers/disable` — disabling the `main` provider clears any client-managed
801
+ * routing (both a `providers/set` config and the legacy gateway auth request),
802
+ * so the agent reverts to its own default routing and `providers/list` reports
803
+ * `current: null`. Disabling any other (unknown) ID is treated as a successful
804
+ * no-op per the RFD's idempotency rule.
805
+ */
806
+ async unstable_disableProvider(params) {
807
+ if (params.providerId === PROVIDER_ID) {
808
+ this.providerConfig = undefined;
809
+ this.gatewayAuthRequest = undefined;
810
+ }
811
+ // Unknown provider: idempotent success.
812
+ return {};
813
+ }
814
+ /**
815
+ * Resolve the effective client-managed routing config. `providers/set` takes
816
+ * precedence; otherwise fall back to the legacy gateway auth request. Returns
817
+ * `null` when neither is configured.
818
+ */
819
+ resolveProviderConfig() {
820
+ if (this.providerConfig) {
821
+ return this.providerConfig;
822
+ }
823
+ return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
824
+ }
684
825
  async logout(_params) {
685
- // Clear in-memory gateway credentials supplied via `authenticate`. The
686
- // gateway method never touches the on-disk credential store, so dropping
687
- // this reference is the whole logout for that path.
826
+ // Clear in-memory gateway credentials supplied via `authenticate` and any
827
+ // provider routing set via `providers/set`. Neither touches the on-disk
828
+ // credential store, so dropping these references is the whole logout for
829
+ // those paths.
688
830
  this.gatewayAuthRequest = undefined;
831
+ this.providerConfig = undefined;
832
+ // Learned context windows are per-account state too: 1M-context
833
+ // entitlement is gated per org/tier, and an OAuth re-login is invisible to
834
+ // the env-derived provider cache key, so windows learned under the old
835
+ // login must not seed sessions under the next. Worst case of clearing is
836
+ // re-learning on each model's next turn.
837
+ contextWindowCache.clear();
689
838
  // For the Claude/Console login methods the credentials live in the native
690
839
  // CLI's store (keychain or config dir), which only the binary can clear.
691
840
  // `claude auth logout` is non-interactive and idempotent.
@@ -739,6 +888,67 @@ export class ClaudeAcpAgent {
739
888
  this.ensureConsumer(session, params.sessionId);
740
889
  return response;
741
890
  }
891
+ /** Steer the session per the ACP steering wire protocol: apply a follow-up
892
+ * message to the turn that is currently running, or — if that turn already
893
+ * finished — start a fresh turn with it. Never drops the message and never
894
+ * returns a JSON-RPC error for the "arrived too late" race; both paths are
895
+ * success outcomes (see {@link SteerOutcome}).
896
+ *
897
+ * When a turn is in flight this injects (returns `injected`): unlike
898
+ * `prompt()`, it does NOT create a Turn or enqueue on `turnQueue`; it pushes
899
+ * an `SDKUserMessage` onto the same streaming input, which the SDK routes
900
+ * into the in-flight turn. The injected message's echo carries a uuid that
901
+ * matches no queued turn, so the consumer drops it as an unrelated replay
902
+ * without promoting/settling anything. It is delivered at {@link
903
+ * STEER_PRIORITY} (`now`) so it pre-empts the current generation (interrupting
904
+ * a single-shot response, or slotting in between a multi-step turn's tool
905
+ * calls). The steered message's own output streams via `session/update`, not
906
+ * this response.
907
+ *
908
+ * When the session is idle (no unsettled turn — the turn we meant to steer
909
+ * raced ahead and finished), this starts a normal new turn with the message
910
+ * and returns `startedNewTurn`. That turn is fire-and-forget from the steer
911
+ * request's view: its `PromptResponse` and output flow through the usual
912
+ * `prompt()`/`session/update` path, so we return the outcome immediately
913
+ * rather than awaiting turn completion. */
914
+ async steer(params) {
915
+ const sessionId = params.sessionId;
916
+ const prompt = params.prompt;
917
+ const session = this.sessions[sessionId];
918
+ if (!session) {
919
+ throw new Error("Session not found");
920
+ }
921
+ if (session.queryClosed) {
922
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
923
+ }
924
+ // "A turn is running" = the queue holds an unsettled turn. This covers both
925
+ // the activated turn and one just submitted but not yet echoed/activated,
926
+ // which is exactly the window in which steering is meaningful.
927
+ const turnInFlight = (session.turnQueue ?? []).some((t) => !t.settled);
928
+ const promptRequest = {
929
+ sessionId: sessionId,
930
+ prompt: prompt,
931
+ };
932
+ if (!turnInFlight) {
933
+ // Race: the turn we meant to steer already finished. Per the protocol the
934
+ // message must not be dropped nor surfaced as an error — start a fresh
935
+ // turn with it. Don't await: the new turn streams via session/update and
936
+ // its PromptResponse is consumed by the normal prompt() path; we only owe
937
+ // the client the outcome. `.catch` keeps the detached promise from
938
+ // becoming an unhandled rejection.
939
+ this.prompt(promptRequest).catch((error) => {
940
+ this.logger.error(`Session ${sessionId}: steered new turn failed: ${error}`);
941
+ });
942
+ return { outcome: "startedNewTurn" };
943
+ }
944
+ const userMessage = promptToClaude(promptRequest);
945
+ userMessage.uuid = randomUUID();
946
+ // Deliver into the running turn rather than queuing behind it as a fresh
947
+ // prompt would.
948
+ userMessage.priority = STEER_PRIORITY;
949
+ session.input.push(userMessage);
950
+ return { outcome: "injected" };
951
+ }
742
952
  /** Lazily start the per-session consumer that drains the SDK query stream for
743
953
  * the session's whole life. Idempotent: only the first `prompt()` starts it. */
744
954
  ensureConsumer(session, sessionId) {
@@ -1592,9 +1802,9 @@ export class ClaudeAcpAgent {
1592
1802
  // Push the full slash-command list after a mid-session change
1593
1803
  // (e.g. skills discovered dynamically as the agent works in a
1594
1804
  // subdirectory). The client should REPLACE its cached command
1595
- // list with this payload: supportedCommands() is captured once
1596
- // at initialize and never reflects mid-session changes, so we
1597
- // forward message.commands directly rather than re-querying.
1805
+ // list with this payload. Forward message.commands directly —
1806
+ // it's authoritative, and re-querying supportedCommands()
1807
+ // would just return the same list with an extra round-trip.
1598
1808
  await sendUpdate({
1599
1809
  sessionId: message.session_id,
1600
1810
  update: {
@@ -1943,12 +2153,35 @@ export class ClaudeAcpAgent {
1943
2153
  const matchingModelUsage = lastAssistantModel
1944
2154
  ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
1945
2155
  : null;
1946
- // Only overwrite when we have an authoritative value — a miss
1947
- // (e.g. a turn with no top-level assistant message) would
1948
- // otherwise discard the window learned on a prior turn and
1949
- // leave the next prompt's mid-stream updates reporting 200k.
1950
- if (matchingModelUsage) {
1951
- session.contextWindowSize = matchingModelUsage.contextWindow;
2156
+ // Only overwrite when we have an authoritative, sane value. A miss
2157
+ // (e.g. a turn with no top-level assistant message), or a
2158
+ // nonsensical non-positive/NaN window (observed from third-party
2159
+ // backends), would otherwise discard the window learned on a prior
2160
+ // turn and leave the next prompt's mid-stream updates reporting a
2161
+ // wrong size. `cacheContextWindow` applies the same `> 0` guard, so
2162
+ // a bad value never reaches the cross-session cache either.
2163
+ if (matchingModelUsage &&
2164
+ typeof matchingModelUsage.usage.contextWindow === "number" &&
2165
+ matchingModelUsage.usage.contextWindow > 0) {
2166
+ session.contextWindowSize = matchingModelUsage.usage.contextWindow;
2167
+ session.contextWindowAuthoritative = true;
2168
+ // Authoritative: fold it into the cross-session cache keyed on
2169
+ // (this session's provider, the resolved model id —
2170
+ // matchingModelUsage.key, e.g. "claude-sonnet-5[1m]") so a later
2171
+ // session/new or switch on the same provider that resolves to
2172
+ // this model seeds the correct window synchronously, with no
2173
+ // getContextUsage IPC.
2174
+ cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, matchingModelUsage.key), matchingModelUsage.usage.contextWindow);
2175
+ // Also cache under the assistant message's own (bare) spelling.
2176
+ // Seed-time reads fall back to a picker value / verbatim live id
2177
+ // when a row carries no resolvedModel (the synthesized
2178
+ // out-of-allowlist resume row sets it undefined on purpose), and
2179
+ // those spellings match `.model` from the assistant message, not
2180
+ // the decorated modelUsage key — without this entry such rows
2181
+ // could never hit the cache.
2182
+ if (lastAssistantModel && lastAssistantModel !== matchingModelUsage.key) {
2183
+ cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, lastAssistantModel), matchingModelUsage.usage.contextWindow);
2184
+ }
1952
2185
  }
1953
2186
  // Send usage_update notification
1954
2187
  if (lastAssistantTotalUsage !== null) {
@@ -2191,11 +2424,16 @@ export class ClaudeAcpAgent {
2191
2424
  const model = message.event.message.model;
2192
2425
  if (model && model !== "<synthetic>") {
2193
2426
  lastAssistantModel = model;
2194
- // Only upgrade from the default — once the SDK has given us
2195
- // an authoritative window (seeded at session creation,
2196
- // refreshed on model switches in `applyConfigOptionValue`,
2197
- // confirmed by each `result`), trust it over the heuristic.
2198
- if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
2427
+ // Only upgrade from the heuristic default — once we have an
2428
+ // authoritative window (cache-seeded at session creation or
2429
+ // on a model switch, read from the resumed session on
2430
+ // session/load, confirmed by each `result`), trust it over
2431
+ // the heuristic. The flag, not the value, is the sentinel: an
2432
+ // authoritative window can legitimately equal
2433
+ // DEFAULT_CONTEXT_WINDOW (e.g. a backend serving a 200k lane
2434
+ // under a "[1m]"-spelled id) and must not be clobbered.
2435
+ if (!session.contextWindowAuthoritative &&
2436
+ session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
2199
2437
  const inferred = inferContextWindowFromModel(model);
2200
2438
  if (inferred !== null) {
2201
2439
  session.contextWindowSize = inferred;
@@ -2471,6 +2709,11 @@ export class ClaudeAcpAgent {
2471
2709
  emittedToolCalls: session.emittedToolCalls,
2472
2710
  messageId: messageIdForGrouping(message),
2473
2711
  toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
2712
+ // On the wire since CLI 2.1.216 but not in SDKUserMessage's
2713
+ // type, hence the cast. Validated by parseToolResultMeta.
2714
+ toolResultMeta: message.type === "user"
2715
+ ? message.tool_result_meta
2716
+ : undefined,
2474
2717
  })) {
2475
2718
  // sendUpdate records delivery. Subagent text/thinking is
2476
2719
  // filtered out of `content` above; blocks that do pass through
@@ -2490,7 +2733,20 @@ export class ClaudeAcpAgent {
2490
2733
  _meta: {
2491
2734
  claudeCode: {
2492
2735
  toolName: message.tool_name,
2493
- toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
2736
+ toolResponse: {
2737
+ elapsedTimeSeconds: message.elapsed_time_seconds,
2738
+ // For Agent/Task calls: the subagent's type, and — when
2739
+ // the subagent is waiting out an API rate-limit retry —
2740
+ // the SDK's retry counters (attempt, max_retries,
2741
+ // retry_delay_ms, …), forwarded verbatim so clients can
2742
+ // show why a spawn looks stalled.
2743
+ ...(message.subagent_type !== undefined && {
2744
+ subagentType: message.subagent_type,
2745
+ }),
2746
+ ...(message.subagent_retry !== undefined && {
2747
+ subagentRetry: message.subagent_retry,
2748
+ }),
2749
+ },
2494
2750
  },
2495
2751
  },
2496
2752
  },
@@ -3090,7 +3346,7 @@ export class ClaudeAcpAgent {
3090
3346
  await this.client.sessionUpdate({ sessionId, update });
3091
3347
  }
3092
3348
  canUseTool(sessionId) {
3093
- return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID }) => {
3349
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
3094
3350
  const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
3095
3351
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
3096
3352
  const session = this.sessions[sessionId];
@@ -3198,7 +3454,14 @@ export class ClaudeAcpAgent {
3198
3454
  };
3199
3455
  }
3200
3456
  }
3201
- if (session.modes.currentModeId === "bypassPermissions") {
3457
+ // In bypass mode the CLI skips permission checks itself; the asks that
3458
+ // still reach canUseTool are the ones it insists on prompting for even
3459
+ // under --dangerously-skip-permissions. Keep auto-allowing those —
3460
+ // bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
3461
+ // user explicitly configured a permissions.ask rule for this tool, and
3462
+ // the SDK's guidance is that hosts running auto-approval must treat such
3463
+ // asks as a human prompt. Fall through to the normal request below.
3464
+ if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
3202
3465
  return {
3203
3466
  behavior: "allow",
3204
3467
  updatedInput: toolInput,
@@ -3404,17 +3667,21 @@ export class ClaudeAcpAgent {
3404
3667
  // carries no "1m" token.
3405
3668
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
3406
3669
  if (session.models.currentModelId !== value) {
3407
- // The cached context window was learned for the previous model. The
3408
- // SDK is already running the new model here (user-driven switches call
3409
- // `query.setModel` before this, and the refusal-fallback sync only
3410
- // reconciles a switch the SDK already made), so ask it for the new
3411
- // window; fall back to the text heuristic so mid-stream updates
3412
- // between now and the next `result` reflect the user's selection
3413
- // instead of the old model's window.
3414
- session.contextWindowSize =
3415
- (await fetchContextWindowSize(session.query, this.logger)) ??
3416
- inferContextWindowFromModel(value, newModelInfo?.resolvedModel, newModelInfo?.displayName, newModelInfo?.description) ??
3417
- DEFAULT_CONTEXT_WINDOW;
3670
+ // Seed the new model's context window WITHOUT any IPC on the switch
3671
+ // path: cached authoritative value if we've already learned it (from a
3672
+ // prior turn's `result.modelUsage`), else the text heuristic, else the
3673
+ // default. We deliberately do NOT call `getContextUsage` here — before
3674
+ // a fresh session's first prompt turn that control request is not
3675
+ // serviced (~15s stall, issues #886/#880), and (because SDK control
3676
+ // requests are serialized over one channel) it would drag the awaited
3677
+ // `setModel` down with it. The authoritative window arrives on the
3678
+ // first `result.modelUsage` for the model and is cached from there;
3679
+ // until then a switched-to alias that has never run a turn shows the
3680
+ // heuristic/default window, which self-corrects on its first response
3681
+ // (matches pre-0.59.0 behavior).
3682
+ const seeded = immediateContextWindow(session.providerCacheKey, value, newModelInfo);
3683
+ session.contextWindowSize = seeded.size;
3684
+ session.contextWindowAuthoritative = seeded.authoritative;
3418
3685
  }
3419
3686
  session.models = { ...session.models, currentModelId: value };
3420
3687
  // Recompute availableModes for the new model and clamp the current
@@ -3746,6 +4013,28 @@ export class ClaudeAcpAgent {
3746
4013
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
3747
4014
  // the same Map that the streaming message handler will read from.
3748
4015
  const taskState = new Map();
4016
+ // The exact env the query will be created with. Built (and the provider
4017
+ // cache key derived from it, below) in one place so the key always
4018
+ // describes the backend this query actually talks to: `providers/set`,
4019
+ // `providers/disable`, and `logout` mutate the process-wide provider
4020
+ // config concurrently, so re-resolving it after any of the awaits between
4021
+ // here and the session registration could disagree with the env baked
4022
+ // into the query.
4023
+ const env = {
4024
+ ...process.env,
4025
+ ...userProvidedOptions?.env,
4026
+ // Client-managed LLM routing: `providers/set` config wins, else the
4027
+ // legacy gateway auth request. Baked into the query at creation, so it
4028
+ // only affects sessions started after the change (matching the RFD).
4029
+ ...createEnvForProvider(this.resolveProviderConfig()),
4030
+ // Opt-in to session state events like when the agent is idle
4031
+ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
4032
+ };
4033
+ // Scopes the context-window cache to this query's backend (see
4034
+ // `contextWindowCache`). Derived from the same `env` object handed to the
4035
+ // SDK, so per-session `_meta` env routing and ambient process-env routing
4036
+ // are distinguished exactly as the CLI will see them.
4037
+ const providerCacheKey = providerCacheKeyFor(env);
3749
4038
  const options = {
3750
4039
  systemPrompt,
3751
4040
  settingSources: ["user", "project", "local"],
@@ -3762,13 +4051,7 @@ export class ClaudeAcpAgent {
3762
4051
  ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
3763
4052
  },
3764
4053
  }),
3765
- env: {
3766
- ...process.env,
3767
- ...userProvidedOptions?.env,
3768
- ...createEnvForGateway(this.gatewayAuthRequest),
3769
- // Opt-in to session state events like when the agent is idle
3770
- CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
3771
- },
4054
+ env,
3772
4055
  // Override certain fields that must be controlled by ACP
3773
4056
  cwd: params.cwd,
3774
4057
  includePartialMessages: true,
@@ -3810,7 +4093,7 @@ export class ClaudeAcpAgent {
3810
4093
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
3811
4094
  {
3812
4095
  hooks: [
3813
- createPostToolUseHook(this.logger, {
4096
+ createPostToolUseHook({
3814
4097
  onEnterPlanMode: async () => {
3815
4098
  await this.client.sessionUpdate({
3816
4099
  sessionId,
@@ -3916,7 +4199,7 @@ export class ClaudeAcpAgent {
3916
4199
  const allowedModels = Array.isArray(settingsAvailableModels)
3917
4200
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
3918
4201
  : initializationResult.models;
3919
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
4202
+ const { modelState: models, resumedContextWindow } = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
3920
4203
  // Gate `auto` (and future model-specific modes) on the resolved model's
3921
4204
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
3922
4205
  // A resumed session can be running a model outside the `availableModels`
@@ -4012,25 +4295,39 @@ export class ClaudeAcpAgent {
4012
4295
  typeof initialEffort.currentValue === "string" &&
4013
4296
  initialEffort.currentValue !== "default") {
4014
4297
  await q.applyFlagSettings({
4015
- effortLevel: initialEffort.currentValue,
4298
+ effortLevel: toSdkEffortLevel(initialEffort.currentValue),
4016
4299
  });
4017
4300
  }
4018
- // Seed the context window from the SDK's authoritative report. Text
4019
- // inference alone misses aliases that resolve to extended-context models
4020
- // with no "1m" token anywhere in their id or description (e.g. `sonnet` →
4021
- // claude-sonnet-5, natively ~1M): those streamed `usage_update.size:
4022
- // 200000` until the first result's modelUsage corrected it — again on
4023
- // every process restart or session re-creation, since the learned window
4024
- // lives only on the Session (issue #596).
4301
+ // Seed the context window WITHOUT any extra IPC on the session/new path.
4302
+ // On session/load, the resumed session's own `getContextUsage` report — a
4303
+ // response `getAvailableModels` already awaited to learn the live model
4304
+ // (resumed sessions ARE serviced pre-turn, unlike fresh ones) — is
4305
+ // authoritative and wins. Otherwise: the cached authoritative window if a
4306
+ // prior turn has learned it for this model (`result.modelUsage`,
4307
+ // cross-session), else the text heuristic, else the default. We
4308
+ // deliberately do NOT issue a getContextUsage call here: on a fresh
4309
+ // session that control request is not serviced until the first prompt
4310
+ // turn runs, so awaiting it — as 0.59.0 did — made session/new take ~15s
4311
+ // (issues #886/#880). The authoritative window arrives on the first
4312
+ // `result.modelUsage` and is cached from there.
4313
+ //
4314
+ // Text inference alone misses aliases that resolve to extended-context
4315
+ // models with no "1m" token anywhere in their id or description (e.g.
4316
+ // `sonnet` → claude-sonnet-5, natively ~1M): those stream
4317
+ // `usage_update.size: 200000` until the first result's modelUsage corrects
4318
+ // it — but the cache means only the FIRST session to ever run a turn on such
4319
+ // a model eats that window, not every fresh session after a process
4320
+ // restart (issue #596; a post-restart session/load is covered by the
4321
+ // resumed report above).
4025
4322
  //
4026
4323
  // The inference fallback is deliberately keyed to the allowlisted entry: a
4027
4324
  // fallback-resolved sibling's resolvedModel/displayName/description can
4028
4325
  // describe a different context lane than the verbatim live id (e.g. an
4029
4326
  // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
4030
4327
  // the id itself is a trustworthy window signal.
4031
- const contextWindowSize = (await fetchContextWindowSize(q, this.logger)) ??
4032
- inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.resolvedModel, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ??
4033
- DEFAULT_CONTEXT_WINDOW;
4328
+ const seededWindow = resumedContextWindow !== null
4329
+ ? { size: resumedContextWindow, authoritative: true }
4330
+ : immediateContextWindow(providerCacheKey, models.currentModelId, allowlistedModelInfo);
4034
4331
  this.sessions[sessionId] = {
4035
4332
  query: q,
4036
4333
  input: input,
@@ -4053,7 +4350,9 @@ export class ClaudeAcpAgent {
4053
4350
  fastModeEnabled,
4054
4351
  abortController,
4055
4352
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
4056
- contextWindowSize,
4353
+ contextWindowSize: seededWindow.size,
4354
+ contextWindowAuthoritative: seededWindow.authoritative,
4355
+ providerCacheKey,
4057
4356
  taskState,
4058
4357
  toolUseCache: {},
4059
4358
  emittedToolCalls: new Set(),
@@ -4129,27 +4428,76 @@ function snapshotFromUsage(usage) {
4129
4428
  cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
4130
4429
  };
4131
4430
  }
4132
- function createEnvForGateway(request) {
4431
+ /**
4432
+ * Adapt a legacy gateway `authenticate` request into the shared
4433
+ * {@link ProviderConfig} shape. Returns `null` when no gateway request is
4434
+ * present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
4435
+ * otherwise anthropic.
4436
+ */
4437
+ function gatewayRequestToProviderConfig(request) {
4133
4438
  if (!request?._meta) {
4439
+ return null;
4440
+ }
4441
+ return {
4442
+ apiType: request.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
4443
+ baseUrl: request._meta.gateway.baseUrl,
4444
+ headers: request._meta.gateway.headers,
4445
+ };
4446
+ }
4447
+ /**
4448
+ * Map a resolved provider config into the Claude Code env vars that redirect API
4449
+ * traffic and inject headers. Returns an empty object when routing is
4450
+ * unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
4451
+ * skips its normal login/credential checks when a gateway is in use.
4452
+ */
4453
+ function createEnvForProvider(config) {
4454
+ if (!config) {
4134
4455
  return {};
4135
4456
  }
4136
- const customHeaders = Object.entries(request._meta.gateway.headers)
4457
+ const customHeaders = Object.entries(config.headers)
4137
4458
  .map(([key, value]) => `${key}: ${value}`)
4138
4459
  .join("\n");
4139
- if (request.methodId === "gateway-bedrock") {
4460
+ if (config.apiType === "bedrock") {
4140
4461
  return {
4141
4462
  CLAUDE_CODE_USE_BEDROCK: "1",
4142
4463
  AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
4143
- ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
4464
+ ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
4465
+ ANTHROPIC_CUSTOM_HEADERS: customHeaders,
4466
+ };
4467
+ }
4468
+ if (config.apiType === "vertex") {
4469
+ // `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
4470
+ // validation; fall back to empty strings defensively.
4471
+ return {
4472
+ CLAUDE_CODE_USE_VERTEX: "1",
4473
+ ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
4474
+ ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
4475
+ CLOUD_ML_REGION: config.vertex?.region ?? "",
4144
4476
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
4145
4477
  };
4146
4478
  }
4147
4479
  return {
4148
- ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
4480
+ ANTHROPIC_BASE_URL: config.baseUrl,
4149
4481
  ANTHROPIC_CUSTOM_HEADERS: customHeaders,
4150
4482
  ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
4151
4483
  };
4152
4484
  }
4485
+ /**
4486
+ * Validate a provider base URL: must be a non-empty absolute http(s) URL.
4487
+ */
4488
+ function isValidBaseUrl(baseUrl) {
4489
+ if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
4490
+ return false;
4491
+ }
4492
+ let parsed;
4493
+ try {
4494
+ parsed = new URL(baseUrl);
4495
+ }
4496
+ catch {
4497
+ return false;
4498
+ }
4499
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
4500
+ }
4153
4501
  /**
4154
4502
  * Build the list of permission modes the agent will advertise for the given
4155
4503
  * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
@@ -4199,7 +4547,10 @@ function buildAvailableModes(modelInfo) {
4199
4547
  // and only clears a key when an explicit `null` is sent — see
4200
4548
  // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
4201
4549
  // `"default"` sentinel and `undefined` (effort option absent for the model) to
4202
- // `null` ensures any previously-applied flag is actually cleared.
4550
+ // `null` ensures any previously-applied flag is actually cleared. Typed as
4551
+ // `EffortLevel` (not `Settings["effortLevel"]`): the picker offers whatever
4552
+ // `supportedEffortLevels` reports, which includes the session-scoped `"max"`
4553
+ // that the persisted Settings shape deliberately excludes.
4203
4554
  function toSdkEffortLevel(value) {
4204
4555
  return value === undefined || value === "default" ? null : value;
4205
4556
  }
@@ -4651,23 +5002,37 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
4651
5002
  }
4652
5003
  /** Read the model a resumed session is actually running (via the
4653
5004
  * `getContextUsage` control request — the same source `/context` prints) and
4654
- * map it onto the picker. Best-effort: a control-request failure is logged
4655
- * and returns null so callers keep their current choice; failing the whole
4656
- * session/load over an unreadable report would be worse. */
5005
+ * map it onto the picker, along with the report's authoritative context
5006
+ * window (`rawMaxTokens`). Resumed sessions get this request serviced before
5007
+ * any turn runs in the new process — unlike fresh sessions, where it stalls
5008
+ * until the first prompt turn (issues #886/#880) — so the same response that
5009
+ * restores the live model (issue #845) also seeds the window for free,
5010
+ * covering post-restart reloads of models the text heuristic misses (issue
5011
+ * #596). Best-effort: a control-request failure is logged and returns nulls
5012
+ * so callers keep their current choice; failing the whole session/load over
5013
+ * an unreadable report would be worse. */
4657
5014
  async function readResumedLiveModel(query, models, logger) {
4658
5015
  try {
4659
- const liveModel = (await query.getContextUsage()).model;
4660
- return liveModel ? matchResumedModel(models, liveModel) : null;
5016
+ const usage = await query.getContextUsage();
5017
+ return {
5018
+ model: usage.model ? matchResumedModel(models, usage.model) : null,
5019
+ contextWindow: usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null,
5020
+ };
4661
5021
  }
4662
5022
  catch (error) {
4663
5023
  logger.error("Failed to read the resumed session's live model:", error);
4664
- return null;
5024
+ return { model: null, contextWindow: null };
4665
5025
  }
4666
5026
  }
4667
5027
  async function getAvailableModels(query, models, sdkModels, settingsManager, logger, isResumedSession) {
4668
5028
  const settings = settingsManager.getSettings();
4669
5029
  let currentModel = models[0];
4670
5030
  let resolvedFromInput;
5031
+ // The context window reported alongside a resumed session's live model.
5032
+ // Only ever non-null on the paths where `currentModel` IS the live model
5033
+ // (no override, or a failed override re-assert), so the window always
5034
+ // describes the model the session actually runs.
5035
+ let resumedContextWindow = null;
4671
5036
  // Model priority (highest to lowest):
4672
5037
  // 1. ANTHROPIC_MODEL environment variable
4673
5038
  // 2. settings.model (user configuration)
@@ -4695,7 +5060,9 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
4695
5060
  // the SDK is already running this model, and pushing a picker alias back
4696
5061
  // (e.g. "opus[1m]") could change the live model rather than describe it.
4697
5062
  if (resolvedFromInput === undefined && isResumedSession) {
4698
- currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
5063
+ const live = await readResumedLiveModel(query, models, logger);
5064
+ currentModel = live.model ?? currentModel;
5065
+ resumedContextWindow = live.contextWindow;
4699
5066
  }
4700
5067
  // Skip the setModel round-trip when we can prove the SDK has already landed
4701
5068
  // on the same model. Two cases qualify:
@@ -4728,16 +5095,21 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
4728
5095
  if (!isResumedSession)
4729
5096
  throw error;
4730
5097
  logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
4731
- currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
5098
+ const live = await readResumedLiveModel(query, models, logger);
5099
+ currentModel = live.model ?? currentModel;
5100
+ resumedContextWindow = live.contextWindow;
4732
5101
  }
4733
5102
  }
4734
5103
  return {
4735
- availableModels: models.map((model) => ({
4736
- modelId: model.value,
4737
- name: model.displayName,
4738
- description: model.description,
4739
- })),
4740
- currentModelId: currentModel.value,
5104
+ modelState: {
5105
+ availableModels: models.map((model) => ({
5106
+ modelId: model.value,
5107
+ name: model.displayName,
5108
+ description: model.description,
5109
+ })),
5110
+ currentModelId: currentModel.value,
5111
+ },
5112
+ resumedContextWindow,
4741
5113
  };
4742
5114
  }
4743
5115
  function getAvailableSlashCommands(commands) {
@@ -4864,6 +5236,12 @@ export function promptToClaude(prompt) {
4864
5236
  },
4865
5237
  session_id: prompt.sessionId,
4866
5238
  parent_tool_use_id: null,
5239
+ // ACP prompts are the user's own input relayed by the client. Stamp the
5240
+ // provenance explicitly: per the SDK, a host wrapping keyboard input must
5241
+ // send `{kind: "human"}` — an absent `origin` is treated as unattributed
5242
+ // and fails closed at the CLI's strict isHuman() trust gates (e.g. the
5243
+ // ultracode keyword opt-in honors only human-originated turns).
5244
+ origin: { kind: "human" },
4867
5245
  };
4868
5246
  }
4869
5247
  /**
@@ -4973,6 +5351,33 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
4973
5351
  ...(locations ? { locations } : {}),
4974
5352
  };
4975
5353
  }
5354
+ /** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
5355
+ * wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
5356
+ * by-tool_use_id lookup. Each entry explains why an is_error tool_result
5357
+ * carries harness prose instead of the tool's own output — "user-rejected",
5358
+ * "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
5359
+ * on the wire ahead of schema updates, so no enum check). Malformed entries
5360
+ * are skipped rather than failing the message. */
5361
+ function parseToolResultMeta(raw) {
5362
+ if (!Array.isArray(raw)) {
5363
+ return undefined;
5364
+ }
5365
+ let byToolUseId;
5366
+ for (const entry of raw) {
5367
+ if (typeof entry !== "object" || entry === null) {
5368
+ continue;
5369
+ }
5370
+ const { id, non_execution_kind, user_feedback } = entry;
5371
+ if (typeof id !== "string" || typeof non_execution_kind !== "string") {
5372
+ continue;
5373
+ }
5374
+ (byToolUseId ??= new Map()).set(id, {
5375
+ nonExecutionKind: non_execution_kind,
5376
+ ...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
5377
+ });
5378
+ }
5379
+ return byToolUseId;
5380
+ }
4976
5381
  /**
4977
5382
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
4978
5383
  * Only handles text, image, and thinking chunks for now.
@@ -5013,6 +5418,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
5013
5418
  .length === 1
5014
5419
  ? options.toolUseResult
5015
5420
  : undefined;
5421
+ // Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
5422
+ // messages need no single-block guard.
5423
+ const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
5016
5424
  const output = [];
5017
5425
  // Only handle the first chunk for streaming; extend as needed for batching
5018
5426
  for (const chunk of content) {
@@ -5156,6 +5564,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
5156
5564
  case "mcp_tool_result": {
5157
5565
  const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
5158
5566
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
5567
+ // Why this is_error result carries harness prose instead of tool
5568
+ // output (user-rejected / interrupted / …), when the SDK said so.
5569
+ // Spread into the claudeCode meta of every update emitted below; the
5570
+ // untracked-tool fallback can't carry it (claudeCode metas always
5571
+ // carry `toolName`, which is unknown there).
5572
+ const nonExecution = toolResultMeta?.get(chunk.tool_use_id);
5159
5573
  const toolUse = toolUseCache[chunk.tool_use_id];
5160
5574
  if (!toolUse) {
5161
5575
  // The permission flow may have surfaced this tool_call even though
@@ -5195,6 +5609,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
5195
5609
  _meta: {
5196
5610
  claudeCode: {
5197
5611
  toolName: toolUse.name,
5612
+ ...(nonExecution ?? {}),
5198
5613
  ...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
5199
5614
  },
5200
5615
  },
@@ -5255,6 +5670,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
5255
5670
  _meta: {
5256
5671
  claudeCode: {
5257
5672
  toolName: toolUse.name,
5673
+ ...(nonExecution ?? {}),
5258
5674
  },
5259
5675
  ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
5260
5676
  },
@@ -5463,9 +5879,13 @@ export function runAcp() {
5463
5879
  .onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
5464
5880
  .onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
5465
5881
  .onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
5882
+ .onRequest(methods.agent.providers.list, (ctx) => agent.unstable_listProviders(ctx.params))
5883
+ .onRequest(methods.agent.providers.set, (ctx) => agent.unstable_setProvider(ctx.params))
5884
+ .onRequest(methods.agent.providers.disable, (ctx) => agent.unstable_disableProvider(ctx.params))
5466
5885
  .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
5467
5886
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
5468
5887
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
5888
+ .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
5469
5889
  .connect(stream);
5470
5890
  agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
5471
5891
  return { connection, agent };
@@ -5477,9 +5897,9 @@ function commonPrefixLength(a, b) {
5477
5897
  }
5478
5898
  return i;
5479
5899
  }
5480
- /** Best-effort first guess of a model's context window, used only as a
5481
- * fallback when the SDK's authoritative `getContextUsage` is unavailable (and
5482
- * until a `result` message arrives with the `modelUsage` value).
5900
+ /** Best-effort first guess of a model's context window, used to seed the
5901
+ * window synchronously (via `immediateContextWindow`) until a `result` message
5902
+ * arrives with the authoritative `modelUsage` value.
5483
5903
  *
5484
5904
  * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
5485
5905
  * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
@@ -5489,9 +5909,12 @@ function commonPrefixLength(a, b) {
5489
5909
  * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
5490
5910
  * This text scan can't catch every model — some resolve to extended-context
5491
5911
  * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
5492
- * ~1M) — which is why `fetchContextWindowSize` is preferred wherever a live
5493
- * query is available. A miss falls back to the default window and is
5494
- * corrected by `result.modelUsage` within one turn. */
5912
+ * ~1M). Such a miss falls back to the default window and is corrected by
5913
+ * `result.modelUsage` (and cached) within one turn. We do NOT consult the
5914
+ * SDK's `getContextUsage` to close that gap: on a fresh session it is not
5915
+ * serviced before the first prompt turn (issues #886/#880, see
5916
+ * `contextWindowCache`; resumed sessions do get it, via
5917
+ * `readResumedLiveModel`). */
5495
5918
  function inferContextWindowFromModel(...texts) {
5496
5919
  if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
5497
5920
  return 1_000_000;
@@ -5512,28 +5935,95 @@ async function fetchContextUsedTokens(query, logger) {
5512
5935
  return null;
5513
5936
  }
5514
5937
  }
5515
- /** Fetch the current model's full context window (`rawMaxTokens`) via the
5516
- * `getContextUsage` control request — the same source `/context` prints.
5517
- * This is the only pre-`result` signal that covers semantic aliases whose
5518
- * text carries no "1m" token (e.g. `sonnet` → claude-sonnet-5, natively
5519
- * ~1M), so it's the primary window source at session creation and on model
5520
- * switches; `inferContextWindowFromModel` remains the fallback. A returned
5521
- * window is still superseded by each `result.modelUsage.contextWindow`.
5938
+ /** Cross-session cache of authoritative context windows, keyed by
5939
+ * `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
5940
+ * The window is a property of (model id, backend): the same resolved model id
5941
+ * (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys)
5942
+ * can name different context lanes behind different base URLs, routing
5943
+ * headers, or credentials, so the key carries both. Caching it module-level
5944
+ * lets a later session/new or switch that resolves to the same (backend,
5945
+ * model) — in this session or any other, within the adapter's lifetime — seed
5946
+ * the correct window synchronously with no IPC. Keying on the resolved id
5947
+ * (rather than the picker value) means aliases that resolve to the same
5948
+ * concrete model share one entry; the result handler additionally writes the
5949
+ * bare assistant-message spelling so seed-time reads that fall back to a
5950
+ * verbatim live id (rows without `resolvedModel`) can hit too.
5522
5951
  *
5523
- * (Older CLIs under-reported extended 1M windows here — commit 20ef663
5524
- * dropped the field for that reason — but the CLI vendored by the pinned
5525
- * SDK reports them correctly again.) Returns `null` on any control-request
5526
- * failure or a nonsensical (non-positive) window. */
5527
- async function fetchContextWindowSize(query, logger) {
5528
- try {
5529
- const usage = await query.getContextUsage();
5530
- return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null;
5531
- }
5532
- catch (error) {
5533
- logger.error("Failed to fetch context window size from SDK:", error);
5534
- return null;
5952
+ * Populated authoritatively by each `result.modelUsage` a turn confirms (see
5953
+ * the consumer's result handler). We deliberately never populate it from a
5954
+ * fresh session's `getContextUsage`: before that session's first prompt turn
5955
+ * has run the control request is not serviced (it stalls ~15s, and serializes
5956
+ * ahead of an awaited `setModel` — issues #886/#880, regressed in 0.59.0), so
5957
+ * it can neither beat the first `result` nor be issued cheaply before one.
5958
+ * Resumed sessions are the exception — their report IS serviced pre-turn, and
5959
+ * the session/load path seeds (but does not cache) the window from the same
5960
+ * response that restores the live model, see `readResumedLiveModel`.
5961
+ * Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
5962
+ * windows learned under one login must not seed sessions under the next. */
5963
+ const contextWindowCache = new Map();
5964
+ /** The env vars that determine which LLM backend — and which context lane on
5965
+ * it — a query's API traffic reaches: endpoint selection (base URLs and the
5966
+ * Bedrock/Vertex switches with their project/region), routing/beta headers
5967
+ * (an `anthropic-beta: context-1m-…` header flips the same model id at the
5968
+ * same endpoint between context lanes), and credential identity (extended
5969
+ * context is entitlement-gated per account). Used to derive the
5970
+ * provider-cache key from the exact env a query is created with, so
5971
+ * `providers/set` config, per-session `_meta` env overrides, and ambient
5972
+ * process env are all distinguished exactly as the CLI will see them. */
5973
+ const PROVIDER_ROUTING_ENV_VARS = [
5974
+ "ANTHROPIC_BASE_URL",
5975
+ "ANTHROPIC_BEDROCK_BASE_URL",
5976
+ "ANTHROPIC_VERTEX_BASE_URL",
5977
+ "CLAUDE_CODE_USE_BEDROCK",
5978
+ "CLAUDE_CODE_USE_VERTEX",
5979
+ "ANTHROPIC_VERTEX_PROJECT_ID",
5980
+ "CLOUD_ML_REGION",
5981
+ "AWS_REGION",
5982
+ "ANTHROPIC_CUSTOM_HEADERS",
5983
+ "ANTHROPIC_API_KEY",
5984
+ "ANTHROPIC_AUTH_TOKEN",
5985
+ ];
5986
+ /** Stable identifier for the LLM backend a session's query is created against,
5987
+ * used to scope {@link contextWindowCache} per backend. Positional `\0`-join
5988
+ * of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade
5989
+ * as another and unset vars everywhere yield one stable "default" bucket.
5990
+ * Header/credential values can be secrets; the key only ever lives as an
5991
+ * in-memory Map key and is never logged or surfaced. Over-keying is the safe
5992
+ * side: a var change that didn't really change the backend costs one cache
5993
+ * miss (heuristic seed until the next result), while under-keying would serve
5994
+ * one backend's window for another's. */
5995
+ function providerCacheKeyFor(env) {
5996
+ return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0");
5997
+ }
5998
+ /** Compose the `contextWindowCache` key from a session's provider key and a
5999
+ * model id. `\0`-joined so the model segment can't collide with a provider
6000
+ * segment. */
6001
+ function contextWindowCacheKey(providerCacheKey, modelId) {
6002
+ return `${providerCacheKey}\0${modelId}`;
6003
+ }
6004
+ function cacheContextWindow(modelKey, window) {
6005
+ if (window > 0) {
6006
+ contextWindowCache.set(modelKey, window);
5535
6007
  }
5536
6008
  }
6009
+ /** The context window to report *right now* for a model, with NO IPC on the
6010
+ * critical path: the cached authoritative value if we've learned it (from a
6011
+ * prior turn's `result.modelUsage`, this or any session on the same backend),
6012
+ * else the text heuristic over the model row's identity strings, else the
6013
+ * default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`,
6014
+ * the same rule at every seed site — so read keys can't drift from the write
6015
+ * site's spelling. `authoritative` reports whether the value came from the
6016
+ * cache: an authoritative window can legitimately equal
6017
+ * DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */
6018
+ function immediateContextWindow(providerCacheKey, modelId, modelInfo) {
6019
+ const cached = contextWindowCache.get(contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId));
6020
+ if (cached !== undefined)
6021
+ return { size: cached, authoritative: true };
6022
+ return {
6023
+ size: inferContextWindowFromModel(modelId, modelInfo?.resolvedModel, modelInfo?.displayName, modelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
6024
+ authoritative: false,
6025
+ };
6026
+ }
5537
6027
  /** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
5538
6028
  * option. The `maxThinkingTokens` option it used to feed is deprecated and
5539
6029
  * reduced to on/off on current models, so map the value to explicit thinking
@@ -5575,6 +6065,12 @@ function getMatchingModelUsage(modelUsage, currentModel) {
5575
6065
  }
5576
6066
  }
5577
6067
  if (bestKey) {
5578
- return modelUsage[bestKey];
6068
+ // `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
6069
+ // the same spelling as ModelInfo.resolvedModel — the primary key the
6070
+ // window is cached under. `currentModel` (the assistant message's
6071
+ // `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
6072
+ // handler caches under that spelling too, for seed-time reads that fall
6073
+ // back to a bare id (rows without `resolvedModel`).
6074
+ return { key: bestKey, usage: modelUsage[bestKey] };
5579
6075
  }
5580
6076
  }