@link-assistant/hive-mind 2.0.6 → 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,61 @@
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
+
36
+ ## 2.0.7
37
+
38
+ ### Patch Changes
39
+
40
+ - 6d9a2bb: feat(solve): log working-tree size before/after the AI agent and warn on Telegram when disk usage exceeds 5 GB (#1945)
41
+
42
+ `/solve` now records the size of its temporary working tree at two checkpoints:
43
+ after the repository is cloned (before the AI agent starts) and after the AI
44
+ working session ends. Both checkpoints emit a structured `📊 [DISK]` marker into
45
+ the captured solve log, so the cloned-repo size, the AI-induced delta, and the
46
+ final total are visible in `tail -f`-style debugging.
47
+
48
+ The session monitor parses those markers from the captured log and appends a
49
+ `💾 Disk usage` block to the Telegram completion message. The block raises a
50
+ warning when the cloned repository exceeds 5 GB, when the working tree grew by
51
+ more than 5 GB during the run, or when the total disk usage for the task
52
+ exceeds 5 GB — exactly the three conditions called out in the issue.
53
+
54
+ Sizing uses `du -sb` (byte-accurate on Linux), falls back to `du -sk` on BSD/
55
+ macOS, and finally to `fs.statSync` for single-file targets — no new runtime
56
+ dependency. The threshold is 5 GiB and uses a strict `>` comparison, so a tree
57
+ that lands at exactly 5 GiB does not warn.
58
+
3
59
  ## 2.0.6
4
60
 
5
61
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.6",
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
  };
@@ -297,6 +297,28 @@ async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = fal
297
297
  }
298
298
  }
299
299
 
300
+ /**
301
+ * Issue #1945: Parse `📊 [DISK]` checkpoint markers out of the captured solve
302
+ * log and, when the captured sizes cross the 5 GB threshold(s), build a
303
+ * Telegram extraSection that warns the operator. Returns an empty string if
304
+ * the log is unreadable or contains no markers.
305
+ */
306
+ async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile } = {}) {
307
+ if (!logPath) return '';
308
+ try {
309
+ const diskLib = await import('./solve.disk-diagnostics.lib.mjs');
310
+ const logText = await readFile(logPath, 'utf8');
311
+ const parsed = diskLib.parseDiskMarkers(logText);
312
+ if (!parsed.afterClone && !parsed.afterAgent) return '';
313
+ return diskLib.formatDiskDiagnosticsBlock(parsed);
314
+ } catch (error) {
315
+ if (verbose) {
316
+ console.log(`[VERBOSE] Could not inspect session log ${logPath} for disk diagnostics: ${error?.message || error}`);
317
+ }
318
+ return '';
319
+ }
320
+ }
321
+
300
322
  function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
301
323
  const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
302
324
  const elapsed = Date.now() - startTime.getTime();
@@ -648,6 +670,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
648
670
  }
649
671
  }
650
672
 
673
+ // Issue #1945: append a "💾 Disk usage" block (with warnings when the
674
+ // cloned repo, the delta during the run, or the total exceed 5 GB)
675
+ // parsed from the captured solve log markers.
676
+ const diskExtraSections = [];
677
+ try {
678
+ const diskLogPath = statusResult?.logPath || sessionInfo?.logPath || null;
679
+ const diskBlock = await buildDiskDiagnosticsExtraSection(diskLogPath, { verbose });
680
+ if (diskBlock) diskExtraSections.push(diskBlock);
681
+ } catch (diskError) {
682
+ if (verbose) {
683
+ console.log(`[VERBOSE] Could not build disk diagnostics section for ${sessionName}: ${diskError?.message || diskError}`);
684
+ }
685
+ }
686
+
651
687
  const message = formatSessionCompletionMessage({
652
688
  sessionName,
653
689
  sessionInfo,
@@ -656,7 +692,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
656
692
  exitCode: finalExitCode,
657
693
  infoBlock: sessionInfo?.infoBlock || '',
658
694
  pullRequestUrl,
659
- extraSections: [...limitsExtraSections, ...resumeExtraSections],
695
+ extraSections: [...limitsExtraSections, ...resumeExtraSections, ...diskExtraSections],
660
696
  });
661
697
 
