@lucascouts/claude-agent-acp-plus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acp-agent.js CHANGED
@@ -8,7 +8,6 @@ import * as path from "node:path";
8
8
  import { promisify } from "node:util";
9
9
  import packageJson from "../package.json" with { type: "json" };
10
10
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
11
- import { askUserQuestionFallbackEnabled, handleAskUserQuestionViaPermission, } from "./ask-user-question-fallback.js";
12
11
  import { agentName } from "./agent-name.js";
13
12
  import { filterDeprecatedModels } from "./model-deprecation.js";
14
13
  import { SettingsManager } from "./settings.js";
@@ -63,6 +62,46 @@ const QUERY_RECREATE_INIT_TIMEOUT_MS = 30_000;
63
62
  * completed/stalled without the host turn resolving (issue #825). */
64
63
  const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
65
64
  "(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
65
+ /** Custom (extension) request method a client uses to steer the turn that is
66
+ * currently running: the message is injected into the in-flight turn rather
67
+ * than queued as a separate `session/prompt`. Named `_session/steering` per the
68
+ * agreed ACP steering wire protocol; advertised to clients via the top-level
69
+ * `InitializeResponse._meta.steering.supported`. */
70
+ const STEER_METHOD = "_session/steering";
71
+ /** How urgently the SDK delivers a steered message relative to the running
72
+ * turn — an internal Claude implementation detail, not part of the wire
73
+ * contract. `now` pre-empts the current generation and handles the message
74
+ * immediately (interrupting a single-shot response, or slotting in between a
75
+ * multi-step turn's tool calls). Maps to `SDKUserMessage.priority`; injected
76
+ * steering always uses `now` so the running turn adapts as soon as possible. */
77
+ const STEER_PRIORITY = "now";
78
+ /** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
79
+ * content blocks are handed to `promptToClaude`, which tolerates unknown block
80
+ * types — but `sessionId` and a non-empty `prompt` array are required. */
81
+ function parseSteerRequest(params) {
82
+ if (!params || typeof params !== "object") {
83
+ throw RequestError.invalidParams(undefined, "steer params must be an object");
84
+ }
85
+ const { sessionId, prompt, _meta } = params;
86
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
87
+ throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
88
+ }
89
+ if (!Array.isArray(prompt) || prompt.length === 0) {
90
+ throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
91
+ }
92
+ const steering = _meta && typeof _meta === "object" ? _meta.steering : undefined;
93
+ const idleBehavior = steering && typeof steering === "object"
94
+ ? steering.idleBehavior
95
+ : undefined;
96
+ if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
97
+ throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
98
+ }
99
+ return {
100
+ sessionId,
101
+ prompt: prompt,
102
+ _meta: _meta,
103
+ };
104
+ }
66
105
  /** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
67
106
  * model did on its own (a task-notification followup, a peer/coordinator/
68
107
  * observer message it handled) rather than the user's prompt. Absent,
@@ -123,6 +162,23 @@ const PROVIDER_ID = "main";
123
162
  * env-var mappings understood by {@link createEnvForProvider}.
124
163
  */
125
164
  const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
165
+ const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
166
+ function supportsSubagentTranscript(capabilities) {
167
+ return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
168
+ }
169
+ function parentToolUseIdOf(message) {
170
+ if (!("parent_tool_use_id" in message))
171
+ return null;
172
+ return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null;
173
+ }
174
+ function stripSubagentTextAndThinking(content) {
175
+ if (!Array.isArray(content))
176
+ return content;
177
+ return content.filter((item) => !item ||
178
+ typeof item !== "object" ||
179
+ !("type" in item) ||
180
+ (item.type !== "text" && item.type !== "thinking"));
181
+ }
126
182
  /**
127
183
  * Advance the lexer state across the fragment appended since the last delta:
128
184
  * just enough JSON awareness (string/escape, nesting depth) to spot commas
@@ -611,6 +667,14 @@ export class ClaudeAcpAgent {
611
667
  ...terminalAuthMethods,
612
668
  ...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
613
669
  ],
670
+ // Top-level `_meta` (sibling of `agentCapabilities`), per the existing ACP
671
+ // steering extension contract: advertises the `_session/steering` request
672
+ // so clients know they may inject a follow-up into a running turn.
673
+ _meta: {
674
+ steering: {
675
+ supported: true,
676
+ },
677
+ },
614
678
  };
615
679
  }
616
680
  async newSession(params) {
@@ -803,6 +867,12 @@ export class ClaudeAcpAgent {
803
867
  // those paths.
804
868
  this.gatewayAuthRequest = undefined;
805
869
  this.providerConfig = undefined;
870
+ // Learned context windows are per-account state too: 1M-context
871
+ // entitlement is gated per org/tier, and an OAuth re-login is invisible to
872
+ // the env-derived provider cache key, so windows learned under the old
873
+ // login must not seed sessions under the next. Worst case of clearing is
874
+ // re-learning on each model's next turn.
875
+ contextWindowCache.clear();
806
876
  // For the Claude/Console login methods the credentials live in the native
807
877
  // CLI's store (keychain or config dir), which only the binary can clear.
808
878
  // `claude auth logout` is non-interactive and idempotent.
@@ -953,6 +1023,71 @@ export class ClaudeAcpAgent {
953
1023
  this.ensureConsumer(session, params.sessionId);
954
1024
  return response;
955
1025
  }
1026
+ /** Steer the session per the ACP steering wire protocol: inject a follow-up
1027
+ * message into the turn that is currently running. If that turn already
1028
+ * settled, the established default starts a new detached turn; Hosts may opt
1029
+ * into the host-owned `promptRequired` fallback through request `_meta`.
1030
+ *
1031
+ * When a turn is in flight this injects (returns `injected`): unlike
1032
+ * `prompt()`, it does NOT create a Turn or enqueue on `turnQueue`; it pushes
1033
+ * an `SDKUserMessage` onto the same streaming input, which the SDK routes
1034
+ * into the in-flight turn. The injected message's echo carries a uuid that
1035
+ * matches no queued turn, so the consumer drops it as an unrelated replay
1036
+ * without promoting/settling anything. It is delivered at {@link
1037
+ * STEER_PRIORITY} (`now`) so it pre-empts the current generation (interrupting
1038
+ * a single-shot response, or slotting in between a multi-step turn's tool
1039
+ * calls). The steered message's own output streams via `session/update`, not
1040
+ * this response.
1041
+ *
1042
+ * When the session is idle, the opt-in path returns `promptRequired` WITHOUT
1043
+ * calling `prompt()`, pushing SDK input, or mutating `turnQueue`: the content
1044
+ * stays Host-owned so the Host can submit it through a standard
1045
+ * `session/prompt`. Without the opt-in, the existing detached `prompt()` and
1046
+ * `startedNewTurn` result are preserved for compatibility. */
1047
+ async steer(params) {
1048
+ const sessionId = params.sessionId;
1049
+ const session = this.sessions[sessionId];
1050
+ if (!session) {
1051
+ throw new Error("Session not found");
1052
+ }
1053
+ if (session.queryClosed) {
1054
+ throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1055
+ }
1056
+ // "A turn is running" = the queue holds an unsettled turn. This covers both
1057
+ // the activated turn and one just submitted but not yet echoed/activated,
1058
+ // which is exactly the window in which steering is meaningful. This check
1059
+ // and the active-path push below stay in one synchronous section so the
1060
+ // turn cannot settle in the gap between deciding to inject and enqueueing.
1061
+ const turnInFlight = (session.turnQueue ?? []).some((turn) => !turn.settled);
1062
+ if (!turnInFlight) {
1063
+ const promptRequest = {
1064
+ sessionId,
1065
+ prompt: params.prompt,
1066
+ };
1067
+ if (params._meta?.steering?.idleBehavior === "promptRequired") {
1068
+ // The opt-in path leaves the content untouched so the Host can retry via
1069
+ // a normal session/prompt whose lifecycle owns the continuation result.
1070
+ return { outcome: "promptRequired", reason: "noRunningTurn" };
1071
+ }
1072
+ // Preserve the established default for Hosts that do not opt in. This is
1073
+ // intentionally detached for compatibility with the existing contract.
1074
+ this.prompt(promptRequest).catch((error) => {
1075
+ this.logger.error(`Session ${sessionId}: steered new turn failed: ${error}`);
1076
+ });
1077
+ return { outcome: "startedNewTurn" };
1078
+ }
1079
+ const promptRequest = {
1080
+ sessionId,
1081
+ prompt: params.prompt,
1082
+ };
1083
+ const userMessage = promptToClaude(promptRequest);
1084
+ userMessage.uuid = randomUUID();
1085
+ // Deliver into the running turn rather than queuing behind it as a fresh
1086
+ // prompt would.
1087
+ userMessage.priority = STEER_PRIORITY;
1088
+ session.input.push(userMessage);
1089
+ return { outcome: "injected" };
1090
+ }
956
1091
  /** Lazily start the per-session consumer that drains the SDK query stream for
957
1092
  * the session's whole life. Idempotent: only the first `prompt()` starts it. */
