@link-assistant/hive-mind 2.0.7 → 2.0.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 072e941: fix(retry): keep the requested `--model` on transient overloads instead of switching to the fallback (#1949)
8
+
9
+ A transient **HTTP 529 "Overloaded"** result used to be classified as a
10
+ model-_capacity_ error (`isCapacity: true`), which made the shared retry helper
11
+ switch the user's requested `--model` to the configured fallback
12
+ (`opus -> opus-4-7`) on every overload. A 529 is a server-wide, transient
13
+ overload — not a signal that the selected model is full — so the run should retry
14
+ the **same** model. The overload branch in `src/tool-retry.lib.mjs` now returns
15
+ `isCapacity: false`; only a genuine "the selected model is at capacity" message
16
+ still triggers a `--model` switch. The fix lives in the shared helper, so every
17
+ tool (claude, codex, gemini, qwen, opencode, agent) inherits it.
18
+
19
+ Per-request fallback is now delegated to Claude Code itself: the claude tool
20
+ forwards `--fallback-model <id>` so overloads fall back _inside_ the CLI while our
21
+ `--model` stays stable.
22
+
23
+ Two display fixes remove the ambiguity that made this hard to diagnose:
24
+ - Warnings now render the resolved model ID alongside the alias, e.g.
25
+ `opus (claude-opus-4-8) -> opus-4-7 (claude-opus-4-7)`, via a new
26
+ `formatModelWithResolvedId` helper.
27
+ - The verbose per-retry "execution context" block now uses a shared
28
+ `logExecutionContext` helper that prints the resolved model actually passed to
29
+ the CLI, replacing a broken `argv.model === 'opus' ? 'opus' : 'sonnet'`
30
+ heuristic that mislabelled every non-`opus` alias as `sonnet`.
31
+
32
+ The PR/issue comment now shows the requested model with its resolved ID and the
33
+ requested thinking level (e.g. `high (~23999 tokens)`) via a new
34
+ `describeRequestedThinking` helper.
35
+
3
36
  ## 2.0.7
4
37
 
5
38
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.7",
3
+ "version": "2.0.8",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -26,7 +26,7 @@ import { buildMcpConfigWithoutPlaywright, ensureClaudePlaywrightMcpServer } from
26
26
  import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
27
27
  import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
28
28
  import { fetchModelInfo } from './model-info.lib.mjs';
29
- import { classifyRetryableError, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
29
+ import { classifyRetryableError, logExecutionContext, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
30
30
  import { resolveSubSessionSize } from './sub-session-size.lib.mjs'; // Issue #1706
31
31
  import { withAgentsMdAsClaudeMd } from './agents-md-claude-support.lib.mjs';
32
32
  import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
@@ -619,18 +619,10 @@ export const executeClaudeCommand = async params => {
619
619
  await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount}/${retryLimits.maxTransientErrorRetries}`)}`);
620
620
  }
621
621
  if (argv.verbose) {
622
- // Output the actual model being used
623
- const modelName = argv.model === 'opus' ? 'opus' : 'sonnet';
624
- await log(` Model: ${modelName}`, { verbose: true });
625
- await log(` Working directory: ${tempDir}`, { verbose: true });
626
- await log(` Branch: ${branchName}`, { verbose: true });
627
- await log(` Prompt length: ${prompt.length} chars`, { verbose: true });
628
- await log(` System prompt length: ${systemPrompt.length} chars`, { verbose: true });
629
- if (feedbackLines && feedbackLines.length > 0) {
630
- await log(` Feedback info included: Yes (${feedbackLines.length} lines)`, { verbose: true });
631
- } else {
632
- await log(' Feedback info included: No', { verbose: true });
633
- }
622
+ // Issue #1949: logExecutionContext shows the requested alias with its resolved
623
+ // full ID (e.g. "opus (claude-opus-4-8)"). The old `argv.model === 'opus' ?
624
+ // 'opus' : 'sonnet'` heuristic mislabelled every non-"opus" alias as "sonnet".
625
+ await logExecutionContext({ log, model: argv.model, tool: 'claude', tempDir, branchName, promptLength: prompt.length, systemPromptLength: systemPrompt.length, feedbackLines });
634
626
  }
635
627
  const resourcesBefore = await getResourceSnapshot();
636
628
  await log('📈 System resources before execution:', { verbose: true });
@@ -704,7 +696,13 @@ export const executeClaudeCommand = async params => {
704
696
  const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
705
697
  const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
706
698
  const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
699
+ // Issue #1949: Let Claude Code handle transient overload (529) fallback via its own
700
+ // `--fallback-model` flag (it re-tries the primary each turn) instead of us swapping
701
+ // `--model`. Only for plain `--model` runs (not opusplan) with a distinct fallback.
702
+ const mappedFallbackModel = argv.fallbackModel ? mapModelToId(argv.fallbackModel) : undefined;
703
+ const useClaudeFallbackModel = !resolvedPlanModel && mappedFallbackModel && mappedFallbackModel !== effectiveModel;
707
704
  let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
705
+ if (useClaudeFallbackModel) claudeArgs += ` --fallback-model ${mappedFallbackModel}`;
708
706
  // Declare queuedFeedback for use in catch/finally blocks and return value
709
707
  let queuedFeedback = [];
710
708
  // Issue #817: When --accept-incomming-comments-as-input is set and we are
@@ -761,17 +759,19 @@ export const executeClaudeCommand = async params => {
761
759
  const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
762
760
  const mcpDisableArgs = mcpConfigPath ? ['--strict-mcp-config', '--mcp-config', mcpConfigPath] : [];
763
761
  const disallowedToolsArgs = disallowedToolsList.length ? ['--disallowedTools', ...disallowedToolsList] : [];
762
+ const fallbackModelArgs = useClaudeFallbackModel ? ['--fallback-model', mappedFallbackModel] : []; // Issue #1949: Claude Code's per-request overload fallback
763
+ if (useClaudeFallbackModel && argv.verbose) await log(`📊 Claude --fallback-model: ${mappedFallbackModel} (Issue #1949 — primary --model ${effectiveModel} stays stable across overload retries)`, { verbose: true });
764
764
  if (argv.resume) {
765
765
  const simpleEscapedPrompt = prompt.replace(/"/g, '\\"');
766
- execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
766
+ execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
767
767
  } else if (streamingInput) {
768
768
  // Issue #817: Drive Claude via --input-format stream-json on a pipe
769
769
  // stdin. Initial prompt + later PR comments are written as NDJSON
770
770
  // frames by attachStreamingInput (see bidirectional-interactive.lib.mjs).
771
771
  const streamingInputArgs = ['-p', '--input-format', 'stream-json'];
772
- execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
772
+ execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
773
773
  } else {
774
- execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
774
+ execCommand = $({ cwd: tempDir, stdin: prompt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
775
775
  }
776
776
  if (streamingInput) {
777
777
  await attachStreamingInput(bidirectionalHandler, execCommand, prompt, log, !!argv.verbose);
@@ -412,6 +412,42 @@ export const getTokensToThinkingLevel = (maxBudget = DEFAULT_MAX_THINKING_BUDGET
412
412
  // Default tokens to thinking level function (using default max budget)
413
413
  export const tokensToThinkingLevel = getTokensToThinkingLevel(DEFAULT_MAX_THINKING_BUDGET);
414
414
 
415
+ /**
416
+ * Issue #1949: Produce a human-readable description of the thinking level that was
417
+ * requested for a run, for inclusion in the PR/issue "Models used" comment. The
418
+ * user asked us to "display requested and (actual thinking level if possible)".
419
+ *
420
+ * The level is derived from `argv.think` (an explicit level keyword) and/or
421
+ * `argv.thinkingBudget` (an explicit token budget), mirroring the same translation
422
+ * logic used by resolveThinkingSettings():
423
+ * - When only --think is given, show the keyword (with the token budget it maps to).
424
+ * - When only --thinking-budget is given, derive the keyword from the budget.
425
+ * - When neither is given, the level is the tool's default → returns null so the
426
+ * caller can omit the line rather than guess.
427
+ *
428
+ * @param {Object} argv - Parsed CLI args (reads think, thinkingBudget, maxThinkingBudget)
429
+ * @returns {string|null} e.g. "high (~24000 tokens)", "off (disabled)", or null
430
+ */
431
+ export const describeRequestedThinking = (argv = {}) => {
432
+ if (!argv || typeof argv !== 'object') return null;
433
+ const maxBudget = argv.maxThinkingBudget ?? DEFAULT_MAX_THINKING_BUDGET;
434
+ const levelToTokens = getThinkingLevelToTokens(maxBudget);
435
+ const tokensToLevel = getTokensToThinkingLevel(maxBudget);
436
+
437
+ let level = argv.think;
438
+ let budget = argv.thinkingBudget;
439
+
440
+ // Neither specified → tool default; the caller omits the line.
441
+ if (level === undefined && budget === undefined) return null;
442
+
443
+ if (level === undefined && budget !== undefined) level = tokensToLevel(budget);
444
+ if (budget === undefined && level !== undefined) budget = levelToTokens[level];
445
+
446
+ if (level === 'off' || budget === 0) return 'off (disabled)';
447
+ if (budget !== undefined && budget !== null) return `${level} (~${budget} tokens)`;
448
+ return String(level);
449
+ };
450
+
415
451
  /**
416
452
  * Valid effort levels for Opus 4.6 and Sonnet 4.6 (Issue #1238, Issue #1620)
417
453
  * These models use CLAUDE_CODE_EFFORT_LEVEL for thinking depth control
@@ -5,7 +5,7 @@ if (typeof globalThis.use === 'undefined') await ensureUseM();
5
5
  const { $ } = await use('command-stream'); // Use command-stream for consistent $ behavior
6
6
  import { log, maskToken, cleanErrorMessage, isENOSPC, ghCmdRetry } from './lib.mjs';
7
7
  import { reportError } from './sentry.lib.mjs';
8
- import { githubLimits, timeouts } from './config.lib.mjs';
8
+ import { describeRequestedThinking, githubLimits, timeouts } from './config.lib.mjs';
9
9
  import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRepositories as batchCheckArchived } from './github.batch.lib.mjs';
10
10
  import { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent } from './token-sanitization.lib.mjs';
11
11
  export { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent }; // Re-export for backward compatibility
@@ -356,6 +356,8 @@ export async function attachLogToGitHub(options) {
356
356
  pricingInfo = null,
357
357
  errorDuringExecution = false, // Issue #1088
358
358
  requestedModel = null, // Issue #1225: The --model flag value
359
+ argv = null, // Issue #1949: parsed CLI args, used to derive the requested thinking level for the comment
360
+ thinkingInfo = null, // Issue #1949: explicit thinking level description (overrides the value derived from argv)
359
361
  tool = null, // The tool used (claude, agent, opencode, codex)
360
362
  resultModelUsage = null, // Issue #1454
361
363
  budgetStatsData = null, // Issue #1491: budget stats for comment
@@ -428,7 +430,10 @@ export async function attachLogToGitHub(options) {
428
430
  let modelInfoString = '';
429
431
  if (requestedModel || tool || actualModelIds) {
430
432
  try {
431
- modelInfoString = await getModelInfoForComment({ requestedModel, tool, pricingInfo, actualModelIds });
433
+ // Issue #1949: prefer an explicit thinkingInfo, otherwise derive it from argv
434
+ // (e.g. "high (~24000 tokens)"). null when the run used the tool's default.
435
+ const resolvedThinkingInfo = thinkingInfo ?? describeRequestedThinking(argv);
436
+ modelInfoString = await getModelInfoForComment({ requestedModel, tool, pricingInfo, actualModelIds, thinkingInfo: resolvedThinkingInfo });
432
437
  if (verbose && modelInfoString) {
433
438
  await log(' 🤖 Model info fetched for comment', { verbose: true });
434
439
  }
@@ -926,7 +926,7 @@ const doesRequestedMatchActual = (requestedModel, actualModelId, tool) => {
926
926
  * @param {Array<{modelId: string, modelInfo: Object|null}>|null} options.modelsUsed - Actual models used from CLI JSON output
927
927
  * @returns {string} Formatted markdown string for model info section
928
928
  */
929
- export const buildModelInfoString = ({ requestedModel = null, tool = null, pricingInfo = null, modelInfo = null, modelsUsed = null } = {}) => {
929
+ export const buildModelInfoString = ({ requestedModel = null, tool = null, pricingInfo = null, modelInfo = null, modelsUsed = null, thinkingInfo = null } = {}) => {
930
930
  const hasRequested = requestedModel !== null && requestedModel !== undefined;
931
931
  const hasModelsUsed = Array.isArray(modelsUsed) && modelsUsed.length > 0;
932
932
  const hasModelInfo = modelInfo !== null;
@@ -941,7 +941,22 @@ export const buildModelInfoString = ({ requestedModel = null, tool = null, prici
941
941
  }
942
942
 
943
943
  if (hasRequested) {
944
- info += `\n- Requested: \`${requestedModel}\``;
944
+ // Issue #1949: the bare alias (e.g. "opus") is ambiguous \u2014 show the full model
945
+ // ID it resolves to so reviewers know exactly which model ran, e.g.
946
+ // "Requested: `opus` (`claude-opus-4-8`)". When the alias already equals its
947
+ // resolved ID (or cannot be resolved) we just print the alias once.
948
+ const resolvedRequested = resolveModelId(requestedModel, tool);
949
+ if (resolvedRequested && String(resolvedRequested).toLowerCase() !== String(requestedModel).toLowerCase()) {
950
+ info += `\n- Requested: \`${requestedModel}\` (\`${resolvedRequested}\`)`;
951
+ } else {
952
+ info += `\n- Requested: \`${requestedModel}\``;
953
+ }
954
+ }
955
+
956
+ // Issue #1949: surface the requested thinking level alongside the model so the
957
+ // comment records how deeply the model was asked to think (null = tool default).
958
+ if (thinkingInfo) {
959
+ info += `\n- Thinking level: ${thinkingInfo}`;
945
960
  }
946
961
 
947
962
  if (hasModelsUsed) {
@@ -1044,7 +1059,7 @@ export const resolveDefaultFallbackModel = (tool, model) => {
1044
1059
  * @param {Array<string>|null} options.actualModelIds - Actual model IDs from CLI JSON output
1045
1060
  * @returns {Promise<string>} Formatted markdown model info section
1046
1061
  */
1047
- export const getModelInfoForComment = async ({ requestedModel = null, tool = null, pricingInfo = null, actualModelIds = null } = {}) => {
1062
+ export const getModelInfoForComment = async ({ requestedModel = null, tool = null, pricingInfo = null, actualModelIds = null, thinkingInfo = null } = {}) => {
1048
1063
  let modelIds = [];
1049
1064
 
1050
1065
  if (Array.isArray(actualModelIds) && actualModelIds.length > 0) {
@@ -1075,5 +1090,6 @@ export const getModelInfoForComment = async ({ requestedModel = null, tool = nul
1075
1090
  pricingInfo,
1076
1091
  modelInfo: modelsUsed.length === 0 ? firstModelInfo : null,
1077
1092
  modelsUsed: modelsUsed.length > 0 ? modelsUsed : null,
1093
+ thinkingInfo,
1078
1094
  });
1079
1095
  };
@@ -197,6 +197,7 @@ export const handleExecutionError = async (error, shouldAttachLogs, owner, repo,
197
197
  verbose: argv.verbose || false,
198
198
  errorMessage: cleanErrorMessage(error),
199
199
  // Issue #1225: Pass model and tool info for PR comments
200
+ argv,
200
201
  requestedModel: argv.originalModel || argv.model,
201
202
  tool: argv.tool || 'claude',
202
203
  });
package/src/solve.mjs CHANGED
@@ -942,10 +942,10 @@ try {
942
942
  toolName: getToolDisplayName(argv.tool),
943
943
  resumeCommand,
944
944
  sessionId,
945
+ argv,
945
946
  requestedModel: argv.originalModel || argv.model,
946
947
  tool: argv.tool || 'claude',
947
- // Issue #1454: Pass resultModelUsage for accurate multi-model display
948
- resultModelUsage,
948
+ resultModelUsage, // Issue #1454: accurate multi-model display
949
949
  });
950
950
 
951
951
  if (logUploadSuccess) {
@@ -1014,10 +1014,10 @@ try {
1014
1014
  // See: https://github.com/link-assistant/hive-mind/issues/1152
1015
1015
  isAutoResumeEnabled: true,
1016
1016
  autoResumeMode: limitContinueMode,
1017
+ argv,
1017
1018
  requestedModel: argv.originalModel || argv.model,
1018
1019
  tool: argv.tool || 'claude',
1019
- // Issue #1454: Pass resultModelUsage for accurate multi-model display
1020
- resultModelUsage,
1020
+ resultModelUsage, // Issue #1454: accurate multi-model display
1021
1021
  });
1022
1022
 
1023
1023
  if (logUploadSuccess) {
@@ -1125,10 +1125,10 @@ try {
1125
1125
  sessionId,
1126
1126
  // If not a usage limit case, fall back to generic failure format
1127
1127
  errorMessage: limitReached ? undefined : toolFailureMessage,
1128
+ argv,
1128
1129
  requestedModel: argv.originalModel || argv.model,
1129
1130
  tool: argv.tool || 'claude',
1130
- // Issue #1454: Pass resultModelUsage for accurate multi-model display
1131
- resultModelUsage,
1131
+ resultModelUsage, // Issue #1454: accurate multi-model display
1132
1132
  });
1133
1133
 
1134
1134
  if (logUploadSuccess) {
@@ -1380,10 +1380,10 @@ try {
1380
1380
  sessionId,
1381
1381
  tempDir,
1382
1382
  anthropicTotalCostUSD,
1383
+ argv,
1383
1384
  requestedModel: argv.originalModel || argv.model,
1384
1385
  tool: argv.tool || 'claude',
1385
- // Issue #1454: Pass resultModelUsage for accurate multi-model display
1386
- resultModelUsage,
1386
+ resultModelUsage, // Issue #1454: accurate multi-model display
1387
1387
  });
1388
1388
 
1389
1389
  if (logUploadSuccess) {
@@ -206,6 +206,7 @@ export async function notifyIssueAboutPrePullRequestFailure(options) {
206
206
  verbose: argv.verbose,
207
207
  errorMessage: `${errorPrefix}\n\nReason: ${reason || 'Unknown error'}`,
208
208
  failureActionSection: buildPrePullRequestFailureActionSection(reason),
209
+ argv,
209
210
  requestedModel: argv.originalModel || argv.model,
210
211
  tool: argv.tool || 'claude',
211
212
  });
@@ -875,6 +875,7 @@ Fixes ${issueRef}
875
875
  // Issue #1152: Pass sessionType for differentiated log comments
876
876
  sessionType,
877
877
  // Issue #1225: Pass model and tool info for PR comments
878
+ argv,
878
879
  requestedModel: argv.originalModel || argv.model,
879
880
  tool: argv.tool || 'claude',
880
881
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
@@ -961,6 +962,7 @@ Fixes ${issueRef}
961
962
  // Issue #1152: Pass sessionType for differentiated log comments
962
963
  sessionType,
963
964
  // Issue #1225: Pass model and tool info for issue comments
965
+ argv,
964
966
  requestedModel: argv.originalModel || argv.model,
965
967
  tool: argv.tool || 'claude',
966
968
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
@@ -1052,6 +1054,7 @@ export const handleExecutionError = async (error, shouldAttachLogs, owner, repo,
1052
1054
  verbose: argv.verbose || false,
1053
1055
  errorMessage: cleanErrorMessage(error),
1054
1056
  // Issue #1225: Pass model and tool info for PR comments
1057
+ argv,
1055
1058
  requestedModel: argv.originalModel || argv.model,
1056
1059
  tool: argv.tool || 'claude',
1057
1060
  });
@@ -27,12 +27,28 @@ export const classifyRetryableError = value => {
27
27
  const message = normalizeMessage(value);
28
28
  const lower = message.toLowerCase();
29
29
 
30
+ // Genuine model-specific capacity: the API explicitly tells us this *particular*
31
+ // model is full and recommends trying a *different* model (e.g. Codex's
32
+ // "Selected model is at capacity. Please try a different model."). Here a model
33
+ // switch is the correct, API-recommended recovery, so isCapacity stays true.
30
34
  if (lower.includes('selected model is at capacity') || (lower.includes('at capacity') && lower.includes('try a different model'))) {
31
35
  return { message, isRetryable: true, isCapacity: true, label: 'Model capacity error' };
32
36
  }
33
37
 
38
+ // Issue #1949: Transient server-wide overload (HTTP 529 / "overloaded_error"). The
39
+ // Claude API surfaces this as a synthetic result message:
40
+ // "API Error: 529 Overloaded. This is a server-side issue, usually temporary —
41
+ // try again in a moment. If it persists, check https://status.claude.com."
42
+ // This is NOT a model-specific capacity problem — Anthropic's own guidance is to
43
+ // retry the *same* request after a short backoff (the message literally says "try
44
+ // again in a moment"). Switching the requested `--model` to a fallback (e.g.
45
+ // opus -> opus-4-7) is wrong here: it silently downgrades the user's chosen model
46
+ // for a purely transient blip, and the fallback model lives behind the same
47
+ // overloaded API anyway. Claude Code already exposes its own per-request fallback
48
+ // via `--fallback-model` (wired in claude.lib.mjs), so we keep `--model` stable and
49
+ // simply retry. Therefore isCapacity is false → retry with the same model.
34
50
  if (lower.includes('overloaded') || lower.includes('overloaded_error')) {
35
- return { message, isRetryable: true, isCapacity: true, label: 'API overload' };
51
+ return { message, isRetryable: true, isCapacity: false, label: 'API overload' };
36
52
  }
37
53
 
38
54
  if (lower.includes('request timed out')) {
@@ -140,6 +156,34 @@ export const resolveConfiguredFallbackModel = ({ tool, currentModel, configuredF
140
156
  return resolveDefaultFallbackModel(tool, currentModel);
141
157
  };
142
158
 
159
+ // Issue #1949: Render a model alias together with the full ID it resolves to, e.g.
160
+ // "opus (claude-opus-4-8)". Earlier the warning printed only the bare alias, so a
161
+ // message like "Switching to fallback model: opus -> opus-4-7" was ambiguous (what
162
+ // does "opus" actually map to?). Showing both removes that ambiguity. When the alias
163
+ // already equals its resolved ID (or cannot be resolved) we just print the alias.
164
+ export const formatModelWithResolvedId = (model, tool) => {
165
+ if (!model) return String(model);
166
+ const resolved = resolveModelId(model, tool);
167
+ if (!resolved || normalizeModelKey(resolved) === normalizeModelKey(model)) return String(model);
168
+ return `${model} (${resolved})`;
169
+ };
170
+
171
+ // Issue #1949: Shared verbose "execution context" logger. Extracted from
172
+ // claude.lib.mjs so the per-tool retry loops can emit a consistent pre-run summary
173
+ // (resolved model, working dir, branch, prompt sizes, feedback) without each file
174
+ // duplicating the block. The model line uses formatModelWithResolvedId so the alias
175
+ // and its full ID are always shown together (e.g. "opus (claude-opus-4-8)").
176
+ export const logExecutionContext = async ({ log, model, tool, tempDir, branchName, promptLength, systemPromptLength, feedbackLines } = {}) => {
177
+ if (typeof log !== 'function') return;
178
+ await log(` Model: ${formatModelWithResolvedId(model, tool)}`, { verbose: true });
179
+ await log(` Working directory: ${tempDir}`, { verbose: true });
180
+ await log(` Branch: ${branchName}`, { verbose: true });
181
+ await log(` Prompt length: ${promptLength} chars`, { verbose: true });
182
+ await log(` System prompt length: ${systemPromptLength} chars`, { verbose: true });
183
+ const feedbackCount = feedbackLines && feedbackLines.length > 0 ? feedbackLines.length : 0;
184
+ await log(feedbackCount > 0 ? ` Feedback info included: Yes (${feedbackCount} lines)` : ' Feedback info included: No', { verbose: true });
185
+ };
186
+
143
187
  export const maybeSwitchToFallbackModel = async ({ tool, argv, log, errorMessage } = {}) => {
144
188
  const fallbackModel = resolveConfiguredFallbackModel({
145
189
  tool,
@@ -148,7 +192,17 @@ export const maybeSwitchToFallbackModel = async ({ tool, argv, log, errorMessage
148
192
  });
149
193
 
150
194
  const classification = classifyRetryableError(errorMessage);
195
+
196
+ // Issue #1949: Only switch the requested `--model` for genuine model-specific
197
+ // capacity errors where the API itself recommends a different model. Transient,
198
+ // retryable conditions (overload/529, timeouts, rate limits, socket drops, …) are
199
+ // classified with isCapacity=false and must retry the *same* model — Claude Code's
200
+ // own `--fallback-model` handles per-request fallback for those without us mutating
201
+ // the user's chosen model.
151
202
  if (!fallbackModel || !classification.isCapacity || !argv?.model) {
203
+ if (typeof log === 'function' && classification.isRetryable && !classification.isCapacity) {
204
+ await log(` Keeping requested model ${formatModelWithResolvedId(argv?.model, tool)} (transient ${classification.label || 'error'} — no fallback switch, Issue #1949)`, { verbose: true });
205
+ }
152
206
  return { switched: false, fallbackModel, reason: classification.label };
153
207
  }
154
208
 
@@ -163,7 +217,9 @@ export const maybeSwitchToFallbackModel = async ({ tool, argv, log, errorMessage
163
217
  if (!argv.fallbackModel) argv.fallbackModel = fallbackModel;
164
218
 
165
219
  if (typeof log === 'function') {
166
- await log(`🔀 Switching to fallback model: ${previousModel} -> ${fallbackModel}`, { level: 'warning' });
220
+ // Issue #1949: show the resolved full model IDs so the switch is unambiguous,
221
+ // e.g. "opus (claude-opus-4-8) -> opus-4-7 (claude-opus-4-7)".
222
+ await log(`🔀 Switching to fallback model: ${formatModelWithResolvedId(previousModel, tool)} -> ${formatModelWithResolvedId(fallbackModel, tool)}`, { level: 'warning' });
167
223
  }
168
224
 
169
225
  return {
@@ -180,4 +236,6 @@ export default {
180
236
  waitWithCountdown,
181
237
  resolveConfiguredFallbackModel,
182
238
  maybeSwitchToFallbackModel,
239
+ formatModelWithResolvedId,
240
+ logExecutionContext,
183
241
  };