@agentclientprotocol/claude-agent-acp 0.75.2-preview.1 → 0.76.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
@@ -4,7 +4,7 @@ import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFr
4
4
  import { sanitizeTitle, SessionTitles } from "./session-titles.js";
5
5
  import { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, asSdkSessionNotification, clientSupportsSubagents, } from "./acp-subagents.js";
6
6
  import { isNativeSubagentControlTool, isNativeSubagentControlUpdate, NativeSubagentRuntime, } from "./native-subagents.js";
7
- import { AIR_ASYNC_TASKS_CAPABILITY, withAirMeta } from "./air-extension.js";
7
+ import { AIR_ASYNC_TASKS_CAPABILITY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, clientSupportsAirCapability, withAirMeta, } from "./air-extension.js";
8
8
  import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, clientSupportsAsyncTasks, } from "./async-tasks.js";
9
9
  import { AUTH_STATUS_PROBE_TIMEOUT_MS, AUTH_STATUS_UPDATE_METHOD, authStatusCapability, fromAccountInfo, fromCliStatus, gatewayAuthStatus, mergeAuthStatus, notLoggedInAuthStatus, sameAuthStatus, } from "./auth-status.js";
10
10
  import { execFile } from "node:child_process";
@@ -32,11 +32,15 @@ import { ContextCompactionLifecycle, contextCompactionMetadataFromBoundary, } fr
32
32
  import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, unregisterHookCallback, } from "./tools.js";
33
33
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
34
34
  import { acceptedPlanToolResult, ExitPlanCoordinator, executionDiagnostic, exitPlanModeRawOutput, observeExitPlanToolResults, } from "./exit-plan.js";
35
- import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
35
+ import { DEFAULT_AGENT_ID } from "./session-config-ids.js";
36
36
  import { parseToolResultMeta } from "./tool-result-meta.js";
37
37
  import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
38
- export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
38
+ export { DEFAULT_AGENT_ID } from "./session-config-ids.js";
39
39
  import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