958
1093
  ensureConsumer(session, sessionId) {
@@ -1612,7 +1747,7 @@ export class ClaudeAcpAgent {
1612
1747
  // A fresh `system`/init (e.g. after reinitialize) can carry an
1613
1748
  // updated Fast mode state; reconcile it with what we seeded at
1614
1749
  // session creation.
1615
- await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
1750
+ await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
1616
1751
  break;
1617
1752
  case "status": {
1618
1753
  // These banners count as delivered text (via sendUpdate), so
@@ -1831,9 +1966,9 @@ export class ClaudeAcpAgent {
1831
1966
  // Push the full slash-command list after a mid-session change
1832
1967
  // (e.g. skills discovered dynamically as the agent works in a
1833
1968
  // subdirectory). The client should REPLACE its cached command
1834
- // list with this payload: supportedCommands() is captured once
1835
- // at initialize and never reflects mid-session changes, so we
1836
- // forward message.commands directly rather than re-querying.
1969
+ // list with this payload. Forward message.commands directly
1970
+ // it's authoritative, and re-querying supportedCommands()
1971
+ // would just return the same list with an extra round-trip.
1837
1972
  await sendUpdate({
1838
1973
  sessionId: message.session_id,
1839
1974
  update: {
@@ -1857,6 +1992,28 @@ export class ClaudeAcpAgent {
1857
1992
  // already emitted as a `tool_call`, so mark it failed with the
1858
1993
  // rejection reason — otherwise the client shows a tool call
1859
1994
  // that silently never resolves.
1995
+ //
1996
+ // The id is the executing call's own, and the frame lands
1997
+ // between its `tool_use` and its `tool_result` (the SDK enqueues
1998
+ // it from inside canUseTool), so the call is normally in flight
1999
+ // here. Not always: the assistant message carrying the tool_use
2000
+ // is dropped by the cancelled-turn guard below, and a denial for
2001
+ // it can still arrive afterwards — the case the `tool_result`
2002
+ // fallback in `toAcpNotifications` gates on `wasEmitted` for.
2003
+ // Drop the update rather than reference a tool call the client
2004
+ // was never given (see `ensureToolCallEmitted`, issue #851).
2005
+ if (!session.emittedToolCalls.has(message.tool_use_id)) {
2006
+ break;
2007
+ }
2008
+ // A denial inside a subagent identifies the subagent by
2009
+ // `agent_id` (as canUseTool does with `agentID`), never by the
2010
+ // Agent/Task call that spawned it. Resolve it the same way so
2011
+ // the update lands in the subagent's transcript alongside the
2012
+ // `tool_call` it resolves, which carries the parent stamped from
2013
+ // `parent_tool_use_id` (see `liveBackgroundTasks`).
2014
+ const parentToolUseId = message.agent_id
2015
+ ? session.liveBackgroundTasks.get(message.agent_id)?.parentToolUseId
2016
+ : undefined;
1860
2017
  const reason = message.decision_reason ?? message.message;
1861
2018
  await sendUpdate({
1862
2019
  sessionId: message.session_id,
@@ -1873,6 +2030,7 @@ export class ClaudeAcpAgent {
1873
2030
  _meta: {
1874
2031
  claudeCode: {
1875
2032
  toolName: message.tool_name,
2033
+ ...(parentToolUseId ? { parentToolUseId } : {}),
1876
2034
  toolResponse: {
1877
2035
  decisionReasonType: message.decision_reason_type,
1878
2036
  decisionReason: message.decision_reason,
@@ -2106,7 +2264,7 @@ export class ClaudeAcpAgent {
2106
2264
  // an autonomous cycle's state lands on the next user turn's
2107
2265
  // result. Runs even when the turn errors or was cancelled.
2108
2266
  if (!isAutonomousResult) {
2109
- await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
2267
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state, message.fast_mode_disabled_reason);
2110
2268
  }
2111
2269
  // A user-turn result needs an active turn so its stop reason is
2112
2270
  // attributed and the turn settles at idle. Local-only commands carry
@@ -2182,12 +2340,35 @@ export class ClaudeAcpAgent {
2182
2340
  const matchingModelUsage = lastAssistantModel
2183
2341
  ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
2184
2342
  : null;
2185
- // Only overwrite when we have an authoritative value a miss
2186
- // (e.g. a turn with no top-level assistant message) would
2187
- // otherwise discard the window learned on a prior turn and
2188
- // leave the next prompt's mid-stream updates reporting 200k.
2189
- if (matchingModelUsage) {
2190
- session.contextWindowSize = matchingModelUsage.contextWindow;
2343
+ // Only overwrite when we have an authoritative, sane value. A miss
2344
+ // (e.g. a turn with no top-level assistant message), or a
2345
+ // nonsensical non-positive/NaN window (observed from third-party
2346
+ // backends), would otherwise discard the window learned on a prior
2347
+ // turn and leave the next prompt's mid-stream updates reporting a
2348
+ // wrong size. `cacheContextWindow` applies the same `> 0` guard, so
2349
+ // a bad value never reaches the cross-session cache either.
2350
+ if (matchingModelUsage &&
2351
+ typeof matchingModelUsage.usage.contextWindow === "number" &&
2352
+ matchingModelUsage.usage.contextWindow > 0) {
2353
+ session.contextWindowSize = matchingModelUsage.usage.contextWindow;
2354
+ session.contextWindowAuthoritative = true;
2355
+ // Authoritative: fold it into the cross-session cache keyed on
2356
+ // (this session's provider, the resolved model id —
2357
+ // matchingModelUsage.key, e.g. "claude-sonnet-5[1m]") so a later
2358
+ // session/new or switch on the same provider that resolves to
2359
+ // this model seeds the correct window synchronously, with no
2360
+ // getContextUsage IPC.
2361
+ cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, matchingModelUsage.key), matchingModelUsage.usage.contextWindow);
2362
+ // Also cache under the assistant message's own (bare) spelling.
2363
+ // Seed-time reads fall back to a picker value / verbatim live id
2364
+ // when a row carries no resolvedModel (the synthesized
2365
+ // out-of-allowlist resume row sets it undefined on purpose), and
2366
+ // those spellings match `.model` from the assistant message, not
2367
+ // the decorated modelUsage key — without this entry such rows
2368
+ // could never hit the cache.
2369
+ if (lastAssistantModel && lastAssistantModel !== matchingModelUsage.key) {
2370
+ cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, lastAssistantModel), matchingModelUsage.usage.contextWindow);
2371
+ }
2191
2372
  }
2192
2373
  // Send usage_update notification
2193
2374
  if (lastAssistantTotalUsage !== null) {
@@ -2430,11 +2611,16 @@ export class ClaudeAcpAgent {
2430
2611
  const model = message.event.message.model;
2431
2612
  if (model && model !== "<synthetic>") {
2432
2613
  lastAssistantModel = model;
2433
- // Only upgrade from the default — once the SDK has given us
2434
- // an authoritative window (seeded at session creation,
2435
- // refreshed on model switches in `applyConfigOptionValue`,
2436
- // confirmed by each `result`), trust it over the heuristic.
2437
- if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
2614
+ // Only upgrade from the heuristic default — once we have an
2615
+ // authoritative window (cache-seeded at session creation or
2616
+ // on a model switch, read from the resumed session on
2617
+ // session/load, confirmed by each `result`), trust it over
2618
+ // the heuristic. The flag, not the value, is the sentinel: an
2619
+ // authoritative window can legitimately equal
2620
+ // DEFAULT_CONTEXT_WINDOW (e.g. a backend serving a 200k lane
2621
+ // under a "[1m]"-spelled id) and must not be clobbered.
2622
+ if (!session.contextWindowAuthoritative &&
2623
+ session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
2438
2624
  const inferred = inferContextWindowFromModel(model);
2439
2625
  if (inferred !== null) {
2440
2626
  session.contextWindowSize = inferred;
@@ -2692,11 +2878,13 @@ export class ClaudeAcpAgent {
2692
2878
  // the record stays bounded to the in-flight message.
2693
2879
  streamedBlocks.length = 0;
2694
2880
  }
2695
- else if (message.type === "assistant") {
2696
- // Subagent assistant message (`parent_tool_use_id !== null`). It is
2697
- // never streamed live and its text/thinking is internal to the tool
2698
- // call keep dropping it so subagent prose doesn't leak into the
2699
- // top-level feed.
2881
+ else if (message.type === "assistant" &&
2882
+ !(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
2883
+ // Legacy clients don't understand nested transcripts. Keep the
2884
+ // historical behavior for them: subagent text/thinking remains
2885
+ // internal to the tool call instead of leaking into the top-level
2886
+ // feed. Capable clients opt into the branch above unchanged, with
2887
+ // `parentToolUseId` stamped by toAcpNotifications.
2700
2888
  content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
2701
2889
  }
2702
2890
  else {
@@ -2710,6 +2898,11 @@ export class ClaudeAcpAgent {
2710
2898
  emittedToolCalls: session.emittedToolCalls,
2711
2899
  messageId: messageIdForGrouping(message),
2712
2900
  toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
2901
+ // On the wire since CLI 2.1.216 but not in SDKUserMessage's
2902
+ // type, hence the cast. Validated by parseToolResultMeta.
2903
+ toolResultMeta: message.type === "user"
2904
+ ? message.tool_result_meta
2905
+ : undefined,
2713
2906
  })) {
2714
2907
  // sendUpdate records delivery. Subagent text/thinking is
2715
2908
  // filtered out of `content` above; blocks that do pass through
@@ -2720,16 +2913,48 @@ export class ClaudeAcpAgent {
2720
2913
  break;
2721
2914
  }
2722
2915
  case "tool_progress": {
2916
+ // Not every beat reports under the id of a tool call the client has
2917
+ // seen: heartbeats derive `<tool_use_id>-heartbeat-<n>`, and the
2918
+ // `agent_api_retry` beats behind `subagentRetry` report under
2919
+ // `agent_<assistant_message_id>`. Forwarding those verbatim leaves the
2920
+ // client resolving an id it has never been told about (the same trap
2921
+ // `ensureToolCallEmitted` documents for #851). The SDK stamps
2922
+ // `parent_tool_use_id` with the executing tool's real id whenever the
2923
+ // beat doesn't carry one of its own, so fall back to it rather than
2924
+ // pattern-matching each synthetic id shape. Beats that do report a real
2925
+ // id (a subagent's `bash_progress`, whose parent is the spawning Agent
2926
+ // call) keep resolving to that id.
2927
+ const toolCallId = session.emittedToolCalls.has(message.tool_use_id)
2928
+ ? message.tool_use_id
2929
+ : message.parent_tool_use_id;
2930
+ // Ids leave `emittedToolCalls` at `tool_result`, so this also stops a
2931
+ // beat that races past completion from reopening a finished call.
2932
+ if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
2933
+ break;
2934
+ }
2723
2935
  await sendUpdate({
2724
2936
  sessionId: message.session_id,
2725
2937
  update: {
2726
2938
  sessionUpdate: "tool_call_update",
2727
- toolCallId: message.tool_use_id,
2939
+ toolCallId,
2728
2940
  status: "in_progress",
2729
2941
  _meta: {
2730
2942
  claudeCode: {
2731
2943
  toolName: message.tool_name,
2732
- toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
2944
+ toolResponse: {
2945
+ elapsedTimeSeconds: message.elapsed_time_seconds,
2946
+ // For Agent/Task calls: the subagent's type, and — when
2947
+ // the subagent is waiting out an API rate-limit retry —
2948
+ // the SDK's retry counters (attempt, max_retries,
2949
+ // retry_delay_ms, …), forwarded verbatim so clients can
2950
+ // show why a spawn looks stalled.
2951
+ ...(message.subagent_type !== undefined && {
2952
+ subagentType: message.subagent_type,
2953
+ }),
2954
+ ...(message.subagent_retry !== undefined && {
2955
+ subagentRetry: message.subagent_retry,
2956
+ }),
2957
+ },
2733
2958
  },
2734
2959
  },
2735
2960
  },
@@ -3276,6 +3501,8 @@ export class ClaudeAcpAgent {
3276
3501
  async replaySessionHistory(sessionId) {
3277
3502
  const toolUseCache = {};
3278
3503
  const messages = await getSessionMessages(sessionId);
3504
+ const forwardSubagentText = this.sessions[sessionId]?.forwardSubagentText ??
3505
+ supportsSubagentTranscript(this.clientCapabilities);
3279
3506
  for (const message of messages) {
3280
3507
  // Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
3281
3508
  // observe live (resumed/loaded sessions), so rewind/resume can translate
@@ -3294,6 +3521,10 @@ export class ClaudeAcpAgent {
3294
3521
  }
3295
3522
  // @ts-expect-error - untyped in SDK but we handle all of these
3296
3523
  let content = message.message.content;
3524
+ const parentToolUseId = parentToolUseIdOf(message);
3525
+ if (message.type === "assistant" && parentToolUseId && !forwardSubagentText) {
3526
+ content = stripSubagentTextAndThinking(content);
3527
+ }
3297
3528
  // @ts-expect-error - untyped in SDK but we handle all of these
3298
3529
  if (message.message.role === "user") {
3299
3530
  content = stripLocalCommandMetadata(content);
@@ -3310,6 +3541,7 @@ export class ClaudeAcpAgent {
3310
3541
  cwd: this.sessions[sessionId]?.cwd,
3311
3542
  taskState: this.sessions[sessionId]?.taskState,
3312
3543
  messageId: replayMessageId,
3544
+ parentToolUseId,
3313
3545
  })) {
3314
3546
  await this.client.sessionUpdate(notification);
3315
3547
  }
@@ -3383,7 +3615,7 @@ export class ClaudeAcpAgent {
3383
3615
  await this.client.sessionUpdate({ sessionId, update });
3384
3616
  }
3385
3617
  canUseTool(sessionId) {
3386
- return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID }) => {
3618
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
3387
3619
  const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
3388
3620
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
3389
3621
  const session = this.sessions[sessionId];
@@ -3420,41 +3652,6 @@ export class ClaudeAcpAgent {
3420
3652
  await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
3421
3653
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
3422
3654
  }
3423
- // Fallback for clients WITHOUT `elicitation.form`: route each question
3424
- // through ACP `session/request_permission` dialogs (gated by
3425
- // ACP_ASKUSERQUESTION_FALLBACK). Placed before ExitPlanMode and the
3426
- // bypassPermissions early-allow so a question is always asked, even in
3427
- // bypass mode.
3428
- if (toolName === "AskUserQuestion" && askUserQuestionFallbackEnabled(process.env)) {
3429
- // Emit the tool_call before the first permission request (R3.1).
3430
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
3431
- // Logger has no `debug`; `.error` reaches stderr in all contexts (the CLI
3432
- // entrypoint also remaps console.log/debug -> stderr), so it never
3433
- // corrupts the stdout ndJSON protocol. Used by story 002 validation.
3434
- this.logger.error("AskUserQuestion: routing via permission fallback (client lacks elicitation.form).");
3435
- return handleAskUserQuestionViaPermission(toolInput, async ({ question, options }) => {
3436
- const response = await this.requestPermissionFromClient({
3437
- options,
3438
- sessionId,
3439
- toolCall: {
3440
- toolCallId: toolUseID,
3441
- rawInput: toolInput,
3442
- ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3443
- // Per-question title so the user sees which question they are
3444
- // answering; placed AFTER the spread so it wins over the
3445
- // tool-derived title.
3446
- title: question.question,
3447
- },
3448
- }, toolName, signal);
3449
- // RequestPermissionResponse has a DOUBLE-nested outcome (see the
3450
- // ExitPlanMode usage below): response.outcome?.outcome is
3451
- // "selected" | "cancelled".
3452
- if (response.outcome?.outcome === "selected") {
3453
- return { outcome: "selected", optionId: response.outcome.optionId };
3454
- }
3455
- return { outcome: "cancelled" };
3456
- }, signal);
3457
- }
3458
3655
  if (toolName === "ExitPlanMode") {
3459
3656
  const optionsAll = [
3460
3657
  { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
@@ -3526,7 +3723,14 @@ export class ClaudeAcpAgent {
3526
3723
  };
3527
3724
  }
3528
3725
  }
3529
- if (session.modes.currentModeId === "bypassPermissions") {
3726
+ // In bypass mode the CLI skips permission checks itself; the asks that
3727
+ // still reach canUseTool are the ones it insists on prompting for even
3728
+ // under --dangerously-skip-permissions. Keep auto-allowing those —
3729
+ // bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
3730
+ // user explicitly configured a permissions.ask rule for this tool, and
3731
+ // the SDK's guidance is that hosts running auto-approval must treat such
3732
+ // asks as a human prompt. Fall through to the normal request below.
3733
+ if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
3530
3734
  return {
3531
3735
  behavior: "allow",
3532
3736
  updatedInput: toolInput,
@@ -3732,17 +3936,21 @@ export class ClaudeAcpAgent {
3732
3936
  // carries no "1m" token.
3733
3937
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
3734
3938
  if (session.models.currentModelId !== value) {
3735
- // The cached context window was learned for the previous model. The
3736
- // SDK is already running the new model here (user-driven switches call
3737
- // `query.setModel` before this, and the refusal-fallback sync only
3738
- // reconciles a switch the SDK already made), so ask it for the new
3739
- // window; fall back to the text heuristic so mid-stream updates
3740
- // between now and the next `result` reflect the user's selection
3741
- // instead of the old model's window.
3742
- session.contextWindowSize =
3743
- (await fetchContextWindowSize(session.query, this.logger)) ??
3744
- inferContextWindowFromModel(value, newModelInfo?.resolvedModel, newModelInfo?.displayName, newModelInfo?.description) ??
3745
- DEFAULT_CONTEXT_WINDOW;
3939
+ // Seed the new model's context window WITHOUT any IPC on the switch
3940
+ // path: cached authoritative value if we've already learned it (from a
3941
+ // prior turn's `result.modelUsage`), else the text heuristic, else the
3942
+ // default. We deliberately do NOT call `getContextUsage` here before
3943
+ // a fresh session's first prompt turn that control request is not
3944
+ // serviced (~15s stall, issues #886/#880), and (because SDK control
3945
+ // requests are serialized over one channel) it would drag the awaited
3946
+ // `setModel` down with it. The authoritative window arrives on the
3947
+ // first `result.modelUsage` for the model and is cached from there;
3948
+ // until then a switched-to alias that has never run a turn shows the
3949
+ // heuristic/default window, which self-corrects on its first response
3950
+ // (matches pre-0.59.0 behavior).
3951
+ const seeded = immediateContextWindow(session.providerCacheKey, value, newModelInfo);
3952
+ session.contextWindowSize = seeded.size;
3953
+ session.contextWindowAuthoritative = seeded.authoritative;
3746
3954
  }
3747
3955
  session.models = { ...session.models, currentModelId: value };
3748
3956
  // Recompute availableModes for the new model and clamp the current
@@ -3780,6 +3988,13 @@ export class ClaudeAcpAgent {
3780
3988
  else {
3781
3989
  session.modes = { ...session.modes, availableModes: newAvailableModes };
3782
3990
  }
3991
+ // `model_not_allowed` described the model we just left, so it must not
3992
+ // follow us onto the new one; the remaining reasons are account- or
3993
+ // environment-scoped and stay true across a switch. Either way the next
3994
+ // init/result report refreshes this.
3995
+ if (session.fastModeDisabledReason === "model_not_allowed") {
3996
+ session.fastModeDisabledReason = undefined;
3997
+ }
3783
3998
  // Rebuild config options since effort levels depend on the selected model
3784
3999
  const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
3785
4000
  const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
@@ -3789,6 +4004,7 @@ export class ClaudeAcpAgent {
3789
4004
  // intent) when a supporting model is selected again.
3790
4005
  supported: newModelInfo?.supportsFastMode ?? false,
3791
4006
  enabled: session.fastModeEnabled,
4007
+ disabledReason: session.fastModeDisabledReason,
3792
4008
  },
3793
4009
  // Thinking is model-independent: re-render the retained tri-state
3794
4010
  // intent so the row survives this rebuild (an option not threaded
@@ -3874,12 +4090,13 @@ export class ClaudeAcpAgent {
3874
4090
  }
3875
4091
  }
3876
4092
  /** Replace the Fast mode option in `session.configOptions` so it reflects
3877
- * `enabled`. A no-op when the option isn't present, so callers must confirm
3878
- * the current model surfaces it first. Rebuilds through the single-parameter
3879
- * {@link createFastModeConfigOption} — the one source of the option's shape —
3880
- * so the shape can't drift from what `buildConfigOptions` first emitted. */
4093
+ * `enabled` (and the session's current disabled reason). A no-op when the
4094
+ * option isn't present, so callers must confirm the current model surfaces
4095
+ * it first. Rebuilds through {@link createFastModeConfigOption} — the one
4096
+ * source of the option's shape — so the shape can't drift from what
4097
+ * `buildConfigOptions` first emitted. */
3881
4098
  refreshFastModeOption(session, enabled) {
3882
- const refreshed = createFastModeConfigOption(enabled);
4099
+ const refreshed = createFastModeConfigOption(enabled, session.fastModeDisabledReason);
3883
4100
  session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
3884
4101
  }
3885
4102
  /** Toggle Fast mode for a session: push the SDK flag, record the user's
@@ -4163,8 +4380,15 @@ export class ClaudeAcpAgent {
4163
4380
  * here).
4164
4381
  * - `cooldown`: a transient suspension of an already-enabled fast mode.
4165
4382
  * Leave the toggle as-is rather than flapping it — and never let a stray
4166
- * cooldown spuriously enable a toggle the user has off. */
4167
- async syncFastModeState(sessionId, session, state) {
4383
+ * cooldown spuriously enable a toggle the user has off.
4384
+ *
4385
+ * `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
4386
+ * state. Only explainable reasons are retained (see
4387
+ * {@link normalizeFastModeDisabledReason}), so the comparison below tracks
4388
+ * exactly what the user can see: a routine `sdk_opt_in_required` report on
4389
+ * every turn's result can't churn the option, while a real blocker updates
4390
+ * the description even when the toggle's own value is unchanged. */
4391
+ async syncFastModeState(sessionId, session, state, reason) {
4168
4392
  if (state === undefined) {
4169
4393
  return;
4170
4394
  }
@@ -4175,11 +4399,31 @@ export class ClaudeAcpAgent {
4175
4399
  return;
4176
4400
  }
4177
4401
  const enabled = state === "on";
4178
- if (enabled === session.fastModeEnabled) {
4402
+ // A reason only describes an off state; drop any that rides an `on` report
4403
+ // so it can't decorate the option the next time fast mode goes off.
4404
+ const nextReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
4405
+ if (enabled === session.fastModeEnabled && nextReason === session.fastModeDisabledReason) {
4179
4406
  return;
4180
4407
  }
4408
+ // The user asked for Fast mode and the SDK is telling us it can't serve it.
4409
+ // The description carries the same explanation, but a toggle silently
4410
+ // snapping back is the case worth saying out loud once, at the flip.
4411
+ const explain = session.fastModeEnabled && !enabled && nextReason !== undefined;
4181
4412
  session.fastModeEnabled = enabled;
4413
+ session.fastModeDisabledReason = nextReason;
4182
4414
  this.refreshFastModeOption(session, enabled);
4415
+ if (explain) {
4416
+ await this.client.sessionUpdate({
4417
+ sessionId,
4418
+ update: {
4419
+ sessionUpdate: "agent_message_chunk",
4420
+ content: {
4421
+ type: "text",
4422
+ text: `**Fast mode turned off:** ${FAST_MODE_UNAVAILABLE_EXPLANATIONS[nextReason]}.`,
4423
+ },
4424
+ },
4425
+ });
4426
+ }
4183
4427
  await this.client.sessionUpdate({
4184
4428
  sessionId,
4185
4429
  update: {
@@ -4311,6 +4555,8 @@ export class ClaudeAcpAgent {
4311
4555
  // Extract options from _meta if provided
4312
4556
  const sessionMeta = params._meta;
4313
4557
  const userProvidedOptions = sessionMeta?.claudeCode?.options;
4558
+ const forwardSubagentText = supportsSubagentTranscript(this.clientCapabilities) ||
4559
+ userProvidedOptions?.forwardSubagentText === true;
4314
4560
  // Configure thinking behavior through the same single code path query
4315
4561
  // recreation uses (`recreateSessionQuery`). A fresh session's Thinking
4316
4562
  // intent is untouched (`undefined`), so this resolves to exactly the
@@ -4326,13 +4572,9 @@ export class ClaudeAcpAgent {
4326
4572
  url: !!this.clientCapabilities?.elicitation?.url,
4327
4573
  };
4328
4574
  // AskUserQuestion surfaces as a `permission_ask_user_question` dialog that
4329
- // we render as a form elicitation. Without form-elicitation support we fall
4330
- // back to sequential ACP `session/request_permission` dialogs when the
4331
- // fallback gate is on (the default); set ACP_ASKUSERQUESTION_FALLBACK=0 to
4332
- // keep it disabled, exactly as upstream does.
4333
- const disallowedTools = elicitationSupport.form || askUserQuestionFallbackEnabled(process.env)
4334
- ? []
4335
- : ["AskUserQuestion"];
4575
+ // we render as a form elicitation. Without form-elicitation support there
4576
+ // is no way to present it over ACP, so keep it disabled in that case.
4577
+ const disallowedTools = elicitationSupport.form ? [] : ["AskUserQuestion"];
4336
4578
  // Resolve which built-in tools to expose.
4337
4579
  // Explicit tools array from _meta.claudeCode.options takes precedence.
4338
4580
  // disableBuiltInTools is a legacy shorthand for tools: [] — kept for
@@ -4344,6 +4586,28 @@ export class ClaudeAcpAgent {
4344
4586
  // below) so the TaskCreated/TaskCompleted hook callbacks can close over
4345
4587
  // the same Map that the streaming message handler will read from.
4346
4588
  const taskState = new Map();
4589
+ // The exact env the query will be created with. Built (and the provider
4590
+ // cache key derived from it, below) in one place so the key always
4591
+ // describes the backend this query actually talks to: `providers/set`,
4592
+ // `providers/disable`, and `logout` mutate the process-wide provider
4593
+ // config concurrently, so re-resolving it after any of the awaits between
4594
+ // here and the session registration could disagree with the env baked
4595
+ // into the query.
4596
+ const env = {
4597
+ ...process.env,
4598
+ ...userProvidedOptions?.env,
4599
+ // Client-managed LLM routing: `providers/set` config wins, else the
4600
+ // legacy gateway auth request. Baked into the query at creation, so it
4601
+ // only affects sessions started after the change (matching the RFD).
4602
+ ...createEnvForProvider(this.resolveProviderConfig()),
4603
+ // Opt-in to session state events like when the agent is idle
4604
+ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
4605
+ };
4606
+ // Scopes the context-window cache to this query's backend (see
4607
+ // `contextWindowCache`). Derived from the same `env` object handed to the
4608
+ // SDK, so per-session `_meta` env routing and ambient process-env routing
4609
+ // are distinguished exactly as the CLI will see them.
4610
+ const providerCacheKey = providerCacheKeyFor(env);
4347
4611
  const options = {
4348
4612
  systemPrompt,
4349
4613
  settingSources: ["user", "project", "local"],
@@ -4364,19 +4628,11 @@ export class ClaudeAcpAgent {
4364
4628
  ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
4365
4629
  },
4366
4630
  }),
4367
- env: {
4368
- ...process.env,
4369
- ...userProvidedOptions?.env,
4370
- // Client-managed LLM routing: `providers/set` config wins, else the
4371
- // legacy gateway auth request. Baked into the query at creation, so it
4372
- // only affects sessions started after the change (matching the RFD).
4373
- ...createEnvForProvider(this.resolveProviderConfig()),
4374
- // Opt-in to session state events like when the agent is idle
4375
- CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
4376
- },
4631
+ env,
4377
4632
  // Override certain fields that must be controlled by ACP
4378
4633
  cwd: params.cwd,
4379
4634
  includePartialMessages: true,
4635
+ forwardSubagentText,
4380
4636
  mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
4381
4637
  // If we want bypassPermissions to be an option, we have to allow it here.
4382
4638
  // But it doesn't work in root mode, so we only activate it if it will work.
@@ -4415,7 +4671,7 @@ export class ClaudeAcpAgent {
4415
4671
  ...(userProvidedOptions?.hooks?.PostToolUse || []),
4416
4672
  {
4417
4673
  hooks: [
4418
- createPostToolUseHook(this.logger, {
4674
+ createPostToolUseHook({
4419
4675
  onEnterPlanMode: async () => {
4420
4676
  await this.client.sessionUpdate({
4421
4677
  sessionId,
@@ -4537,7 +4793,7 @@ export class ClaudeAcpAgent {
4537
4793
  const allowedModels = Array.isArray(settingsAvailableModels)
4538
4794
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
4539
4795
  : hideDeprecatedModels(initializationResult.models, this.logger);
4540
- const models = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
4796
+ const { modelState: models, resumedContextWindow } = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
4541
4797
  // Gate `auto` (and future model-specific modes) on the resolved model's
4542
4798
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
4543
4799
  // Looked up in the UNfiltered catalog: a session honoring a persisted
@@ -4628,9 +4884,17 @@ export class ClaudeAcpAgent {
4628
4884
  // the resolved model advertises `supportsFastMode`.
4629
4885
  const fastModeEnabled = initializationResult.fast_mode_state !== undefined &&
4630
4886
  fastModeStateEnabled(initializationResult.fast_mode_state);
4887
+ // `fast_mode_disabled_reason` reflects the post-switch model since SDK
4888
+ // 0.3.219 (the initialize response used to answer from the spawn-time
4889
+ // model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
4890
+ // the opt-in — which normalizes away, so only real blockers are retained.
4891
+ const fastModeDisabledReason = fastModeEnabled
4892
+ ? undefined
4893
+ : normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);
4631
4894
  const fastMode = {
4632
4895
  supported: currentModelInfo?.supportsFastMode ?? false,
4633
4896
  enabled: fastModeEnabled,
4897
+ disabledReason: fastModeDisabledReason,
4634
4898
  };
4635
4899
  const configOptions = buildConfigOptions(modes, models,
4636
4900
  // Catalog-based (see `modelInfos` above), matching the model-switch
@@ -4652,25 +4916,40 @@ export class ClaudeAcpAgent {
4652
4916
  typeof initialEffort.currentValue === "string" &&
4653
4917
  initialEffort.currentValue !== "default") {
4654
4918
  await q.applyFlagSettings({
4655
- effortLevel: initialEffort.currentValue,
4919
+ effortLevel: toSdkEffortLevel(initialEffort.currentValue),
4656
4920
  });
4657
4921
  }
4658
- // Seed the context window from the SDK's authoritative report. Text
4659
- // inference alone misses aliases that resolve to extended-context models
4660
- // with no "1m" token anywhere in their id or description (e.g. `sonnet` →
4661
- // claude-sonnet-5, natively ~1M): those streamed `usage_update.size:
4662
- // 200000` until the first result's modelUsage corrected it again on
4663
- // every process restart or session re-creation, since the learned window
4664
- // lives only on the Session (issue #596).
4922
+ // Seed the context window WITHOUT any extra IPC on the session/new path.
4923
+ // On session/load, the resumed session's own `getContextUsage` report — a
4924
+ // response `getAvailableModels` already awaited to learn the live model
4925
+ // (resumed sessions ARE serviced pre-turn, unlike fresh ones) is
4926
+ // authoritative and wins. Otherwise: the cached authoritative window if a
4927
+ // prior turn has learned it for this model (`result.modelUsage`,
4928
+ // cross-session), else the text heuristic, else the default. We
4929
+ // deliberately do NOT issue a getContextUsage call here: on a fresh
4930
+ // session that control request is not serviced until the first prompt
4931
+ // turn runs, so awaiting it — as 0.59.0 did — made session/new take ~15s
4932
+ // (issues #886/#880). The authoritative window arrives on the first
4933
+ // `result.modelUsage` and is cached from there.
4934
+ //
4935
+ // Text inference alone misses aliases that resolve to extended-context
4936
+ // models with no "1m" token anywhere in their id or description (e.g.
4937
+ // `sonnet` → claude-sonnet-5, natively ~1M): those stream
4938
+ // `usage_update.size: 200000` until the first result's modelUsage corrects
4939
+ // it — but the cache means only the FIRST session to ever run a turn on such
4940
+ // a model eats that window, not every fresh session after a process
4941
+ // restart (issue #596; a post-restart session/load is covered by the
4942
+ // resumed report above).
4665
4943
  //
4666
- // The inference fallback is deliberately keyed to the allowlisted entry: a
4944
+ // The inference fallback is deliberately keyed to the catalog entry (the
4945
+ // fork's unfiltered lookup, see `catalogModelInfo` above): a
4667
4946
  // fallback-resolved sibling's resolvedModel/displayName/description can
4668
4947
  // describe a different context lane than the verbatim live id (e.g. an
4669
4948
  // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
4670
4949
  // the id itself is a trustworthy window signal.
4671
- const contextWindowSize = (await fetchContextWindowSize(q, this.logger)) ??
4672
- inferContextWindowFromModel(models.currentModelId, catalogModelInfo?.resolvedModel, catalogModelInfo?.displayName, catalogModelInfo?.description) ??
4673
- DEFAULT_CONTEXT_WINDOW;
4950
+ const seededWindow = resumedContextWindow !== null
4951
+ ? { size: resumedContextWindow, authoritative: true }
4952
+ : immediateContextWindow(providerCacheKey, models.currentModelId, catalogModelInfo);
4674
4953
  this.sessions[sessionId] = {
4675
4954
  query: q,
4676
4955
  input: input,
@@ -4698,9 +4977,13 @@ export class ClaudeAcpAgent {
4698
4977
  agents,
4699
4978
  currentAgent,
4700
4979
  fastModeEnabled,
4980
+ fastModeDisabledReason,
4701
4981
  abortController,
4702
4982
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
4703
- contextWindowSize,
4983
+ forwardSubagentText,
4984
+ contextWindowSize: seededWindow.size,
4985
+ contextWindowAuthoritative: seededWindow.authoritative,
4986
+ providerCacheKey,
4704
4987
  taskState,
4705
4988
  toolUseCache: {},
4706
4989
  emittedToolCalls: new Set(),
@@ -4895,7 +5178,10 @@ function buildAvailableModes(modelInfo) {
4895
5178
  // and only clears a key when an explicit `null` is sent — see
4896
5179
  // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
4897
5180
  // `"default"` sentinel and `undefined` (effort option absent for the model) to
4898
- // `null` ensures any previously-applied flag is actually cleared.
5181
+ // `null` ensures any previously-applied flag is actually cleared. Typed as
5182
+ // `EffortLevel` (not `Settings["effortLevel"]`): the picker offers whatever
5183
+ // `supportedEffortLevels` reports, which includes the session-scoped `"max"`
5184
+ // that the persisted Settings shape deliberately excludes.
4899
5185
  function toSdkEffortLevel(value) {
4900
5186
  return value === undefined || value === "default" ? null : value;
4901
5187
  }
@@ -4953,17 +5239,58 @@ const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
4953
5239
  export function fastModeStateEnabled(state) {
4954
5240
  return state !== "off";
4955
5241
  }
5242
+ /** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
5243
+ * that a user can act on (or at least wants to know about). Deliberately
5244
+ * partial — the omitted reasons are not worth surfacing:
5245
+ * - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
5246
+ * opt-in), so it describes the default, not a problem.
5247
+ * - `preference`: the user turned Fast mode off themselves.
5248
+ * - `pending`: eligibility is still resolving; the next report supersedes it.
5249
+ * - `unknown`: nothing meaningful to say.
5250
+ * Unknown future reasons fall through the same way (open set — the SDK's docs
5251
+ * say to ignore values you don't handle). */
5252
+ const FAST_MODE_UNAVAILABLE_EXPLANATIONS = {
5253
+ free: "not available on the free plan",
5254
+ extra_usage_disabled: "requires extra usage to be enabled for this account",
5255
+ model_not_allowed: "not available for the selected model",
5256
+ not_first_party: "not available on this API provider",
5257
+ disabled_by_env: "disabled by environment configuration",
5258
+ network_error: "eligibility could not be verified (network error)",
5259
+ };
5260
+ /** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
5261
+ * a reason we have an explanation for, else `undefined`. Keeping only
5262
+ * explainable reasons means state comparisons (see `syncFastModeState`) track
5263
+ * exactly what the user can see, so routine reports like
5264
+ * `sdk_opt_in_required` never churn the config option. */
5265
+ export function normalizeFastModeDisabledReason(reason) {
5266
+ return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
5267
+ }
4956
5268
  /** Build the Fast mode config option as a two-value on/off `select`. Emitted
4957
- * for EVERY Client — the boolean option shape is gone (story 006, R2.1). Only
4958
- * the emitted SHAPE is fixed to a select; boolean VALUES are still honored on
4959
- * set (see {@link resolveFastModeEnabled}). This factory is the single source
4960
- * of the option's shape, re-rendered by `refreshFastModeOption` /
4961
- * `syncFastModeState` so the shape can never desync. */
4962
- export function createFastModeConfigOption(enabled) {
5269
+ * for EVERY Client — the boolean option shape is gone (story 006, R2.1;
5270
+ * retained through the v0.64.0 sync by story 008 R3.4). Only the emitted SHAPE
5271
+ * is fixed to a select; boolean VALUES are still honored on set (see
5272
+ * {@link resolveFastModeEnabled}). This factory is the single source of the
5273
+ * option's shape, re-rendered by `refreshFastModeOption` / `syncFastModeState`
5274
+ * so the shape can never desync.
5275
+ *
5276
+ * `disabledReason` (the SDK's `fast_mode_disabled_reason`, upstream v0.64.0) is
5277
+ * folded into the description while the toggle reads off, so a user whose
5278
+ * account or provider can't serve Fast mode sees why instead of a switch that
5279
+ * silently refuses to stay on. Ignored while enabled: a reason reported
5280
+ * alongside an `on`/`cooldown` state isn't blocking anything right now.
5281
+ *
5282
+ * Upstream's second parameter (`useBooleanOption`) is deliberately absent: the
5283
+ * shape is unconditionally a select, so there is no branch to select. What
5284
+ * guards that is behavioural, not structural — `tests/fast-mode-select-only.
5285
+ * test.ts` proves no argument combination can yield the boolean shape. */
5286
+ export function createFastModeConfigOption(enabled, disabledReason) {
5287
+ const explanation = enabled
5288
+ ? undefined
5289
+ : disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
4963
5290
  return {
4964
5291
  id: FAST_MODE_CONFIG_ID,
4965
5292
  name: "Fast mode",
4966
- description: FAST_MODE_DESCRIPTION,
5293
+ description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
4967
5294
  category: "model_config",
4968
5295
  type: "select",
4969
5296
  currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
@@ -5056,7 +5383,7 @@ thinkingEnabled) {
5056
5383
  // option is always emitted as a two-value on/off select for every Client
5057
5384
  // (R2.1); boolean values remain accepted on set for boolean-era clients.
5058
5385
  if (fastMode?.supported) {
5059
- options.push(createFastModeConfigOption(fastMode.enabled));
5386
+ options.push(createFastModeConfigOption(fastMode.enabled, fastMode.disabledReason));
5060
5387
  }
5061
5388
  // Surface the Thinking toggle whenever the caller supplies its display
5062
5389
  // state. Unlike Fast mode it is model-independent — no `supported` gate —
@@ -5397,17 +5724,26 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
5397
5724
  }
5398
5725
  /** Read the model a resumed session is actually running (via the
5399
5726
  * `getContextUsage` control request — the same source `/context` prints) and
5400
- * map it onto the picker. Best-effort: a control-request failure is logged
5401
- * and returns null so callers keep their current choice; failing the whole
5402
- * session/load over an unreadable report would be worse. */
5727
+ * map it onto the picker, along with the report's authoritative context
5728
+ * window (`rawMaxTokens`). Resumed sessions get this request serviced before
5729
+ * any turn runs in the new process unlike fresh sessions, where it stalls
5730
+ * until the first prompt turn (issues #886/#880) — so the same response that
5731
+ * restores the live model (issue #845) also seeds the window for free,
5732
+ * covering post-restart reloads of models the text heuristic misses (issue
5733
+ * #596). Best-effort: a control-request failure is logged and returns nulls
5734
+ * so callers keep their current choice; failing the whole session/load over
5735
+ * an unreadable report would be worse. */
5403
5736
  async function readResumedLiveModel(query, models, logger) {
5404
5737
  try {
5405
- const liveModel = (await query.getContextUsage()).model;
5406
- return liveModel ? matchResumedModel(models, liveModel) : null;
5738
+ const usage = await query.getContextUsage();
5739
+ return {
5740
+ model: usage.model ? matchResumedModel(models, usage.model) : null,
5741
+ contextWindow: usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null,
5742
+ };
5407
5743
  }
5408
5744
  catch (error) {
5409
5745
  logger.error("Failed to read the resumed session's live model:", error);
5410
- return null;
5746
+ return { model: null, contextWindow: null };
5411
5747
  }
5412
5748
  }
5413
5749
  async function getAvailableModels(query,
@@ -5423,6 +5759,11 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
5423
5759
  const settings = settingsManager.getSettings();
5424
5760
  let currentModel = models[0];
5425
5761
  let resolvedFromInput;
5762
+ // The context window reported alongside a resumed session's live model.
5763
+ // Only ever non-null on the paths where `currentModel` IS the live model
5764
+ // (no override, or a failed override re-assert), so the window always
5765
+ // describes the model the session actually runs.
5766
+ let resumedContextWindow = null;
5426
5767
  // Model priority (highest to lowest):
5427
5768
  // 1. ANTHROPIC_MODEL environment variable
5428
5769
  // 2. settings.model (user configuration)
@@ -5450,7 +5791,9 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
5450
5791
  // the SDK is already running this model, and pushing a picker alias back
5451
5792
  // (e.g. "opus[1m]") could change the live model rather than describe it.
5452
5793
  if (resolvedFromInput === undefined && isResumedSession) {
5453
- currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
5794
+ const live = await readResumedLiveModel(query, models, logger);
5795
+ currentModel = live.model ?? currentModel;
5796
+ resumedContextWindow = live.contextWindow;
5454
5797
  }
5455
5798
  // Skip the setModel round-trip when we can prove the SDK has already landed
5456
5799
  // on the same model. Two cases qualify:
@@ -5483,16 +5826,25 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
5483
5826
  if (!isResumedSession)
5484
5827
  throw error;
5485
5828
  logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
5486
- currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
5829
+ const live = await readResumedLiveModel(query, models, logger);
5830
+ currentModel = live.model ?? currentModel;
5831
+ resumedContextWindow = live.contextWindow;
5487
5832
  }
5488
5833
  }
5489
5834
  return {
5490
- availableModels: pickerModels.map((model) => ({
5491
- modelId: model.value,
5492
- name: model.displayName,
5493
- description: model.description,
5494
- })),
5495
- currentModelId: currentModel.value,
5835
+ modelState: {
5836
+ // Picker rows come from the DEPRECATION-FILTERED list (`pickerModels`),
5837
+ // not the raw catalog upstream maps here: the filter is visibility-only,
5838
+ // so preference resolution and capability lookups elsewhere keep reading
5839
+ // the unfiltered catalog (R4.3).
5840
+ availableModels: pickerModels.map((model) => ({
5841
+ modelId: model.value,
5842
+ name: model.displayName,
5843
+ description: model.description,
5844
+ })),
5845
+ currentModelId: currentModel.value,
5846
+ },
5847
+ resumedContextWindow,
5496
5848
  };
5497
5849
  }
5498
5850
  function getAvailableSlashCommands(commands) {
@@ -5630,6 +5982,12 @@ export function promptToClaude(prompt) {
5630
5982
  },
5631
5983
  session_id: prompt.sessionId,
5632
5984
  parent_tool_use_id: null,
5985
+ // ACP prompts are the user's own input relayed by the client. Stamp the
5986
+ // provenance explicitly: per the SDK, a host wrapping keyboard input must
5987
+ // send `{kind: "human"}` — an absent `origin` is treated as unattributed
5988
+ // and fails closed at the CLI's strict isHuman() trust gates (e.g. the
5989
+ // ultracode keyword opt-in honors only human-originated turns).
5990
+ origin: { kind: "human" },
5633
5991
  };
5634
5992
  }
5635
5993
  /**
@@ -5686,6 +6044,24 @@ function isTaskTool(toolName) {
5686
6044
  function shouldEmitToolCall(toolName) {
5687
6045
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
5688
6046
  }
6047
+ /** Build the Claude Code-specific metadata for a tool call. Bash descriptions
6048
+ * are kept out of ACP's standard `title`, which clients may use as the shell
6049
+ * command preview, while still giving clients access to Claude's concise
6050
+ * human-readable title. */
6051
+ function claudeCodeMetaFromToolUse(toolUse) {
6052
+ const description = toolUse.name === "Bash" &&
6053
+ toolUse.input !== null &&
6054
+ typeof toolUse.input === "object" &&
6055
+ "description" in toolUse.input &&
6056
+ typeof toolUse.input.description === "string"
6057
+ ? toolUse.input.description
6058
+ : undefined;
6059
+ return {
6060
+ toolName: toolUse.name,
6061
+ ...(description ? { title: description } : {}),
6062
+ ...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
6063
+ };
6064
+ }
5689
6065
  /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
5690
6066
  * notification for a tool_use. Shared by every site that surfaces a tool call:
5691
6067
  * the streamed tool_use path (first encounter → tool_call, later encounter →
@@ -5696,7 +6072,7 @@ function shouldEmitToolCall(toolName) {
5696
6072
  function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
5697
6073
  if (refine) {
5698
6074
  return {
5699
- _meta: { claudeCode: { toolName: toolUse.name } },
6075
+ _meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
5700
6076
  toolCallId: toolUse.id,
5701
6077
  sessionUpdate: "tool_call_update",
5702
6078
  rawInput,
@@ -5705,7 +6081,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
5705
6081
  }
5706
6082
  return {
5707
6083
  _meta: {
5708
- claudeCode: { toolName: toolUse.name },
6084
+ claudeCode: claudeCodeMetaFromToolUse(toolUse),
5709
6085
  ...(toolUse.name === "Bash" && supportsTerminalOutput
5710
6086
  ? { terminal_info: { terminal_id: toolUse.id } }
5711
6087
  : {}),
@@ -5730,7 +6106,9 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
5730
6106
  }
5731
6107
  const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
5732
6108
  return {
5733
- _meta: { claudeCode: { toolName: toolUse.name } },
6109
+ _meta: {
6110
+ claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
6111
+ },
5734
6112
  toolCallId: toolUse.id,
5735
6113
  sessionUpdate: "tool_call_update",
5736
6114
  rawInput: input,
@@ -5739,6 +6117,33 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
5739
6117
  ...(locations ? { locations } : {}),
5740
6118
  };
5741
6119
  }
6120
+ /** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
6121
+ * wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
6122
+ * by-tool_use_id lookup. Each entry explains why an is_error tool_result
6123
+ * carries harness prose instead of the tool's own output — "user-rejected",
6124
+ * "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
6125
+ * on the wire ahead of schema updates, so no enum check). Malformed entries
6126
+ * are skipped rather than failing the message. */
6127
+ function parseToolResultMeta(raw) {
6128
+ if (!Array.isArray(raw)) {
6129
+ return undefined;
6130
+ }
6131
+ let byToolUseId;
6132
+ for (const entry of raw) {
6133
+ if (typeof entry !== "object" || entry === null) {
6134
+ continue;
6135
+ }
6136
+ const { id, non_execution_kind, user_feedback } = entry;
6137
+ if (typeof id !== "string" || typeof non_execution_kind !== "string") {
6138
+ continue;
6139
+ }
6140
+ (byToolUseId ??= new Map()).set(id, {
6141
+ nonExecutionKind: non_execution_kind,
6142
+ ...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
6143
+ });
6144
+ }
6145
+ return byToolUseId;
6146
+ }
5742
6147
  /**
5743
6148
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
5744
6149
  * Only handles text, image, and thinking chunks for now.
@@ -5779,6 +6184,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
5779
6184
  .length === 1
5780
6185
  ? options.toolUseResult
5781
6186
  : undefined;
6187
+ // Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
6188
+ // messages need no single-block guard.
6189
+ const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
5782
6190
  const output = [];
5783
6191
  // Only handle the first chunk for streaming; extend as needed for batching
5784
6192
  for (const chunk of content) {
@@ -5922,6 +6330,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
5922
6330
  case "mcp_tool_result": {
5923
6331
  const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
5924
6332
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
6333
+ // Why this is_error result carries harness prose instead of tool
6334
+ // output (user-rejected / interrupted / …), when the SDK said so.
6335
+ // Spread into the claudeCode meta of every update emitted below; the
6336
+ // untracked-tool fallback can't carry it (claudeCode metas always
6337
+ // carry `toolName`, which is unknown there).
6338
+ const nonExecution = toolResultMeta?.get(chunk.tool_use_id);
5925
6339
  const toolUse = toolUseCache[chunk.tool_use_id];
5926
6340
  if (!toolUse) {
5927
6341
  // The permission flow may have surfaced this tool_call even though
@@ -5961,6 +6375,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
5961
6375
  _meta: {
5962
6376
  claudeCode: {
5963
6377
  toolName: toolUse.name,
6378
+ ...(nonExecution ?? {}),
5964
6379
  ...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
5965
6380
  },
5966
6381
  },
@@ -6021,6 +6436,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
6021
6436
  _meta: {
6022
6437
  claudeCode: {
6023
6438
  toolName: toolUse.name,
6439
+ ...(nonExecution ?? {}),
6024
6440
  },
6025
6441
  ...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
6026
6442
  },
@@ -6235,6 +6651,7 @@ export function runAcp() {
6235
6651
  .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
6236
6652
  .onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
6237
6653
  .onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
6654
+ .onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
6238
6655
  .connect(stream);
6239
6656
  agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
6240
6657
  return { connection, agent };
@@ -6246,9 +6663,9 @@ function commonPrefixLength(a, b) {
6246
6663
  }
6247
6664
  return i;
6248
6665
  }
6249
- /** Best-effort first guess of a model's context window, used only as a
6250
- * fallback when the SDK's authoritative `getContextUsage` is unavailable (and
6251
- * until a `result` message arrives with the `modelUsage` value).
6666
+ /** Best-effort first guess of a model's context window, used to seed the
6667
+ * window synchronously (via `immediateContextWindow`) until a `result` message
6668
+ * arrives with the authoritative `modelUsage` value.
6252
6669
  *
6253
6670
  * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
6254
6671
  * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
@@ -6258,9 +6675,12 @@ function commonPrefixLength(a, b) {
6258
6675
  * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
6259
6676
  * This text scan can't catch every model — some resolve to extended-context
6260
6677
  * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
6261
- * ~1M) which is why `fetchContextWindowSize` is preferred wherever a live
6262
- * query is available. A miss falls back to the default window and is
6263
- * corrected by `result.modelUsage` within one turn. */
6678
+ * ~1M). Such a miss falls back to the default window and is corrected by
6679
+ * `result.modelUsage` (and cached) within one turn. We do NOT consult the
6680
+ * SDK's `getContextUsage` to close that gap: on a fresh session it is not
6681
+ * serviced before the first prompt turn (issues #886/#880, see
6682
+ * `contextWindowCache`; resumed sessions do get it, via
6683
+ * `readResumedLiveModel`). */
6264
6684
  function inferContextWindowFromModel(...texts) {
6265
6685
  if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
6266
6686
  return 1_000_000;
@@ -6281,28 +6701,95 @@ async function fetchContextUsedTokens(query, logger) {
6281
6701
  return null;
6282
6702
  }
6283
6703
  }
6284
- /** Fetch the current model's full context window (`rawMaxTokens`) via the
6285
- * `getContextUsage` control request — the same source `/context` prints.
6286
- * This is the only pre-`result` signal that covers semantic aliases whose
6287
- * text carries no "1m" token (e.g. `sonnet` → claude-sonnet-5, natively
6288
- * ~1M), so it's the primary window source at session creation and on model
6289
- * switches; `inferContextWindowFromModel` remains the fallback. A returned
6290
- * window is still superseded by each `result.modelUsage.contextWindow`.
6704
+ /** Cross-session cache of authoritative context windows, keyed by
6705
+ * `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
6706
+ * The window is a property of (model id, backend): the same resolved model id
6707
+ * (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys)
6708
+ * can name different context lanes behind different base URLs, routing
6709
+ * headers, or credentials, so the key carries both. Caching it module-level
6710
+ * lets a later session/new or switch that resolves to the same (backend,
6711
+ * model) — in this session or any other, within the adapter's lifetime — seed
6712
+ * the correct window synchronously with no IPC. Keying on the resolved id
6713
+ * (rather than the picker value) means aliases that resolve to the same
6714
+ * concrete model share one entry; the result handler additionally writes the
6715
+ * bare assistant-message spelling so seed-time reads that fall back to a
6716
+ * verbatim live id (rows without `resolvedModel`) can hit too.
6291
6717
  *
6292
- * (Older CLIs under-reported extended 1M windows here commit 20ef663
6293
- * dropped the field for that reason but the CLI vendored by the pinned
6294
- * SDK reports them correctly again.) Returns `null` on any control-request
6295
- * failure or a nonsensical (non-positive) window. */
6296
- async function fetchContextWindowSize(query, logger) {
6297
- try {
6298
- const usage = await query.getContextUsage();
6299
- return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null;
6300
- }
6301
- catch (error) {
6302
- logger.error("Failed to fetch context window size from SDK:", error);
6303
- return null;
6718
+ * Populated authoritatively by each `result.modelUsage` a turn confirms (see
6719
+ * the consumer's result handler). We deliberately never populate it from a
6720
+ * fresh session's `getContextUsage`: before that session's first prompt turn
6721
+ * has run the control request is not serviced (it stalls ~15s, and serializes
6722
+ * ahead of an awaited `setModel` — issues #886/#880, regressed in 0.59.0), so
6723
+ * it can neither beat the first `result` nor be issued cheaply before one.
6724
+ * Resumed sessions are the exception — their report IS serviced pre-turn, and
6725
+ * the session/load path seeds (but does not cache) the window from the same
6726
+ * response that restores the live model, see `readResumedLiveModel`.
6727
+ * Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
6728
+ * windows learned under one login must not seed sessions under the next. */
6729
+ const contextWindowCache = new Map();
6730
+ /** The env vars that determine which LLM backend — and which context lane on
6731
+ * it — a query's API traffic reaches: endpoint selection (base URLs and the
6732
+ * Bedrock/Vertex switches with their project/region), routing/beta headers
6733
+ * (an `anthropic-beta: context-1m-…` header flips the same model id at the
6734
+ * same endpoint between context lanes), and credential identity (extended
6735
+ * context is entitlement-gated per account). Used to derive the
6736
+ * provider-cache key from the exact env a query is created with, so
6737
+ * `providers/set` config, per-session `_meta` env overrides, and ambient
6738
+ * process env are all distinguished exactly as the CLI will see them. */
6739
+ const PROVIDER_ROUTING_ENV_VARS = [
6740
+ "ANTHROPIC_BASE_URL",
6741
+ "ANTHROPIC_BEDROCK_BASE_URL",
6742
+ "ANTHROPIC_VERTEX_BASE_URL",
6743
+ "CLAUDE_CODE_USE_BEDROCK",
6744
+ "CLAUDE_CODE_USE_VERTEX",
6745
+ "ANTHROPIC_VERTEX_PROJECT_ID",
6746
+ "CLOUD_ML_REGION",
6747
+ "AWS_REGION",
6748
+ "ANTHROPIC_CUSTOM_HEADERS",
6749
+ "ANTHROPIC_API_KEY",
6750
+ "ANTHROPIC_AUTH_TOKEN",
6751
+ ];
6752
+ /** Stable identifier for the LLM backend a session's query is created against,
6753
+ * used to scope {@link contextWindowCache} per backend. Positional `\0`-join
6754
+ * of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade
6755
+ * as another and unset vars everywhere yield one stable "default" bucket.
6756
+ * Header/credential values can be secrets; the key only ever lives as an
6757
+ * in-memory Map key and is never logged or surfaced. Over-keying is the safe
6758
+ * side: a var change that didn't really change the backend costs one cache
6759
+ * miss (heuristic seed until the next result), while under-keying would serve
6760
+ * one backend's window for another's. */
6761
+ function providerCacheKeyFor(env) {
6762
+ return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0");
6763
+ }
6764
+ /** Compose the `contextWindowCache` key from a session's provider key and a
6765
+ * model id. `\0`-joined so the model segment can't collide with a provider
6766
+ * segment. */
6767
+ function contextWindowCacheKey(providerCacheKey, modelId) {
6768
+ return `${providerCacheKey}\0${modelId}`;
6769
+ }
6770
+ function cacheContextWindow(modelKey, window) {
6771
+ if (window > 0) {
6772
+ contextWindowCache.set(modelKey, window);
6304
6773
  }
6305
6774
  }
6775
+ /** The context window to report *right now* for a model, with NO IPC on the
6776
+ * critical path: the cached authoritative value if we've learned it (from a
6777
+ * prior turn's `result.modelUsage`, this or any session on the same backend),
6778
+ * else the text heuristic over the model row's identity strings, else the
6779
+ * default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`,
6780
+ * the same rule at every seed site — so read keys can't drift from the write
6781
+ * site's spelling. `authoritative` reports whether the value came from the
6782
+ * cache: an authoritative window can legitimately equal
6783
+ * DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */
6784
+ function immediateContextWindow(providerCacheKey, modelId, modelInfo) {
6785
+ const cached = contextWindowCache.get(contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId));
6786
+ if (cached !== undefined)
6787
+ return { size: cached, authoritative: true };
6788
+ return {
6789
+ size: inferContextWindowFromModel(modelId, modelInfo?.resolvedModel, modelInfo?.displayName, modelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
6790
+ authoritative: false,
6791
+ };
6792
+ }
6306
6793
  function parseModelConfig(raw) {
6307
6794
  if (!raw)
6308
6795
  return undefined;
@@ -6328,6 +6815,12 @@ function getMatchingModelUsage(modelUsage, currentModel) {
6328
6815
  }
6329
6816
  }
6330
6817
  if (bestKey) {
6331
- return modelUsage[bestKey];
6818
+ // `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
6819
+ // the same spelling as ModelInfo.resolvedModel — the primary key the
6820
+ // window is cached under. `currentModel` (the assistant message's
6821
+ // `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
6822
+ // handler caches under that spelling too, for seed-time reads that fall
6823
+ // back to a bare id (rows without `resolvedModel`).
6824
+ return { key: bestKey, usage: modelUsage[bestKey] };
6332
6825
  }
6333
6826
  }