662
698
  // Update the original reply message if messageId is available, otherwise send new message
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Disk-space diagnostics for the `/solve` command (issue #1945).
3
+ *
4
+ * Captures two checkpoints around the AI working session:
5
+ *
6
+ * 1. AFTER_CLONE — size of the freshly-cloned `tempDir` BEFORE the AI agent
7
+ * starts. Tells us how large the repository itself is.
8
+ * 2. AFTER_AGENT — size of the same `tempDir` AFTER the AI agent has
9
+ * finished, so we can see how many bytes the working session added.
10
+ *
11
+ * Both checkpoints are written to the captured solve log as a single-line
12
+ * structured marker. The Telegram bot's `session-monitor.lib.mjs` parses those
13
+ * markers and, on the completion message, surfaces a Telegram block plus
14
+ * warnings when any of the three thresholds from the issue are crossed:
15
+ *
16
+ * - cloned repository > WARNING_THRESHOLD_BYTES
17
+ * - delta during run > WARNING_THRESHOLD_BYTES
18
+ * - total space used > WARNING_THRESHOLD_BYTES
19
+ *
20
+ * Implementation notes:
21
+ *
22
+ * - Uses `du -sb <path>` on Linux for byte-accurate sizing, falls back to
23
+ * `du -sk <path>` (kilobytes ×1024) on systems without GNU coreutils
24
+ * (macOS BSD `du` doesn't support `-b`). A final fs.statSync fallback
25
+ * keeps the helper non-throwing for plain files / inaccessible dirs.
26
+ * - The marker format is deliberately ASCII and key=value so it survives
27
+ * log truncation and stays parseable with a one-line regex. We DO NOT
28
+ * emit JSON because the existing log is human-tailing-friendly and a
29
+ * stray closing brace from another logger could confuse JSON.parse.
30
+ *
31
+ * @see https://github.com/link-assistant/hive-mind/issues/1945
32
+ */
33
+
34
+ import { execFileSync } from 'node:child_process';
35
+ import fs from 'node:fs';
36
+
37
+ /** 5 GB threshold (binary). Matches the issue body verbatim. */
38
+ export const WARNING_THRESHOLD_BYTES = 5 * 1024 * 1024 * 1024;
39
+
40
+ export const DISK_MARKER_PREFIX = '📊 [DISK]';
41
+ export const DISK_PHASE_AFTER_CLONE = 'after_clone';
42
+ export const DISK_PHASE_AFTER_AGENT = 'after_agent';
43
+
44
+ /**
45
+ * Measure the size of a path in bytes. Robust to missing tools / paths.
46
+ *
47
+ * @param {string} targetPath
48
+ * @returns {number|null} Bytes, or null if the path is missing/unreadable.
49
+ */
50
+ export function measureDirectorySize(targetPath) {
51
+ if (!targetPath) return null;
52
+ // Prefer `du -sb` (GNU coreutils) for byte-accurate sizing.
53
+ try {
54
+ const out = execFileSync('du', ['-sb', targetPath], {
55
+ encoding: 'utf8',
56
+ stdio: ['ignore', 'pipe', 'ignore'],
57
+ timeout: 60_000,
58
+ maxBuffer: 4 * 1024 * 1024,
59
+ }).trim();
60
+ const bytes = parseInt(out.split(/\s+/)[0], 10);
61
+ if (Number.isFinite(bytes) && bytes >= 0) return bytes;
62
+ } catch {
63
+ // Fall through to -sk fallback for BSD du / macOS.
64
+ }
65
+ // BSD `du` (macOS) doesn't support -b but does support -sk (kilobytes).
66
+ try {
67
+ const out = execFileSync('du', ['-sk', targetPath], {
68
+ encoding: 'utf8',
69
+ stdio: ['ignore', 'pipe', 'ignore'],
70
+ timeout: 60_000,
71
+ maxBuffer: 4 * 1024 * 1024,
72
+ }).trim();
73
+ const kb = parseInt(out.split(/\s+/)[0], 10);
74
+ if (Number.isFinite(kb) && kb >= 0) return kb * 1024;
75
+ } catch {
76
+ // Fall through to fs.statSync — last resort for single-file paths.
77
+ }
78
+ try {
79
+ const stat = fs.statSync(targetPath);
80
+ return stat.size;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Human-readable byte format. Two flavours:
88
+ * - `formatBytes(bytes)` → `"12.0 GB"` (matches limits.lib.mjs style)
89
+ * - `formatBytesCompact(b)` → `"12G"` (matches the issue body verbatim
90
+ * and cleanup.lib.mjs)
91
+ *
92
+ * @param {number|null|undefined} bytes
93
+ * @returns {string}
94
+ */
95
+ export function formatBytes(bytes) {
96
+ if (bytes == null || Number.isNaN(bytes)) return '? B';
97
+ if (bytes < 1024) return `${bytes} B`;
98
+ const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
99
+ let value = bytes / 1024;
100
+ let unit = 0;
101
+ while (value >= 1024 && unit < units.length - 1) {
102
+ value /= 1024;
103
+ unit++;
104
+ }
105
+ // 1 decimal for GB and above (matches limits.lib formatBytes), none below.
106
+ const decimals = units[unit] === 'GB' || units[unit] === 'TB' || units[unit] === 'PB' ? 1 : 0;
107
+ return `${value.toFixed(decimals)} ${units[unit]}`;
108
+ }
109
+
110
+ /**
111
+ * Signed byte delta — adds a leading "+" for positive non-zero values so a
112
+ * growth like 500 MB renders as "+500 MB" in both logs and Telegram.
113
+ */
114
+ export function formatBytesDelta(bytes) {
115
+ if (bytes == null || Number.isNaN(bytes)) return '? B';
116
+ if (bytes === 0) return '±0 B';
117
+ const sign = bytes > 0 ? '+' : '-';
118
+ return `${sign}${formatBytes(Math.abs(bytes))}`;
119
+ }
120
+
121
+ function escapeForMarker(value) {
122
+ // Strip newlines and the marker prefix so a path containing the literal
123
+ // "📊 [DISK]" cannot inject a fake marker. Paths almost never contain spaces
124
+ // in /tmp but we still quote with backticks for the human-readable suffix
125
+ // and use key=value pairs for the parseable head.
126
+ return String(value)
127
+ .replace(/[\r\n]+/g, ' ')
128
+ .slice(0, 2048);
129
+ }
130
+
131
+ /**
132
+ * Build a single-line structured log marker the parent (Telegram bot) can
133
+ * parse out of the captured log to surface size warnings.
134
+ *
135
+ * Example (after_clone):
136
+ * 📊 [DISK] phase=after_clone bytes=12884901888 path=/tmp/foo size=12.0 GB
137
+ *
138
+ * Example (after_agent):
139
+ * 📊 [DISK] phase=after_agent bytes=13312000000 deltaBytes=524288000 path=/tmp/foo size=12.4 GB delta=+500.0 MB
140
+ *
141
+ * @param {Object} params
142
+ * @param {string} params.phase - 'after_clone' | 'after_agent'
143
+ * @param {number|null} params.bytes - Current size of tempDir in bytes
144
+ * @param {number|null} [params.deltaBytes] - Bytes added since after_clone (after_agent only)
145
+ * @param {string} params.path - The measured path
146
+ * @returns {string}
147
+ */
148
+ export function buildDiskMarker({ phase, bytes, deltaBytes = null, path: targetPath }) {
149
+ const head = [`phase=${phase}`];
150
+ if (Number.isFinite(bytes)) head.push(`bytes=${bytes}`);
151
+ if (Number.isFinite(deltaBytes)) head.push(`deltaBytes=${deltaBytes}`);
152
+ head.push(`path=${escapeForMarker(targetPath || '')}`);
153
+ const suffixParts = [];
154
+ if (Number.isFinite(bytes)) suffixParts.push(`size=${formatBytes(bytes)}`);
155
+ if (Number.isFinite(deltaBytes)) suffixParts.push(`delta=${formatBytesDelta(deltaBytes)}`);
156
+ const suffix = suffixParts.length ? ` ${suffixParts.join(' ')}` : '';
157
+ return `${DISK_MARKER_PREFIX} ${head.join(' ')}${suffix}`;
158
+ }
159
+
160
+ /**
161
+ * Parse all `📊 [DISK]` markers out of a captured solve log. The LAST marker
162
+ * for each phase wins (sessions that restart can emit more than one).
163
+ *
164
+ * @param {string} logText
165
+ * @returns {{
166
+ * afterClone: {bytes:number|null, path:string|null} | null,
167
+ * afterAgent: {bytes:number|null, deltaBytes:number|null, path:string|null} | null
168
+ * }}
169
+ */
170
+ export function parseDiskMarkers(logText) {
171
+ const result = { afterClone: null, afterAgent: null };
172
+ if (!logText || typeof logText !== 'string') return result;
173
+ // Anchor to the marker prefix so a quoted user comment containing this
174
+ // string mid-line is not mistakenly parsed.
175
+ const re = /📊 \[DISK\] ([^\n\r]+)/g;
176
+ let m;
177
+ while ((m = re.exec(logText)) !== null) {
178
+ const pairs = {};
179
+ // key=value tokens, where value runs until next " key=" or EOL.
180
+ const tokenRe = /(\w+)=([^\s][^\n\r]*?)(?=\s+\w+=|$)/g;
181
+ let t;
182
+ while ((t = tokenRe.exec(m[1])) !== null) {
183
+ pairs[t[1]] = t[2];
184
+ }
185
+ const phase = pairs.phase;
186
+ if (phase !== DISK_PHASE_AFTER_CLONE && phase !== DISK_PHASE_AFTER_AGENT) continue;
187
+ const bytes = parseInt(pairs.bytes, 10);
188
+ const deltaBytes = parseInt(pairs.deltaBytes, 10);
189
+ const entry = {
190
+ bytes: Number.isFinite(bytes) ? bytes : null,
191
+ path: pairs.path || null,
192
+ };
193
+ if (phase === DISK_PHASE_AFTER_AGENT) {
194
+ entry.deltaBytes = Number.isFinite(deltaBytes) ? deltaBytes : null;
195
+ result.afterAgent = entry;
196
+ } else {
197
+ result.afterClone = entry;
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+
203
+ /**
204
+ * Decide which of the three issue thresholds were crossed.
205
+ *
206
+ * @param {{afterClone: object|null, afterAgent: object|null}} parsed
207
+ * @param {number} [threshold=WARNING_THRESHOLD_BYTES]
208
+ * @returns {{cloneTooLarge:boolean, deltaTooLarge:boolean, totalTooLarge:boolean}}
209
+ */
210
+ export function computeDiskWarnings(parsed, threshold = WARNING_THRESHOLD_BYTES) {
211
+ const cloneBytes = parsed?.afterClone?.bytes ?? null;
212
+ const totalBytes = parsed?.afterAgent?.bytes ?? cloneBytes;
213
+ const deltaBytes = parsed?.afterAgent?.deltaBytes ?? null;
214
+ return {
215
+ cloneTooLarge: Number.isFinite(cloneBytes) && cloneBytes > threshold,
216
+ deltaTooLarge: Number.isFinite(deltaBytes) && deltaBytes > threshold,
217
+ totalTooLarge: Number.isFinite(totalBytes) && totalBytes > threshold,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Telegram block (Markdown code fence) describing the captured sizes plus,
223
+ * when any threshold is crossed, a `⚠️ Warnings:` tail. Returns an empty
224
+ * string when there are no markers in the log (no logs ⇒ no surprise output).
225
+ *
226
+ * Returned shape:
227
+ *
228
+ * 💾 Disk usage (gh-issue-solver-…)
229
+ * ```
230
+ * Cloned repository: 12.0 GB
231
+ * After agent: 12.4 GB (+500.0 MB)
232
+ * Threshold: 5.0 GB
233
+ *
234
+ * ⚠️ Cloned repository exceeds 5.0 GB
235
+ * ⚠️ Total disk usage exceeds 5.0 GB
236
+ * ```
237
+ *
238
+ * @param {{afterClone: object|null, afterAgent: object|null}} parsed
239
+ * @param {Object} [options]
240
+ * @param {number} [options.threshold=WARNING_THRESHOLD_BYTES]
241
+ * @param {string} [options.title='💾 Disk usage']
242
+ * @returns {string}
243
+ */
244
+ export function formatDiskDiagnosticsBlock(parsed, options = {}) {
245
+ if (!parsed || (!parsed.afterClone && !parsed.afterAgent)) return '';
246
+ const threshold = Number.isFinite(options.threshold) ? options.threshold : WARNING_THRESHOLD_BYTES;
247
+ const title = options.title || '💾 Disk usage';
248
+ const warnings = computeDiskWarnings(parsed, threshold);
249
+ const lines = [];
250
+ const cloneBytes = parsed.afterClone?.bytes ?? null;
251
+ const totalBytes = parsed.afterAgent?.bytes ?? null;
252
+ const deltaBytes = parsed.afterAgent?.deltaBytes ?? null;
253
+ if (cloneBytes !== null) {
254
+ lines.push(`Cloned repository: ${formatBytes(cloneBytes)}`);
255
+ }
256
+ if (totalBytes !== null) {
257
+ const deltaStr = deltaBytes !== null ? ` (${formatBytesDelta(deltaBytes)})` : '';
258
+ lines.push(`After agent: ${formatBytes(totalBytes)}${deltaStr}`);
259
+ } else if (deltaBytes !== null) {
260
+ lines.push(`Delta during run: ${formatBytesDelta(deltaBytes)}`);
261
+ }
262
+ lines.push(`Threshold: ${formatBytes(threshold)}`);
263
+ const warningLines = [];
264
+ if (warnings.cloneTooLarge) warningLines.push(`⚠️ Cloned repository exceeds ${formatBytes(threshold)}`);
265
+ if (warnings.deltaTooLarge) warningLines.push(`⚠️ Folder grew by more than ${formatBytes(threshold)} during the run`);
266
+ if (warnings.totalTooLarge) warningLines.push(`⚠️ Total disk usage exceeds ${formatBytes(threshold)}`);
267
+ if (warningLines.length) {
268
+ lines.push('');
269
+ lines.push(...warningLines);
270
+ }
271
+ return `${title}\n\`\`\`\n${lines.join('\n')}\n\`\`\``;
272
+ }
273
+
274
+ /**
275
+ * Capture the AFTER_CLONE checkpoint and log it. Safe to call when `log`
276
+ * is missing; degrades to console.log so a CLI-only run still shows the size.
277
+ *
278
+ * Returns the captured size in bytes so the caller can stash it for the
279
+ * AFTER_AGENT delta calculation, or null if measurement failed.
280
+ *
281
+ * @param {Object} params
282
+ * @param {string} params.tempDir
283
+ * @param {Function} [params.log] - The bound `log` from solve.mjs
284
+ * @returns {Promise<number|null>}
285
+ */
286
+ export async function recordAfterCloneSize({ tempDir, log }) {
287
+ const bytes = measureDirectorySize(tempDir);
288
+ const marker = buildDiskMarker({
289
+ phase: DISK_PHASE_AFTER_CLONE,
290
+ bytes,
291
+ path: tempDir,
292
+ });
293
+ if (log) {
294
+ await log(`\n${marker}`);
295
+ } else {
296
+ console.log(marker);
297
+ }
298
+ return bytes;
299
+ }
300
+
301
+ /**
302
+ * Capture the AFTER_AGENT checkpoint and log it (with delta versus the
303
+ * AFTER_CLONE checkpoint when available). Returns the captured size in bytes.
304
+ *
305
+ * @param {Object} params
306
+ * @param {string} params.tempDir
307
+ * @param {number|null} params.beforeBytes - The AFTER_CLONE size captured earlier
308
+ * @param {Function} [params.log]
309
+ * @returns {Promise<number|null>}
310
+ */
311
+ export async function recordAfterAgentSize({ tempDir, beforeBytes, log }) {
312
+ const bytes = measureDirectorySize(tempDir);
313
+ const deltaBytes = Number.isFinite(bytes) && Number.isFinite(beforeBytes) ? bytes - beforeBytes : null;
314
+ const marker = buildDiskMarker({
315
+ phase: DISK_PHASE_AFTER_AGENT,
316
+ bytes,
317
+ deltaBytes,
318
+ path: tempDir,
319
+ });
320
+ if (log) {
321
+ await log(`\n${marker}`);
322
+ } else {
323
+ console.log(marker);
324
+ }
325
+ return bytes;
326
+ }
327
+
328
+ export default {
329
+ WARNING_THRESHOLD_BYTES,
330
+ DISK_MARKER_PREFIX,
331
+ DISK_PHASE_AFTER_CLONE,
332
+ DISK_PHASE_AFTER_AGENT,
333
+ measureDirectorySize,
334
+ formatBytes,
335
+ formatBytesDelta,
336
+ buildDiskMarker,
337
+ parseDiskMarkers,
338
+ computeDiskWarnings,
339
+ formatDiskDiagnosticsBlock,
340
+ recordAfterCloneSize,
341
+ recordAfterAgentSize,
342
+ };
@@ -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
@@ -55,6 +55,7 @@ const { configureWorkingSession, beginWorkingSession, endWorkingSession } = awai
55
55
  const getResourceSnapshot = memoryCheck.getResourceSnapshot;
56
56
  const { handleAutoPrCreation } = await import('./solve.auto-pr.lib.mjs');
57
57
  const { setupRepositoryAndClone, verifyDefaultBranchAndStatus } = await import('./solve.repo-setup.lib.mjs');
58
+ const { recordAfterCloneSize, recordAfterAgentSize } = await import('./solve.disk-diagnostics.lib.mjs');
58
59
  const { createOrCheckoutBranch } = await import('./solve.branch.lib.mjs');
59
60
  const { startWorkSession, endWorkSession, SESSION_TYPES } = await import('./solve.session.lib.mjs');
60
61
  // Issue #1625: centralized markers + tracked comment posting for solve.mjs's
@@ -501,6 +502,8 @@ try {
501
502
  needsClone,
502
503
  });
503
504
 
505
+ cleanupContext.diskDiagnostics = { beforeBytes: await recordAfterCloneSize({ tempDir, log }) };
506
+
504
507
  // Verify default branch and status using the new module
505
508
  // Pass argv, owner, repo, issueUrl for empty repository auto-initialization (--auto-init-repository)
506
509
  const defaultBranch = await verifyDefaultBranchAndStatus({
@@ -814,6 +817,12 @@ try {
814
817
  toolResult = claudeResult;
815
818
  }
816
819
 
820
+ try {
821
+ await recordAfterAgentSize({ tempDir, beforeBytes: cleanupContext.diskDiagnostics?.beforeBytes ?? null, log });
822
+ } catch (diskError) {
823
+ await log(`⚠️ Disk-size measurement failed: ${cleanErrorMessage(diskError)}`, { level: 'warning', verbose: true });
824
+ }
825
+
817
826
  // Issue #1823: Mark the end of the AI working session. If a graceful-shutdown interrupt arrived
818
827
  // during the session (deferred by the working-session guard), honor it now: auto-commit any
819
828
  // uncommitted changes and exit gracefully — only AFTER the AI tool has fully finished its turn.
@@ -933,10 +942,10 @@ try {
933
942
  toolName: getToolDisplayName(argv.tool),
934
943
  resumeCommand,
935
944
  sessionId,
945
+ argv,
936
946
  requestedModel: argv.originalModel || argv.model,
937
947
  tool: argv.tool || 'claude',
938
- // Issue #1454: Pass resultModelUsage for accurate multi-model display
939
- resultModelUsage,
948
+ resultModelUsage, // Issue #1454: accurate multi-model display
940
949
  });
941
950
 
942
951
  if (logUploadSuccess) {
@@ -1005,10 +1014,10 @@ try {
1005
1014
  // See: https://github.com/link-assistant/hive-mind/issues/1152
1006
1015
  isAutoResumeEnabled: true,
1007
1016
  autoResumeMode: limitContinueMode,
1017
+ argv,
1008
1018
  requestedModel: argv.originalModel || argv.model,
1009
1019
  tool: argv.tool || 'claude',
1010
- // Issue #1454: Pass resultModelUsage for accurate multi-model display
1011
- resultModelUsage,
1020
+ resultModelUsage, // Issue #1454: accurate multi-model display
1012
1021
  });
1013
1022
 
1014
1023
  if (logUploadSuccess) {
@@ -1116,10 +1125,10 @@ try {
1116
1125
  sessionId,
1117
1126
  // If not a usage limit case, fall back to generic failure format
1118
1127
  errorMessage: limitReached ? undefined : toolFailureMessage,
1128
+ argv,
1119
1129
  requestedModel: argv.originalModel || argv.model,
1120
1130
  tool: argv.tool || 'claude',
1121
- // Issue #1454: Pass resultModelUsage for accurate multi-model display
1122
- resultModelUsage,
1131
+ resultModelUsage, // Issue #1454: accurate multi-model display
1123
1132
  });
1124
1133
 
1125
1134
  if (logUploadSuccess) {
@@ -1371,10 +1380,10 @@ try {
1371
1380
  sessionId,
1372
1381
  tempDir,
1373
1382
  anthropicTotalCostUSD,
1383
+ argv,
1374
1384
  requestedModel: argv.originalModel || argv.model,
1375
1385
  tool: argv.tool || 'claude',
1376
- // Issue #1454: Pass resultModelUsage for accurate multi-model display
1377
- resultModelUsage,
1386
+ resultModelUsage, // Issue #1454: accurate multi-model display
1378
1387
  });
1379
1388
 
1380
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
  };