40
+ import { applyAvailableModelsAllowlist, buildModelConfigOption, getAvailableModels, MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js";
41
+ import { buildEffortConfigOption, EFFORT_CONFIG_ID, mergeEffortSettings, settingsEffortForModel, toSdkEffortLevel, } from "./session-effort.js";
42
+ export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
43
+ export { applyAvailableModelsAllowlist, matchResumedModel, MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js";
40
44
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
41
45
  const execFileAsync = promisify(execFile);
42
46
  const MAX_INLINE_FAILURE_TITLE_LENGTH = 256;
@@ -940,7 +944,7 @@ export class ClaudeAcpAgent {
940
944
  // steering extension contract: advertises the `_session/steering` request
941
945
  // so clients know they may inject a follow-up into a running turn.
942
946
  _meta: {
943
- ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY),
947
+ ...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, AIR_ASYNC_TASKS_CAPABILITY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY),
944
948
  steering: {
945
949
  supported: true,
946
950
  },
@@ -5521,9 +5525,19 @@ export class ClaudeAcpAgent {
5521
5525
  // along.
5522
5526
  const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
5523
5527
  const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
5524
- const seedEffort = session.effortPinnedByUser
5525
- ? currentEffort
5526
- : settingsEffortForModel(session.settingsManager.getSettings(), newModelInfo, value);
5528
+ const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities);
5529
+ const pinnedEffort = session.effortPinnedLevel;
5530
+ const effortWasPinned = pinnedEffort !== undefined;
5531
+ const appliedEffortBeforeSwitch = session.appliedEffortLevel ??
5532
+ (useRecommendedValue && typeof currentEffort === "string" && currentEffort !== "default"
5533
+ ? currentEffort
5534
+ : pinnedEffort);
5535
+ const effortPinnedForNewModel = effortWasPinned &&
5536
+ newModelInfo?.supportsEffort === true &&
5537
+ newModelInfo.supportedEffortLevels?.some((level) => level === pinnedEffort) === true;
5538
+ const seedEffort = effortPinnedForNewModel
5539
+ ? pinnedEffort
5540
+ : settingsEffortForModel(mergeEffortSettings(session.settingsManager.getSettings(), session.effortSettingsOverride), newModelInfo, value);
5527
5541
  session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, seedEffort, session.agents, session.currentAgent, {
5528
5542
  // The toggle follows the newly selected model: it disappears when the
5529
5543
  // model lacks fast support and reappears (with the retained user
@@ -5532,26 +5546,65 @@ export class ClaudeAcpAgent {
5532
5546
  enabled: session.fastModeEnabled,
5533
5547
  useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
5534
5548
  disabledReason: session.fastModeDisabledReason,
5549
+ }, {
5550
+ useRecommendedValue,
5535
5551
  });
5536
- // Sync effort with the SDK only when a user pin changed across the
5552
+ // Opted-in clients apply the concrete displayed effort on every switch,
5553
+ // including settings-derived values, so a previous automatic flag cannot
5554
+ // shadow the new model's settings. This does not create a user pin.
5555
+ // For legacy clients, sync only when a user pin changed across the
5537
5556
  // switch — i.e. the new model clamped it away (buildConfigOptions
5538
5557
  // validated the seed against the new model's levels), where the flag
5539
5558
  // must be cleared too or the SDK would keep running the old pin
5540
5559
  // invisibly. Settings-derived seeds are display-only: the CLI resolves
5541
5560
  // persisted effort itself, and pinning it at the flag layer would
5542
5561
  // shadow the per-model values on every later switch.
5543
- if (session.effortPinnedByUser) {
5562
+ const shouldSyncEffort = useRecommendedValue || (effortWasPinned && !effortPinnedForNewModel);
5563
+ if (shouldSyncEffort) {
5544
5564
  const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
5545
5565
  const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
5546
- if (newEffort !== currentEffort) {
5566
+ try {
5547
5567
  await session.query.applyFlagSettings({
5548
- effortLevel: toSdkEffortLevel(newEffort),
5568
+ // A legacy client's unpinned effort is display-only: the CLI
5569
+ // resolves the persisted value for the new model. When an old
5570
+ // user pin is no longer supported, clear the flag layer instead
5571
+ // of replacing it with that displayed value. Opted-in clients
5572
+ // deliberately apply their concrete displayed effort.
5573
+ effortLevel: useRecommendedValue ? toSdkEffortLevel(newEffort) : null,
5549
5574
  });
5550
- if (newEffort === undefined || newEffort === "default") {
5551
- session.effortPinnedByUser = false;
5575
+ session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined;
5576
+ session.appliedEffortLevel =
5577
+ useRecommendedValue && newEffort !== "default" ? newEffort : undefined;
5578
+ }
5579
+ catch (error) {
5580
+ // setModel has already succeeded. Effort synchronization is a
5581
+ // secondary, best-effort operation: propagating this error would
5582
+ // make the RPC report failure (or suppress an external-switch
5583
+ // notification) even though the SDK is already on the new model.
5584
+ // Preserve the old SDK/pin bookkeeping and never advertise the
5585
+ // unapplied value: restore the last applied value when the new model
5586
+ // can select it, otherwise omit the effort option until a later
5587
+ // successful switch rebuilds it.
5588
+ session.effortPinnedLevel = pinnedEffort;
5589
+ session.appliedEffortLevel = appliedEffortBeforeSwitch;
5590
+ const appliedValueStillSelectable = appliedEffortBeforeSwitch !== undefined &&
5591
+ newEffortOpt?.type === "select" &&
5592
+ newEffortOpt.options.some((option) => "value" in option
5593
+ ? option.value === appliedEffortBeforeSwitch
5594
+ : option.options.some((nested) => nested.value === appliedEffortBeforeSwitch));
5595
+ if (newEffortOpt?.type === "select" && appliedValueStillSelectable) {
5596
+ newEffortOpt.currentValue = appliedEffortBeforeSwitch;
5552
5597
  }
5598
+ else {
5599
+ session.configOptions = session.configOptions.filter((option) => option.id !== EFFORT_CONFIG_ID);
5600
+ }
5601
+ this.logger.error(`Failed to synchronize effort after model switch to "${value}":`, error);
5553
5602
  }
5554
5603
  }
5604
+ else if (effortPinnedForNewModel) {
5605
+ session.effortPinnedLevel = pinnedEffort;
5606
+ session.appliedEffortLevel = pinnedEffort;
5607
+ }
5555
5608
  // Emit current_mode_update only after session.modes AND
5556
5609
  // session.configOptions have been fully reconciled. This way, a failure
5557
5610
  // in the configOptions/effort rebuild above can't leave the client with
@@ -5574,17 +5627,21 @@ export class ClaudeAcpAgent {
5574
5627
  session.currentAgent = value;
5575
5628
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
5576
5629
  }
5630
+ else if (configId === EFFORT_CONFIG_ID) {
5631
+ // Apply first so a rejected control request cannot leave the displayed
5632
+ // value ahead of the SDK flag layer.
5633
+ await session.query.applyFlagSettings({
5634
+ effortLevel: toSdkEffortLevel(value),
5635
+ });
5636
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
5637
+ session.appliedEffortLevel = value !== "default" ? value : undefined;
5638
+ // "Default" clears the flag layer (toSdkEffortLevel → null), handing
5639
+ // effort back to the CLI's persisted per-model resolution — so it
5640
+ // un-pins; any other pick pins effort for the session.
5641
+ session.effortPinnedLevel = value !== "default" ? value : undefined;
5642
+ }
5577
5643
  else {
5578
5644
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
5579
- if (configId === EFFORT_CONFIG_ID) {
5580
- await session.query.applyFlagSettings({
5581
- effortLevel: toSdkEffortLevel(value),
5582
- });
5583
- // "Default" clears the flag layer (toSdkEffortLevel → null), handing
5584
- // effort back to the CLI's persisted per-model resolution — so it
5585
- // un-pins; any other pick pins effort for the session.
5586
- session.effortPinnedByUser = value !== "default";
5587
- }
5588
5645
  }
5589
5646
  }
5590
5647
  /** Reconcile adapter model state after the SDK switched the session's
@@ -5927,14 +5984,15 @@ export class ClaudeAcpAgent {
5927
5984
  ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
5928
5985
  }
5929
5986
  : undefined);
5987
+ const configuredSettingsObject = typeof configuredSettings === "string"
5988
+ ? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
5989
+ : configuredSettings;
5930
5990
  // Claude Code applies env from settings.json after the subprocess env. Put
5931
5991
  // an active ACP route in the programmatic settings tier too so user/project
5932
5992
  // settings cannot silently restore a different ANTHROPIC_BASE_URL.
5933
5993
  let settings = configuredSettings;
5934
5994
  if (resolvedProvider) {
5935
- const baseSettings = typeof configuredSettings === "string"
5936
- ? JSON.parse(await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"))
5937
- : configuredSettings;
5995
+ const baseSettings = configuredSettingsObject;
5938
5996
  settings = {
5939
5997
  ...baseSettings,
5940
5998
  apiKeyHelper: "",
@@ -6248,15 +6306,19 @@ export class ClaudeAcpAgent {
6248
6306
  useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
6249
6307
  disabledReason: fastModeDisabledReason,
6250
6308
  };
6251
- // The Effort picker seed mirrors what the CLI itself resolves from the
6252
- // persisted settings — the current model's `modelSettings` entry first
6253
- // (the CLI persists /effort per model), then the legacy top-level value.
6254
- // Display-only: no applyFlagSettings here. The CLI reads the same
6255
- // settings, so pinning the seed at the flag layer was always redundant —
6256
- // and it would override the CLI's own per-model restore on every later
6257
- // model switch. Only a user's explicit ACP picker choice pins the flag
6258
- // (see applyConfigOptionValue / Session.effortPinnedByUser).
6259
- const configOptions = buildConfigOptions(modes, models, modelInfos, settingsEffortForModel(settingsManager.getSettings(), currentModelInfo), agents, currentAgent, fastMode);
6309
+ // Concrete effort must also be applied to the SDK: its automatic default
6310
+ // need not be our "medium" recommendation. Preserve explicit SDK options
6311
+ // and persisted settings; automatic values re-seed on every model switch.
6312
+ // Legacy clients still leave effort resolution entirely to the CLI.
6313
+ const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities);
6314
+ const configOptions = buildConfigOptions(modes, models, modelInfos, userProvidedOptions?.effort ??
6315
+ settingsEffortForModel(mergeEffortSettings(settingsManager.getSettings(), configuredSettingsObject), currentModelInfo), agents, currentAgent, fastMode, {
6316
+ useRecommendedValue,
6317
+ });
6318
+ const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
6319
+ if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
6320
+ await q.applyFlagSettings({ effortLevel: toSdkEffortLevel(initialEffort.currentValue) });
6321
+ }
6260
6322
  // Seed the context window without extra IPC. The cached authoritative
6261
6323
  // window from a prior turn wins (`result.modelUsage`, cross-session),
6262
6324
  // then the text heuristic, then the default. We deliberately do NOT issue
@@ -6287,6 +6349,7 @@ export class ClaudeAcpAgent {
6287
6349
  sessionFingerprint: computeSessionFingerprint(params),
6288
6350
  creationParams: params,
6289
6351
  settingsManager,
6352
+ effortSettingsOverride: configuredSettingsObject,
6290
6353
  titles: new SessionTitles(this, sessionId),
6291
6354
  accumulatedUsage: {
6292
6355
  inputTokens: 0,
@@ -6302,6 +6365,16 @@ export class ClaudeAcpAgent {
6302
6365
  autoModeFallbackWarningShown: false,
6303
6366
  autoModeFallbackWarningPending,
6304
6367
  configOptions,
6368
+ effortPinnedLevel: userProvidedOptions?.effort !== undefined &&
6369
+ initialEffort?.currentValue === userProvidedOptions.effort
6370
+ ? userProvidedOptions.effort
6371
+ : undefined,
6372
+ appliedEffortLevel: useRecommendedValue && typeof initialEffort?.currentValue === "string"
6373
+ ? initialEffort.currentValue
6374
+ : userProvidedOptions?.effort !== undefined &&
6375
+ initialEffort?.currentValue === userProvidedOptions.effort
6376
+ ? userProvidedOptions.effort
6377
+ : undefined,
6305
6378
  agents,
6306
6379
  currentAgent,
6307
6380
  fastModeEnabled,
@@ -6696,39 +6769,6 @@ function isValidBaseUrl(baseUrl) {
6696
6769
  }
6697
6770
  return parsed.protocol === "http:" || parsed.protocol === "https:";
6698
6771
  }
6699
- // Translate a UI effort value into the flag-layer payload. The SDK
6700
- // shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
6701
- // and only clears a key when an explicit `null` is sent — see
6702
- // `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
6703
- // `"default"` sentinel and `undefined` (effort option absent for the model) to
6704
- // `null` ensures any previously-applied flag is actually cleared. Typed as
6705
- // `EffortLevel` (not `Settings["effortLevel"]`): the picker offers whatever
6706
- // `supportedEffortLevels` reports, which includes the session-scoped `"max"`
6707
- // that the persisted Settings shape deliberately excludes.
6708
- function toSdkEffortLevel(value) {
6709
- return value === undefined || value === "default" ? null : value;
6710
- }
6711
- /** The effort level the CLI itself resolves for a model from the persisted
6712
- * settings: the per-model entry (`modelSettings`, keyed by canonical model
6713
- * name — the CLI persists /effort per model since 2.1.243) wins over the
6714
- * legacy top-level `effortLevel`. Alias picker rows are looked up by their
6715
- * resolved model id first, then the row value, then the raw config value
6716
- * for models not in the picker. Exact keys only — the CLI's canonical-form
6717
- * fallback matching isn't replicated, so a miss simply falls back to the
6718
- * top-level value (best-effort display; `buildConfigOptions` still
6719
- * validates the result against the model's supported levels). */
6720
- export function settingsEffortForModel(settings, modelInfo, modelId) {
6721
- const modelSettings = settings.modelSettings;
6722
- if (modelSettings) {
6723
- for (const key of [modelInfo?.resolvedModel, modelInfo?.value, modelId]) {
6724
- const perModel = key !== undefined ? modelSettings[key]?.effortLevel : undefined;
6725
- if (typeof perModel === "string") {
6726
- return perModel;
6727
- }
6728
- }
6729
- }
6730
- return settings.effortLevel;
6731
- }
6732
6772
  // `supportedAgents()` always returns Claude Code's built-in subagents — the
6733
6773
  // ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
6734
6774
  // has configured none of their own. Those aren't meaningful *main-thread*
@@ -6765,7 +6805,6 @@ export async function discoverCustomAgents(q) {
6765
6805
  * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
6766
6806
  * same identifiers and can't drift apart. */
6767
6807
  export { MODE_CONFIG_ID };
6768
- export const MODEL_CONFIG_ID = "model";
6769
6808
  export const AGENT_CONFIG_ID = "agent";
6770
6809
  export const FAST_MODE_CONFIG_ID = "fast";
6771
6810
  /** Select-fallback values used when the client has not opted into boolean
@@ -6813,6 +6852,12 @@ export function normalizeFastModeDisabledReason(reason) {
6813
6852
  export function clientSupportsBooleanConfigOptions(clientCapabilities) {
6814
6853
  return clientCapabilities?.session?.configOptions?.boolean != null;
6815
6854
  }
6855
+ /** Whether the Client advertised the AIR `recommendedValue` capability for
6856
+ * select-style model and effort options. Missing or malformed AIR metadata
6857
+ * retains the legacy `default` entries. */
6858
+ export function clientSupportsRecommendedConfigValue(clientCapabilities) {
6859
+ return clientSupportsAirCapability(clientCapabilities, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY);
6860
+ }
6816
6861
  /** Build the Fast mode config option. When the Client supports boolean config
6817
6862
  * options we expose a native `type: "boolean"` toggle; otherwise we degrade to
6818
6863
  * a two-value `select` ("on"/"off") so older Clients still get a usable
@@ -6862,61 +6907,14 @@ export function resolveFastModeEnabled(params) {
6862
6907
  }
6863
6908
  throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`);
6864
6909
  }
6865
- export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
6910
+ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode, presentation) {
6866
6911
  const options = [
6867
6912
  SessionModeManager.configOption(modes),
6868
- {
6869
- id: MODEL_CONFIG_ID,
6870
- name: "Model",
6871
- description: "AI model to use",
6872
- category: "model",
6873
- type: "select",
6874
- currentValue: models.currentModelId,
6875
- options: models.availableModels.map((m) => {
6876
- if (m.modelId === "default") {
6877
- const defaultInfo = modelInfos.find((mi) => mi.value === "default");
6878
- const resolvedModel = defaultInfo?.resolvedModel;
6879
- if (resolvedModel) {
6880
- const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
6881
- return {
6882
- value: m.modelId,
6883
- name: m.name,
6884
- description: namedMatch?.displayName ?? resolvedModel,
6885
- };
6886
- }
6887
- }
6888
- return { value: m.modelId, name: m.name, description: m.description ?? undefined };
6889
- }),
6890
- },
6913
+ buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true),
6891
6914
  ];
6892
- // Add effort level option based on the currently selected model
6893
- const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId);
6894
- const supportedLevels = currentModelInfo?.supportsEffort
6895
- ? (currentModelInfo.supportedEffortLevels ?? [])
6896
- : [];
6897
- if (supportedLevels.length > 0) {
6898
- const effortOptions = [
6899
- { value: "default", name: "Default" },
6900
- ...supportedLevels.map((level) => ({
6901
- value: level,
6902
- name: level
6903
- .split(/[_-]/)
6904
- .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
6905
- .join(" "),
6906
- })),
6907
- ];
6908
- const includes = (l) => l === "default" || supportedLevels.includes(l);
6909
- const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
6910
- options.push({
6911
- id: EFFORT_CONFIG_ID,
6912
- name: "Effort",
6913
- description: "Available effort levels for this model",
6914
- category: "thought_level",
6915
- type: "select",
6916
- currentValue: validEffort,
6917
- options: effortOptions,
6918
- });
6919
- }
6915
+ const effort = buildEffortConfigOption(modelInfos, models.currentModelId, currentEffortLevel, presentation?.useRecommendedValue === true);
6916
+ if (effort)
6917
+ options.push(effort);
6920
6918
  // Surface the Fast mode toggle only when the current model supports it. The
6921
6919
  // option renders as a native boolean toggle for Clients that opted in, and a
6922
6920
  // two-value select otherwise.
@@ -6946,369 +6944,6 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
6946
6944
  }
6947
6945
  return options;
6948
6946
  }
6949
- // Claude Code CLI persists display strings like "opus[1m]" in settings,
6950
- // but the SDK model list uses IDs like "claude-opus-4-6-1m".
6951
- const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
6952
- // The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m");
6953
- // shared by the strip and canonicalize helpers below so the two can't drift.
6954
- const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
6955
- /** Remove context-window hints — the display form "[1m]" and the SDK id
6956
- * suffix form "-1m" — from a model string. Those digits describe context
6957
- * size, not model identity or generation version. */
6958
- function stripContextHints(s) {
6959
- return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
6960
- }
6961
- /** Canonicalize a model id for exact comparison: trimmed, lowercased, with
6962
- * the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]").
6963
- * The hint itself is kept — bare and 1M ids must stay distinct. */
6964
- function canonicalizeModelId(s) {
6965
- return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
6966
- }
6967
- /** The context hint a model string carries ("1m" for either spelling), or
6968
- * null for a bare id. */
6969
- function contextHintOf(s) {
6970
- return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
6971
- }
6972
- // Captures a model family version: `4-6`/`4.7` for dated generations, or a
6973
- // bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
6974
- // `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
6975
- const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
6976
- function extractModelFamilyVersion(s) {
6977
- const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN);
6978
- if (!match)
6979
- return null;
6980
- return match[2] ? `${match[1]}.${match[2]}` : match[1];
6981
- }
6982
- function modelVersionsCompatible(preference, candidate) {
6983
- const preferred = extractModelFamilyVersion(preference);
6984
- if (!preferred)
6985
- return true;
6986
- const candidateVersion = extractModelFamilyVersion(candidate.value) ??
6987
- extractModelFamilyVersion(candidate.displayName) ??
6988
- extractModelFamilyVersion(candidate.description);
6989
- if (!candidateVersion)
6990
- return true;
6991
- return preferred === candidateVersion;
6992
- }
6993
- function tokenizeModelPreference(model) {
6994
- const lower = model.trim().toLowerCase();
6995
- const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
6996
- const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ");
6997
- const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
6998
- const tokens = rawTokens
6999
- .map((token) => {
7000
- if (token === "opusplan")
7001
- return "opus";
7002
- if (token === "best" || token === "default")
7003
- return "";
7004
- return token;
7005
- })
7006
- .filter((token) => token && token !== "claude")
7007
- .filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
7008
- return { tokens, contextHint };
7009
- }
7010
- function scoreModelMatch(model, tokens, contextHint) {
7011
- const haystack = `${model.value} ${model.displayName}`.toLowerCase();
7012
- let score = 0;
7013
- let nonHintMatched = false;
7014
- for (const token of tokens) {
7015
- if (haystack.includes(token)) {
7016
- if (token !== contextHint)
7017
- nonHintMatched = true;
7018
- score += token === contextHint ? 3 : 1;
7019
- }
7020
- }
7021
- if (contextHint && !nonHintMatched)
7022
- return 0;
7023
- return score;
7024
- }
7025
- export function resolveModelPreference(models, preference) {
7026
- const trimmed = preference.trim();
7027
- if (!trimmed)
7028
- return null;
7029
- const lower = trimmed.toLowerCase();
7030
- // Exact match on value or display name. Values compare on the canonical
7031
- // hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
7032
- const canonicalPreference = canonicalizeModelId(trimmed);
7033
- const directMatch = models.find((model) => model.value === trimmed ||
7034
- canonicalizeModelId(model.value) === canonicalPreference ||
7035
- model.displayName.toLowerCase() === lower);
7036
- if (directMatch)
7037
- return directMatch;
7038
- // Exact match on the alias's canonical resolved id (e.g. a pinned
7039
- // "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
7040
- // reported and unambiguous, so it's tried before the fuzzier tiers below.
7041
- // Compared on the canonical hint spelling so a "-1m"-suffix pin matches a
7042
- // "[1m]"-spelled resolvedModel instead of falling into the substring tier
7043
- // (which would land on the bare 200k sibling). "default" is skipped first
7044
- // since it shares a resolvedModel with whichever alias the CLI currently
7045
- // recommends — a specific pin should land on that named alias, not
7046
- // "default".
7047
- const matchesResolved = (model) => model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
7048
- const resolvedMatch = models.find((model) => model.value !== "default" && matchesResolved(model)) ??
7049
- models.find(matchesResolved);
7050
- if (resolvedMatch)
7051
- return resolvedMatch;
7052
- // Substring match. Skips candidates whose context hint disagrees with the
7053
- // preference's — a bare row must not absorb a 1M-hinted preference (nor
7054
- // vice versa); such pairs fall through to the tokenized tier, which
7055
- // weighs hints in its scoring and still finds the best same-family row.
7056
- const preferenceHint = contextHintOf(trimmed);
7057
- const includesMatch = models.find((model) => {
7058
- if (!modelVersionsCompatible(trimmed, model))
7059
- return false;
7060
- if (contextHintOf(model.value) !== preferenceHint)
7061
- return false;
7062
- const value = model.value.toLowerCase();
7063
- const display = model.displayName.toLowerCase();
7064
- return value.includes(lower) || display.includes(lower) || lower.includes(value);
7065
- });
7066
- if (includesMatch)
7067
- return includesMatch;
7068
- // Tokenized matching for aliases like "opus[1m]"
7069
- const { tokens, contextHint } = tokenizeModelPreference(trimmed);
7070
- if (tokens.length === 0)
7071
- return null;
7072
- let bestMatch = null;
7073
- let bestScore = 0;
7074
- for (const model of models) {
7075
- if (!modelVersionsCompatible(trimmed, model))
7076
- continue;
7077
- const score = scoreModelMatch(model, tokens, contextHint);
7078
- if (0 < score && (!bestMatch || bestScore < score)) {
7079
- bestMatch = model;
7080
- bestScore = score;
7081
- }
7082
- }
7083
- return bestMatch;
7084
- }
7085
- /** Map the live model reported by a resumed session onto the picker's model
7086
- * list. The CLI restores a resumed session's model from the transcript's
7087
- * last assistant message, which records the concrete API id (e.g.
7088
- * "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order:
7089
- * 1. Exact match with the Default entry's resolution — when a named alias
7090
- * shares Default's resolvedModel verbatim, the live id can't tell the
7091
- * two apart, and a never-customized session should stay on Default.
7092
- * 2. Exact resolvedModel match on a named row. Checked before the
7093
- * hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands
7094
- * on the "sonnet[1m]" row rather than a Default that resolves to the
7095
- * bare "claude-sonnet-5" — the two rows differ in context window, which
7096
- * drives `contextWindowSize` and capability gating downstream.
7097
- * 3. Hint-stripped match with Default's resolution — a session that never
7098
- * left the default resumes as the bare transcript id, and shouldn't show
7099
- * a concrete picker entry.
7100
- * 4. `resolveModelPreference` over the picker entries.
7101
- * 5. A model with no picker counterpart (e.g. excluded by an
7102
- * `availableModels` allowlist) is tracked verbatim, mirroring
7103
- * `syncModelAfterExternalSwitch`: the picker shows no selection, but the
7104
- * model-dependent bookkeeping stays truthful to what the SDK is running. */
7105
- export function matchResumedModel(models, liveModel) {
7106
- const live = canonicalizeModelId(liveModel);
7107
- const defaultEntry = models.find((m) => m.value === "default");
7108
- const defaultResolved = defaultEntry?.resolvedModel
7109
- ? canonicalizeModelId(defaultEntry.resolvedModel)
7110
- : undefined;
7111
- if (defaultEntry && defaultResolved === live) {
7112
- return defaultEntry;
7113
- }
7114
- // No default-row exclusion needed: a default row matching `live` exactly
7115
- // already returned at the tier above.
7116
- const exactMatch = models.find((m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live);
7117
- if (exactMatch)
7118
- return exactMatch;
7119
- if (defaultEntry &&
7120
- defaultResolved &&
7121
- stripContextHints(defaultResolved) === stripContextHints(live)) {
7122
- return defaultEntry;
7123
- }
7124
- return (resolveModelPreference(models, liveModel) ?? {
7125
- value: liveModel,
7126
- displayName: liveModel,
7127
- description: "",
7128
- });
7129
- }
7130
- function resolveSettingsModel(models, settingsModel, logger) {
7131
- if (settingsModel === undefined) {
7132
- return null;
7133
- }
7134
- if (typeof settingsModel !== "string") {
7135
- const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
7136
- logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
7137
- return null;
7138
- }
7139
- return resolveModelPreference(models, settingsModel);
7140
- }
7141
- /**
7142
- * Restrict the SDK's model list to the user's `availableModels` allowlist
7143
- * (already merged-and-deduped across settings sources by `SettingsManager`).
7144
- * The user's exact entries become the model IDs surfaced via configOptions
7145
- * and passed to `setModel`, which prevents Claude Code from silently
7146
- * substituting a date-pinned variant (e.g. `haiku` →
7147
- * `claude-haiku-4-5-20251001`) that the user may not have access to.
7148
- *
7149
- * Display info and capability flags are copied from the closest SDK match so
7150
- * the UI still renders sensible names and effort levels.
7151
- *
7152
- * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
7153
- * - `undefined` is handled by the caller (no allowlist applied).
7154
- * - The Default option is unaffected by `availableModels` — it always remains
7155
- * available, even when the allowlist is `[]`.
7156
- */
7157
- export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides) {
7158
- // Default is always preserved per the docs. Synthesize one if the SDK
7159
- // didn't surface it so downstream code (e.g. `getAvailableModels` picking
7160
- // `models[0]` as a fallback) still has something to work with.
7161
- const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
7162
- value: "default",
7163
- displayName: "Default",
7164
- description: "",
7165
- };
7166
- const result = [defaultModel];
7167
- const seen = new Set([defaultModel.value]);
7168
- const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
7169
- // Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6")
7170
- // in availableModels but require provider-specific IDs at the API. We still
7171
- // resolve `sdkMatch` against the alias (`trimmed`) — that's what the
7172
- // matching heuristics above are built for, and override targets (ARNs,
7173
- // opaque provider IDs) often won't textually resemble anything in
7174
- // `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
7175
- // override target, so it's what `setModel` ends up passing to the API.
7176
- for (const entry of allowlist) {
7177
- const trimmed = entry.trim();
7178
- if (!trimmed || seen.has(trimmed))
7179
- continue;
7180
- const overridden = settingsModelOverrides?.[trimmed];
7181
- const effective = overridden ?? trimmed;
7182
- if (seen.has(effective))
7183
- continue;
7184
- const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
7185
- if (sdkMatch) {
7186
- result.push({ ...sdkMatch, value: effective });
7187
- }
7188
- else {
7189
- result.push({ value: effective, displayName: trimmed, description: "" });
7190
- }
7191
- seen.add(effective);
7192
- }
7193
- // The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
7194
- // allowlist, the same way Default is. Per the model-config docs it adds an
7195
- // entry "without replacing the built-in aliases" and "appears at the bottom of
7196
- // the /model picker", so we append it last and skip the allowlist filter; this
7197
- // keeps a slim alias allowlist from hiding the custom model row.
7198
- // https://code.claude.com/docs/en/model-config#add-a-custom-model-option
7199
- const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
7200
- if (customModelOption && !seen.has(customModelOption)) {
7201
- const customModel = sdkModels.find((m) => m.value === customModelOption);
7202
- if (customModel) {
7203
- result.push(customModel);
7204
- seen.add(customModel.value);
7205
- }
7206
- }
7207
- return result;
7208
- }
7209
- /** Whether a rejected `setModel` was vetoed by a user-configured
7210
- * PreModelSwitch hook. The CLI's rejection message is the stable,
7211
- * distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
7212
- * <reason>"); a format change makes this stop matching and the failure
7213
- * falls back to the ordinary fail-loud path, no worse than before. */
7214
- function isPreModelSwitchHookBlock(error) {
7215
- return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook");
7216
- }
7217
- async function getAvailableModels(query, models, sdkModels, settingsManager, logger, isResumedSession, sessionId, resumedModelHint) {
7218
- const settings = settingsManager.getSettings();
7219
- let currentModel = models[0];
7220
- let resolvedFromInput;
7221
- // Model priority (highest to lowest):
7222
- // 1. ANTHROPIC_MODEL environment variable
7223
- // 2. settings.model (user configuration)
7224
- // 3. the resumed session's live model (resumed sessions only)
7225
- // 4. models[0] (default first model)
7226
- if (process.env.ANTHROPIC_MODEL) {
7227
- const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
7228
- if (match) {
7229
- currentModel = match;
7230
- resolvedFromInput = process.env.ANTHROPIC_MODEL;
7231
- }
7232
- }
7233
- else if (typeof settings.model === "string") {
7234
- const match = resolveSettingsModel(models, settings.model, logger);
7235
- if (match) {
7236
- currentModel = match;
7237
- resolvedFromInput = settings.model;
7238
- }
7239
- }
7240
- // A resumed session restores the model from its last real assistant
7241
- // transcript record. Use that same local record instead of getContextUsage:
7242
- // the latter is a control request to the live CLI process and can add tens
7243
- // of seconds to session/load before its first turn. No `setModel` here: the
7244
- // SDK is already running this model, and pushing a picker alias back (e.g.
7245
- // "opus[1m]") could change the live model rather than describe it.
7246
- if (resolvedFromInput === undefined && isResumedSession) {
7247
- currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : currentModel;
7248
- }
7249
- // Skip the setModel round-trip when we can prove the SDK has already landed
7250
- // on the same model. Two cases qualify:
7251
- // (a) No override applied — currentModel is the SDK's own default (or, on
7252
- // resume, the live model read back from the SDK above); nothing to sync.
7253
- // (b) The resolver returned the user's input verbatim AND that value exists
7254
- // in the SDK's original model list — meaning no fuzzy match or
7255
- // allowlist rewrite was involved, and the SDK (which reads the same
7256
- // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
7257
- // This only holds for fresh sessions: a resumed session lands on the
7258
- // transcript's model regardless of env/settings, so the override must
7259
- // be re-asserted to keep the reported model truthful.
7260
- // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
7261
- // setModel call so we don't drift from the user's intended pin.
7262
- const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
7263
- const skipSetModel = resolvedFromInput === undefined ||
7264
- (!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
7265
- if (!skipSetModel) {
7266
- const setModelStartedAt = performance.now();
7267
- try {
7268
- await query.setModel(currentModel.value);
7269
- logger.log(`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=success`);
7270
- }
7271
- catch (error) {
7272
- logger.log(`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=error`);
7273
- // On a fresh session the pin is a defining option — fail loudly. A
7274
- // resumed session already runs fine on the transcript's model, so
7275
- // failing the whole session/load over the re-assert would be worse
7276
- // than loading with the pin unapplied (mirrors the setPermissionMode
7277
- // containment in createSession). The SDK then stayed on the
7278
- // transcript's model, so read that back rather than reporting the
7279
- // pin the session isn't running.
7280
- //
7281
- // One fresh-session failure is advisory, not defining: a
7282
- // user-configured PreModelSwitch hook can veto the pin (CLI 2.1.251+;
7283
- // 'deny', or 'ask' — which headless sessions refuse), and the SDK
7284
- // rejects setModel with "Model switch blocked by a PreModelSwitch
7285
- // hook: …". Terminal Claude Code never lets a hook veto its startup
7286
- // model (the spawn model isn't a switch), so failing session/new here
7287
- // would make the same hook config fatal only over ACP. The session
7288
- // stays on the SDK's own default — report that. We can't read the
7289
- // live model back on this path: getContextUsage isn't serviced on a
7290
- // fresh session until the first prompt turn (issues #886/#880).
7291
- if (!isResumedSession) {
7292
- if (!isPreModelSwitchHookBlock(error))
7293
- throw error;
7294
- logger.error(`Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`, error);
7295
- currentModel = models[0];
7296
- }
7297
- else {
7298
- logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
7299
- currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0];
7300
- }
7301
- }
7302
- }
7303
- return {
7304
- availableModels: models.map((model) => ({
7305
- modelId: model.value,
7306
- name: model.displayName,
7307
- description: model.description,
7308
- })),
7309
- currentModelId: currentModel.value,
7310
- };
7311
- }
7312
6947
  function getAvailableSlashCommands(commands,
7313
6948
  // Names the CLI tagged terminal-bound on `system`/init (their UX lives in
7314
6949
  // the CLI's own terminal, which ACP clients aren't) — filtered alongside