@link-assistant/hive-mind 2.0.22 → 2.0.24

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,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.24
4
+
5
+ ### Patch Changes
6
+
7
+ - 4cdff62: Improve task disk usage diagnostics for repository and Docker container filesystem reporting.
8
+
9
+ ## 2.0.23
10
+
11
+ ### Patch Changes
12
+
13
+ - 4778963: Add `--sub-agent-model` for Claude Code subagents and agent teams. The option is accepted by solve, hive, and Telegram command parsing, validates Claude aliases/full IDs plus `inherit`, and maps to `CLAUDE_CODE_SUBAGENT_MODEL` only when explicitly provided so Claude Code defaults remain unchanged.
14
+
3
15
  ## 2.0.22
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.22",
3
+ "version": "2.0.24",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { resolveCodexReasoningEffort } from './codex.options.lib.mjs';
11
- import { mapModelForTool } from './models/index.mjs';
11
+ import { mapClaudeSubAgentModelToEnvValue, mapModelForTool } from './models/index.mjs';
12
12
  import { buildCodexDisable1mContextConfigArgs, buildCodexSubSessionSizeConfigArgs, parseSubSessionSize } from './sub-session-size.lib.mjs';
13
13
  import { detectUsageLimit } from './usage-limit.lib.mjs';
14
14
  import { getCacheReadTokenCount, getCumulativeContextInputTokens, getOutputTokenCount } from './context-fill.lib.mjs';
@@ -54,6 +54,7 @@ const buildClaudeToolOptions = (argv = {}) => {
54
54
  if (argv.disable1mContext) extraEnv.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
55
55
  if (argv.showThinkingContent) extraEnv.CLAUDE_CODE_SHOW_THINKING = '1';
56
56
  if (argv.planModel) extraEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = argv.planModel;
57
+ if (argv.subAgentModel) extraEnv.CLAUDE_CODE_SUBAGENT_MODEL = mapClaudeSubAgentModelToEnvValue(argv.subAgentModel);
57
58
  appendExtraEnv(options, extraEnv);
58
59
 
59
60
  return options;
@@ -21,7 +21,7 @@ import { seedCumulativeAnthropicCost, addAnthropicRunCost } from './anthropic-co
21
21
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
22
22
  import { SESSION_FORCE_KILLED_MARKER, postTrackedComment } from './tool-comments.lib.mjs'; // Issue #1625
23
23
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
24
- import { CLAUDE_MODELS as availableModels } from './models/index.mjs'; // Issue #1221
24
+ import { CLAUDE_MODELS as availableModels, mapClaudeSubAgentModelToEnvValue } from './models/index.mjs'; // Issue #1221, #1978
25
25
  import { buildMcpConfigWithoutPlaywright, ensureClaudePlaywrightMcpServer } from './playwright-mcp.lib.mjs';
26
26
  import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
27
27
  import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
@@ -658,6 +658,7 @@ export const executeClaudeCommand = async params => {
658
658
  let execCommand;
659
659
  const mappedModel = mapModelToId(argv.model);
660
660
  const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
661
+ const resolvedSubAgentModel = argv.subAgentModel ? mapClaudeSubAgentModelToEnvValue(argv.subAgentModel) : undefined; // Issue #1978
661
662
  const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
662
663
  const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
663
664
  // Issue #1949: Let Claude Code handle transient overload (529) fallback via its own
@@ -706,12 +707,13 @@ export const executeClaudeCommand = async params => {
706
707
  // Issue #1706: --sub-session-size + --disable-1m-context. Resolve here, then pass into getClaudeEnv along with the rest.
707
708
  const { parsed: parsedSubSessionSize, contextWindowTokens } = await resolveSubSessionSize({ rawValue: argv.subSessionSize, tool: 'claude', modelId: effectiveModel, fetchModelInfo, log });
708
709
  // Issue #817: streaming mode sets exitAfterStopDelayMs=60000 so the headless Claude process stays alive between NDJSON turns.
709
- const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined, disable1mContext: !!argv.disable1mContext, subSessionSize: parsedSubSessionSize, contextWindowTokens });
710
+ const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, subAgentModel: resolvedSubAgentModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined, disable1mContext: !!argv.disable1mContext, subSessionSize: parsedSubSessionSize, contextWindowTokens });
710
711
  if (argv.verbose) claudeEnv.ANTHROPIC_LOG = 'debug';
711
712
  const modelMaxOutputTokens = getMaxOutputTokensForModel(effectiveModel);
712
713
  if (argv.verbose) {
713
714
  await log(`📊 CLAUDE_CODE_MAX_OUTPUT_TOKENS: ${modelMaxOutputTokens}, MCP_TIMEOUT: ${claudeCode.mcpTimeout}ms, MCP_TOOL_TIMEOUT: ${claudeCode.mcpToolTimeout}ms, ANTHROPIC_LOG: debug`, { verbose: true });
714
715
  if (resolvedPlanModel) await log(`📊 opusplan: plan=${resolvedPlanModel}, exec=${resolvedExecutionModel}`, { verbose: true });
716
+ if (claudeEnv.CLAUDE_CODE_SUBAGENT_MODEL) await log(`📊 CLAUDE_CODE_SUBAGENT_MODEL: ${claudeEnv.CLAUDE_CODE_SUBAGENT_MODEL}`, { verbose: true });
715
717
  if (resolvedThinkingBudget !== undefined) await log(`📊 MAX_THINKING_TOKENS: ${resolvedThinkingBudget}`, { verbose: true });
716
718
  if (claudeEnv.CLAUDE_CODE_EFFORT_LEVEL) await log(`📊 CLAUDE_CODE_EFFORT_LEVEL: ${claudeEnv.CLAUDE_CODE_EFFORT_LEVEL}`, { verbose: true });
717
719
  if (claudeEnv.CLAUDE_CODE_SHOW_THINKING) await log(`📊 CLAUDE_CODE_SHOW_THINKING: ${claudeEnv.CLAUDE_CODE_SHOW_THINKING}`, { verbose: true });
@@ -543,9 +543,11 @@ export const supportsThinkingBudget = (version, minVersion = '2.1.12') => {
543
543
  // Supports planModel/executionModel for opusplan mode (Issue #1223)
544
544
  // Issue #1706: supports subSessionSize (parsed) + disable1mContext to cap
545
545
  // auto-compaction sub-session size and opt out of the 1M extended context.
546
+ // Issue #1978: supports subAgentModel for Claude Code native subagents and agent teams.
546
547
  // See: https://code.claude.com/docs/en/env-vars and https://code.claude.com/docs/en/model-config
547
548
  // ANTHROPIC_DEFAULT_OPUS_MODEL → model used in plan mode (and for 'opus' alias)
548
549
  // ANTHROPIC_DEFAULT_SONNET_MODEL → model used in execution mode (and for 'sonnet' alias)
550
+ // CLAUDE_CODE_SUBAGENT_MODEL → model used by all subagents and agent teams
549
551
  // CLAUDE_CODE_DISABLE_1M_CONTEXT, CLAUDE_CODE_AUTO_COMPACT_WINDOW, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
550
552
  export const getClaudeEnv = (options = {}) => {
551
553
  // Get max output tokens based on model (Issue #1221)
@@ -619,6 +621,12 @@ export const getClaudeEnv = (options = {}) => {
619
621
  env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(options.executionModel);
620
622
  }
621
623
 
624
+ // Issue #1978: Set Claude Code native subagent/agent-team model only when
625
+ // explicitly requested. Leaving this unset preserves Claude Code defaults.
626
+ if (options.subAgentModel) {
627
+ env.CLAUDE_CODE_SUBAGENT_MODEL = String(options.subAgentModel);
628
+ }
629
+
622
630
  // Issue #1706: --disable-1m-context. Sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1.
623
631
  if (options.disable1mContext) {
624
632
  env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
package/src/hive.mjs CHANGED
@@ -74,7 +74,7 @@ if (isRunningDirectly) {
74
74
  const { validateClaudeConnection } = claudeLib;
75
75
  // Import model validation library
76
76
  const modelValidation = await import('./models/index.mjs');
77
- const { validateAndExitOnInvalidModel, defaultModels, resolveRuntimeDefaultModel } = modelValidation;
77
+ const { validateAndExitOnInvalidClaudeSubAgentModel, validateAndExitOnInvalidModel, defaultModels, resolveRuntimeDefaultModel } = modelValidation;
78
78
  const githubLib = await import('./github.lib.mjs');
79
79
  const { checkGitHubPermissions, fetchAllIssuesWithPagination, fetchProjectIssues, isRateLimitError, batchCheckPullRequestsForIssues, parseGitHubUrl, batchCheckArchivedRepositories } = githubLib;
80
80
  // Import YouTrack-related functions
@@ -473,6 +473,9 @@ if (isRunningDirectly) {
473
473
  }
474
474
  await validateAndExitOnInvalidModel(argv.planModel, tool, safeExit);
475
475
  }
476
+ if (argv.subAgentModel) {
477
+ await validateAndExitOnInvalidClaudeSubAgentModel(argv.subAgentModel, tool, safeExit);
478
+ }
476
479
 
477
480
  // Handle -s (--skip-issues-with-prs) and --auto-continue interaction
478
481
  const hasExplicitAutoContinue = rawArgs.includes('--auto-continue');
@@ -58,6 +58,11 @@ const DOCKER_ISOLATION_SHELL = 'sh';
58
58
  // less headroom than this cannot safely pull one. Diagnostic only — never
59
59
  // blocks startup. See issue #1914.
60
60
  const DOCKER_ISOLATION_LOW_DISK_GIB = 40;
61
+ // Docker-only start gate used to capture the container writable-layer baseline
62
+ // before the task command begins cloning or generating files. The parent
63
+ // releases the gate immediately after `docker inspect --size`; the fallback
64
+ // keeps the task from hanging forever if the parent exits at the wrong time.
65
+ const DOCKER_START_GATE_WAIT_TENTHS = 300;
61
66
  // Sentinel start-command's detached docker logger records when it cannot capture
62
67
  // the container's real exit code. A terminal `$ --status` carrying this value is
63
68
  // ambiguous — the container may still be running — so we cross-check it against
@@ -94,6 +99,16 @@ function buildShellCommand(command, args = []) {
94
99
  return [command, ...args].map(shellQuote).join(' ');
95
100
  }
96
101
 
102
+ function buildDockerStartGatePath(sessionId) {
103
+ return sessionId ? `/tmp/hive-mind-disk-baseline-${sessionId}` : null;
104
+ }
105
+
106
+ function buildDockerStartGatedCommand(taskCommand, sessionId) {
107
+ const gatePath = buildDockerStartGatePath(sessionId);
108
+ if (!gatePath) return taskCommand;
109
+ return `gate=${shellQuote(gatePath)}; i=0; while [ ! -e "$gate" ] && [ "$i" -lt ${DOCKER_START_GATE_WAIT_TENTHS} ]; do i=$((i+1)); sleep 0.1; done; rm -f "$gate"; exec ${taskCommand}`;
110
+ }
111
+
97
112
  function shouldRunPrivilegedDockerIsolation(image, env = process.env) {
98
113
  return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' || String(image || '').includes('hive-mind-dind');
99
114
  }
@@ -234,7 +249,8 @@ export function buildDockerIsolationStartArgs(command, args = [], options = {})
234
249
  startArgs.push('--volume', `${mount.source}:${mount.target}`);
235
250
  }
236
251
 
237
- startArgs.push('--detached', '--session', sessionId, '--', buildShellCommand(command, args));
252
+ const taskCommand = buildShellCommand(command, args);
253
+ startArgs.push('--detached', '--session', sessionId, '--', buildDockerStartGatedCommand(taskCommand, sessionId));
238
254
  return startArgs;
239
255
  }
240
256
 
@@ -541,7 +557,7 @@ async function logDockerIsolationPostLaunchDiagnostics(sessionId, env = process.
541
557
  * @param {string} [options.sessionId] - UUID for session tracking (auto-generated if not provided)
542
558
  * @param {string} [options.tool] - AI tool selected for the task; used to scope Docker auth mounts
543
559
  * @param {boolean} [options.verbose] - Enable verbose logging
544
- * @returns {Promise<{success: boolean, sessionId: string, output: string, error?: string, warning?: string}>}
560
+ * @returns {Promise<{success: boolean, sessionId: string, output: string, error?: string, warning?: string, containerFilesystemStartBytes?: number|null}>}
545
561
  */
546
562
  export async function executeWithIsolation(command, args, options = {}) {
547
563
  const { backend, verbose = false } = options;
@@ -598,6 +614,15 @@ export async function executeWithIsolation(command, args, options = {}) {
598
614
  if (result.error) stream(`[VERBOSE] isolation-runner: Error: ${result.error}`);
599
615
  }
600
616
 
617
+ let containerFilesystemStartBytes = null;
618
+ if (result.success && backend === 'docker') {
619
+ try {
620
+ containerFilesystemStartBytes = await getDockerContainerWritableLayerSize(sessionId, verbose);
621
+ } finally {
622
+ await releaseDockerContainerStartGate(sessionId, verbose);
623
+ }
624
+ }
625
+
601
626
  // Issue #1939: capture the freshly-launched docker session's reported status
602
627
  // and the live container state together, so the next iteration has the data to
603
628
  // diagnose a premature "executed/-1" status (problem #1) or a surprise image
@@ -611,6 +636,7 @@ export async function executeWithIsolation(command, args, options = {}) {
611
636
  success: true,
612
637
  sessionId,
613
638
  output: result.output,
639
+ containerFilesystemStartBytes,
614
640
  };
615
641
  }
616
642
 
@@ -831,6 +857,77 @@ export async function checkDockerContainerRunning(containerName, verbose = false
831
857
  }
832
858
  }
833
859
 
860
+ export function parseDockerContainerWritableLayerSizeOutput(output) {
861
+ const text = String(output || '').trim();
862
+ if (!text) return null;
863
+ const bytes = Number.parseInt(text.split(/\s+/)[0], 10);
864
+ return Number.isFinite(bytes) && bytes >= 0 ? bytes : null;
865
+ }
866
+
867
+ /**
868
+ * Best-effort size of a Docker task container's writable layer.
869
+ *
870
+ * `docker inspect --size` exposes `.SizeRw`, which excludes the image's base
871
+ * layers and counts only filesystem data created or changed by this container.
872
+ * That is the closest Docker-native representation of per-task disk usage.
873
+ *
874
+ * @param {string} containerName - Container name (the session UUID)
875
+ * @param {boolean} [verbose] - Enable verbose logging
876
+ * @returns {Promise<number|null>} Writable layer bytes, or null when unavailable.
877
+ */
878
+ export async function getDockerContainerWritableLayerSize(containerName, verbose = false) {
879
+ if (!containerName) return null;
880
+ try {
881
+ const result = await $({ mirror: false })`docker inspect --size -f ${'{{.SizeRw}}'} ${containerName}`;
882
+ const bytes = parseDockerContainerWritableLayerSizeOutput(result.stdout?.toString() || '');
883
+ if (verbose) {
884
+ const label = bytes === null ? 'unknown' : `${bytes} bytes`;
885
+ console.log(`[VERBOSE] isolation-runner: docker writable layer size for '${containerName}': ${label}`);
886
+ }
887
+ return bytes;
888
+ } catch (error) {
889
+ if (verbose) {
890
+ const stderr = error?.stderr?.toString?.().trim();
891
+ console.log(`[VERBOSE] isolation-runner: could not inspect writable layer size for '${containerName}': ${stderr || error?.message || error}`);
892
+ }
893
+ return null;
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Release the Docker-only start gate after the writable-layer baseline has been
899
+ * captured. Best-effort: the gated task also has a timeout fallback.
900
+ *
901
+ * @param {string} containerName - Container name (the session UUID)
902
+ * @param {boolean} [verbose] - Enable verbose logging
903
+ * @returns {Promise<boolean>} true when the gate file was touched.
904
+ */
905
+ export async function releaseDockerContainerStartGate(containerName, verbose = false) {
906
+ const gatePath = buildDockerStartGatePath(containerName);
907
+ if (!containerName || !gatePath) return false;
908
+ const releaseCommand = `touch ${shellQuote(gatePath)}`;
909
+ let lastError = null;
910
+
911
+ for (let attempt = 1; attempt <= 5; attempt++) {
912
+ try {
913
+ await $({ mirror: false })`docker exec ${containerName} sh -c ${releaseCommand}`;
914
+ if (verbose) {
915
+ console.log(`[VERBOSE] isolation-runner: released docker start gate for '${containerName}'`);
916
+ }
917
+ return true;
918
+ } catch (error) {
919
+ lastError = error;
920
+ await new Promise(resolve => setTimeout(resolve, 200));
921
+ }
922
+ }
923
+
924
+ if (verbose) {
925
+ const stderr = lastError?.stderr?.toString?.().trim();
926
+ console.log(`[VERBOSE] isolation-runner: could not release docker start gate for '${containerName}': ${stderr || lastError?.message || lastError}`);
927
+ }
928
+ return false;
929
+ }
930
+
834
931
  /**
835
932
  * Best-effort removal for a Docker container backing a native
836
933
  * `$ --isolated docker` session.
@@ -742,6 +742,62 @@ export const validateModelName = (model, tool = 'claude') => {
742
742
  };
743
743
  };
744
744
 
745
+ export const CLAUDE_SUB_AGENT_MODEL_INHERIT = 'inherit';
746
+
747
+ export const normalizeClaudeSubAgentModelName = model => {
748
+ if (model === undefined || model === null) return model;
749
+ if (typeof model !== 'string') return model;
750
+
751
+ const trimmed = model.trim();
752
+ return trimmed.toLowerCase() === CLAUDE_SUB_AGENT_MODEL_INHERIT ? CLAUDE_SUB_AGENT_MODEL_INHERIT : trimmed;
753
+ };
754
+
755
+ const looksLikeClaudeProviderModelId = model => {
756
+ if (typeof model !== 'string') return false;
757
+ const normalized = model.toLowerCase();
758
+ return normalized.startsWith('claude-') || normalized.startsWith('anthropic/') || normalized.startsWith('anthropic.') || normalized.includes('.anthropic.');
759
+ };
760
+
761
+ /**
762
+ * Validate the Claude Code subagent/agent-team model override.
763
+ *
764
+ * Claude Code documents CLAUDE_CODE_SUBAGENT_MODEL as accepting full provider
765
+ * model IDs, normal Claude model aliases, and the special value "inherit".
766
+ * Keep "inherit" scoped to this option so it does not become a valid main
767
+ * session model.
768
+ *
769
+ * @param {string} model - The subagent model override value
770
+ * @returns {{ valid: boolean, message?: string, suggestions?: string[], mappedModel?: string }}
771
+ */
772
+ export const validateClaudeSubAgentModelName = model => {
773
+ const normalized = normalizeClaudeSubAgentModelName(model);
774
+
775
+ if (normalized === CLAUDE_SUB_AGENT_MODEL_INHERIT) {
776
+ return {
777
+ valid: true,
778
+ mappedModel: CLAUDE_SUB_AGENT_MODEL_INHERIT,
779
+ };
780
+ }
781
+
782
+ const validation = validateModelName(normalized, 'claude');
783
+ if (validation.valid) return validation;
784
+ if (looksLikeClaudeProviderModelId(normalized)) {
785
+ return {
786
+ valid: true,
787
+ mappedModel: normalized,
788
+ };
789
+ }
790
+
791
+ return validation;
792
+ };
793
+
794
+ export const mapClaudeSubAgentModelToEnvValue = model => {
795
+ const result = validateClaudeSubAgentModelName(model);
796
+ if (result.valid && result.mappedModel) return result.mappedModel;
797
+ const normalized = normalizeClaudeSubAgentModelName(model);
798
+ return mapModelForTool('claude', normalized);
799
+ };
800
+
745
801
  /**
746
802
  * Validate model name and exit with error if invalid
747
803
  * This is the main entry point for model validation in solve.mjs, hive.mjs, etc.
@@ -767,6 +823,46 @@ export const validateAndExitOnInvalidModel = async (model, tool = 'claude', exit
767
823
  return true;
768
824
  };
769
825
 
826
+ /**
827
+ * Validate --sub-agent-model and exit with error if invalid.
828
+ *
829
+ * This option maps to Claude Code's CLAUDE_CODE_SUBAGENT_MODEL, so it is only
830
+ * meaningful for the Claude tool even when solve/hive supports multiple tools.
831
+ *
832
+ * @param {string} model - The subagent model override value
833
+ * @param {string} tool - The selected tool
834
+ * @param {Function} exitFn - Function to call for exiting (default: process.exit)
835
+ * @returns {Promise<boolean>} True if valid, exits process if invalid
836
+ */
837
+ export const validateAndExitOnInvalidClaudeSubAgentModel = async (model, tool = 'claude', exitFn = null) => {
838
+ if (model === undefined || model === null || model === '') return true;
839
+
840
+ if (tool !== 'claude') {
841
+ await log(`❌ --sub-agent-model is only supported with --tool claude (current tool: ${tool})`, { level: 'error' });
842
+ if (exitFn) {
843
+ await exitFn(1, '--sub-agent-model requires --tool claude');
844
+ } else {
845
+ process.exit(1);
846
+ }
847
+ return false;
848
+ }
849
+
850
+ const result = validateClaudeSubAgentModelName(model);
851
+
852
+ if (!result.valid) {
853
+ await log(`❌ Invalid --sub-agent-model: ${result.message}`, { level: 'error' });
854
+
855
+ if (exitFn) {
856
+ await exitFn(1, 'Invalid sub-agent model name');
857
+ } else {
858
+ process.exit(1);
859
+ }
860
+ return false;
861
+ }
862
+
863
+ return true;
864
+ };
865
+
770
866
  /**
771
867
  * Format the list of available models for help text
772
868
  * @param {string} tool - The tool name
@@ -338,19 +338,32 @@ async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = fal
338
338
  }
339
339
 
340
340
  /**
341
- * Issue #1945: Parse `📊 [DISK]` checkpoint markers out of the captured solve
342
- * log and, when the captured sizes cross the 5 GB threshold(s), build a
343
- * Telegram extraSection that warns the operator. Returns an empty string if
344
- * the log is unreadable or contains no markers.
341
+ * Issue #1945/#1988: Parse `📊 [DISK]` repository-size checkpoint markers out
342
+ * of the captured solve log, optionally add docker writable-layer sizes, and
343
+ * build the Telegram extraSection. Returns an empty string if there is no
344
+ * repository or docker filesystem data to show.
345
345
  */
346
- async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile } = {}) {
347
- if (!logPath) return '';
346
+ async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile, isolationBackend = null, containerFilesystemStartBytes = null, containerFilesystemAfterBytes = null } = {}) {
347
+ if (!logPath && !Number.isFinite(containerFilesystemStartBytes) && !Number.isFinite(containerFilesystemAfterBytes)) return '';
348
348
  try {
349
349
  const diskLib = await import('./solve.disk-diagnostics.lib.mjs');
350
- const logText = await readFile(logPath, 'utf8');
350
+ let logText = '';
351
+ if (logPath) {
352
+ try {
353
+ logText = await readFile(logPath, 'utf8');
354
+ } catch (readError) {
355
+ if (verbose) {
356
+ console.log(`[VERBOSE] Could not read session log ${logPath} for disk diagnostics: ${readError?.message || readError}`);
357
+ }
358
+ }
359
+ }
351
360
  const parsed = diskLib.parseDiskMarkers(logText);
352
- if (!parsed.afterClone && !parsed.afterAgent) return '';
353
- return diskLib.formatDiskDiagnosticsBlock(parsed);
361
+ if (!parsed.afterClone && !parsed.afterAgent && !Number.isFinite(containerFilesystemStartBytes) && !Number.isFinite(containerFilesystemAfterBytes)) return '';
362
+ return diskLib.formatDiskDiagnosticsBlock(parsed, {
363
+ isolationBackend,
364
+ containerFilesystemStartBytes,
365
+ containerFilesystemAfterBytes,
366
+ });
354
367
  } catch (error) {
355
368
  if (verbose) {
356
369
  console.log(`[VERBOSE] Could not inspect session log ${logPath} for disk diagnostics: ${error?.message || error}`);
@@ -359,6 +372,26 @@ async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, read
359
372
  }
360
373
  }
361
374
 
375
+ async function getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
376
+ if (sessionInfo?.isolationBackend !== 'docker') return null;
377
+ const containerName = sessionInfo.sessionId || sessionName;
378
+ if (!containerName) return null;
379
+ try {
380
+ if (typeof sizeProvider === 'function') {
381
+ const bytes = await sizeProvider(containerName, { sessionName, sessionInfo, verbose });
382
+ return Number.isFinite(bytes) ? bytes : null;
383
+ }
384
+ const runner = await getIsolationRunner();
385
+ if (typeof runner.getDockerContainerWritableLayerSize !== 'function') return null;
386
+ return await runner.getDockerContainerWritableLayerSize(containerName, verbose);
387
+ } catch (error) {
388
+ if (verbose) {
389
+ console.log(`[VERBOSE] Could not inspect docker filesystem size for ${containerName}: ${error?.message || error}`);
390
+ }
391
+ return null;
392
+ }
393
+ }
394
+
362
395
  function isSuccessfulTaskCompletion({ exitCode = null, status = null } = {}) {
363
396
  const outcome = classifySessionOutcome({ exitCode, status });
364
397
  if (outcome.failed) return false;
@@ -783,13 +816,22 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
783
816
  }
784
817
  }
785
818
 
786
- // Issue #1945: append a "💾 Disk usage" block (with warnings when the
787
- // cloned repo, the delta during the run, or the total exceed 5 GB)
788
- // parsed from the captured solve log markers.
819
+ // Issue #1945/#1988: append a "💾 Disk usage" block from repository
820
+ // size markers and, for docker isolation, the container writable layer.
789
821
  const diskExtraSections = [];
790
822
  try {
791
823
  const diskLogPath = statusResult?.logPath || sessionInfo?.logPath || null;
792
- const diskBlock = await buildDiskDiagnosticsExtraSection(diskLogPath, { verbose });
824
+ const containerFilesystemAfterBytes = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
825
+ verbose,
826
+ sizeProvider: options.dockerContainerSizeProvider,
827
+ });
828
+ const diskBlock = await buildDiskDiagnosticsExtraSection(diskLogPath, {
829
+ verbose,
830
+ readFile: options.readFile,
831
+ isolationBackend: sessionInfo?.isolationBackend || statusResult?.isolation || null,
832
+ containerFilesystemStartBytes: Number.isFinite(sessionInfo?.containerFilesystemStartBytes) ? sessionInfo.containerFilesystemStartBytes : null,
833
+ containerFilesystemAfterBytes,
834
+ });
793
835
  if (diskBlock) diskExtraSections.push(diskBlock);
794
836
  } catch (diskError) {
795
837
  if (verbose) {
@@ -33,7 +33,7 @@ import path from 'node:path';
33
33
  // excluded so the snapshot stays small and safe to reload.
34
34
  // `args` (#1927 review follow-up) is persisted so a killed /solve can be resumed
35
35
  // with its exact original invocation plus `--resume <lastSessionId>`.
36
- const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
36
+ const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
37
37
 
38
38
  /**
39
39
  * Resolve the directory durable bot state is written to. Honors
@@ -333,6 +333,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
333
333
  description: 'Fallback model to switch to on model capacity/overload errors (and, for Fable 5, on safety-classifier refusals). When supported, retries resume the same session with this model. Defaults: claude fable/claude-fable-5 -> opus (Opus 4.8); claude mythos-5/claude-mythos-5 -> fable; claude opus/opus-4-8 -> opus-4-7; claude opus-4-7 -> opus-4-6; codex gpt-5.5 -> gpt-5.4; all others unset.',
334
334
  default: undefined,
335
335
  },
336
+ 'sub-agent-model': {
337
+ type: 'string',
338
+ description: 'Claude Code subagent/agent-team model override. Sets CLAUDE_CODE_SUBAGENT_MODEL only when provided. Accepts Claude model aliases, full model IDs, or "inherit" to use normal Claude Code subagent model resolution. Only works with --tool claude.',
339
+ default: undefined,
340
+ },
336
341
  'show-thinking-content': {
337
342
  type: 'boolean',
338
343
  description: 'Show thinking content in Claude responses. Opus 4.7+ omits thinking content by default (applies to Opus 4.8 as well); this option opts in to receive summarized thinking blocks. Disabled by default. Only affects --tool claude.',
@@ -10,12 +10,8 @@
10
10
  *
11
11
  * Both checkpoints are written to the captured solve log as a single-line
12
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
13
+ * markers and, on the completion message, surfaces a Telegram block plus a
14
+ * warning when total task disk usage crosses the configured threshold.
19
15
  *
20
16
  * Implementation notes:
21
17
  *
@@ -220,54 +216,79 @@ export function computeDiskWarnings(parsed, threshold = WARNING_THRESHOLD_BYTES)
220
216
 
221
217
  /**
222
218
  * 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).
219
+ * when the task total crosses the threshold, a warning tail. Returns an empty
220
+ * string when there are no markers or docker container filesystem sizes to show
221
+ * (no logs ⇒ no surprise output).
225
222
  *
226
223
  * Returned shape:
227
224
  *
228
225
  * 💾 Disk usage (gh-issue-solver-…)
229
226
  * ```
230
- * Cloned repository: 12.0 GB
231
- * After agent: 12.4 GB (+500.0 MB)
232
- * Threshold: 5.0 GB
227
+ * Repository size:
228
+ * Cloned: 12.0 GB
229
+ * On completion: 12.4 GB (+500 MB)
233
230
  *
234
- * ⚠️ Cloned repository exceeds 5.0 GB
235
- * ⚠️ Total disk usage exceeds 5.0 GB
231
+ * ⚠️ Total disk usage per task exceeds 5.0 GB
236
232
  * ```
237
233
  *
238
234
  * @param {{afterClone: object|null, afterAgent: object|null}} parsed
239
235
  * @param {Object} [options]
240
236
  * @param {number} [options.threshold=WARNING_THRESHOLD_BYTES]
241
237
  * @param {string} [options.title='💾 Disk usage']
238
+ * @param {string} [options.isolationBackend] - Adds container filesystem details for docker isolation.
239
+ * @param {number|null} [options.containerFilesystemStartBytes]
240
+ * @param {number|null} [options.containerFilesystemAfterBytes]
242
241
  * @returns {string}
243
242
  */
244
243
  export function formatDiskDiagnosticsBlock(parsed, options = {}) {
245
- if (!parsed || (!parsed.afterClone && !parsed.afterAgent)) return '';
246
244
  const threshold = Number.isFinite(options.threshold) ? options.threshold : WARNING_THRESHOLD_BYTES;
247
245
  const title = options.title || '💾 Disk usage';
248
- const warnings = computeDiskWarnings(parsed, threshold);
246
+ const isolationBackend = String(options.isolationBackend || '').toLowerCase();
247
+ const isDockerIsolation = isolationBackend === 'docker';
248
+ const containerFilesystemStartBytes = Number.isFinite(options.containerFilesystemStartBytes) ? options.containerFilesystemStartBytes : null;
249
+ const containerFilesystemAfterBytes = Number.isFinite(options.containerFilesystemAfterBytes) ? options.containerFilesystemAfterBytes : null;
250
+ const hasRepositoryMarkers = Boolean(parsed?.afterClone || parsed?.afterAgent);
251
+ const hasContainerFilesystemMarkers = isDockerIsolation && (containerFilesystemStartBytes !== null || containerFilesystemAfterBytes !== null);
252
+ if (!hasRepositoryMarkers && !hasContainerFilesystemMarkers) return '';
253
+
249
254
  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
+ const cloneBytes = parsed?.afterClone?.bytes ?? null;
256
+ const totalBytes = parsed?.afterAgent?.bytes ?? null;
257
+ const deltaBytes = parsed?.afterAgent?.deltaBytes ?? null;
258
+
259
+ const pushSizeLine = (label, bytes, suffix = '') => {
260
+ if (bytes === null) return;
261
+ lines.push(` ${label.padEnd(16)} ${formatBytes(bytes)}${suffix}`);
262
+ };
263
+
264
+ if (hasRepositoryMarkers) {
265
+ lines.push('Repository size:');
266
+ pushSizeLine('Cloned:', cloneBytes);
267
+ if (totalBytes !== null) {
268
+ const deltaStr = deltaBytes !== null ? ` (${formatBytesDelta(deltaBytes)})` : '';
269
+ pushSizeLine('On completion:', totalBytes, deltaStr);
270
+ } else if (deltaBytes !== null) {
271
+ lines.push(` ${'Delta during run:'.padEnd(16)} ${formatBytesDelta(deltaBytes)}`);
272
+ }
255
273
  }
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)}`);
274
+
275
+ if (hasContainerFilesystemMarkers) {
276
+ lines.push('Container filesystem size:');
277
+ pushSizeLine('On start:', containerFilesystemStartBytes);
278
+ pushSizeLine('On completion:', containerFilesystemAfterBytes);
261
279
  }
262
- lines.push(`Threshold: ${formatBytes(threshold)}`);
280
+
281
+ const taskTotalBytes = isDockerIsolation && containerFilesystemAfterBytes !== null ? containerFilesystemAfterBytes : (totalBytes ?? cloneBytes);
263
282
  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)}`);
283
+ if (Number.isFinite(taskTotalBytes) && taskTotalBytes > threshold) {
284
+ warningLines.push(`⚠️ Total disk usage per task exceeds ${formatBytes(threshold)}`);
285
+ }
286
+
267
287
  if (warningLines.length) {
268
288
  lines.push('');
269
289
  lines.push(...warningLines);
270
290
  }
291
+
271
292
  return `${title}\n\`\`\`\n${lines.join('\n')}\n\`\`\``;
272
293
  }
273
294
 
package/src/solve.mjs CHANGED
@@ -64,7 +64,7 @@ const { attachFinalLogIfMissing } = await import('./attach-logs-guarantee.lib.mj
64
64
  // "did the AI post anything?" check in --auto-attach-solution-summary).
65
65
  const { postTrackedComment, USAGE_LIMIT_REACHED_MARKER } = await import('./tool-comments.lib.mjs');
66
66
  const { prepareFeedbackAndTimestamps, checkUncommittedChanges, checkForkActions } = await import('./solve.preparation.lib.mjs');
67
- const { validateAndExitOnInvalidModel } = await import('./models/index.mjs');
67
+ const { validateAndExitOnInvalidClaudeSubAgentModel, validateAndExitOnInvalidModel } = await import('./models/index.mjs');
68
68
  const { autoAcceptInviteForRepo } = await import('./solve.accept-invite.lib.mjs');
69
69
  const { handleAutoForkOption, handleMaintainerForkAccess } = await import('./solve.fork-detection.lib.mjs');
70
70
  // Initialize log file early (before argument parsing) to capture all output
@@ -243,6 +243,7 @@ if (argv.planModel) {
243
243
  }
244
244
  await validateAndExitOnInvalidModel(argv.planModel, tool, safeExit);
245
245
  }
246
+ if (argv.subAgentModel) await validateAndExitOnInvalidClaudeSubAgentModel(argv.subAgentModel, tool, safeExit);
246
247
 
247
248
  // Perform all system checks (skip tool connection check in dry-run or when --skip-tool-connection-check; model validation always runs)
248
249
  const skipToolConnectionCheck = argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
@@ -316,7 +316,7 @@ if (config.dryRun) {
316
316
  const { buildUserMention } = await import('./buildUserMention.lib.mjs');
317
317
  const { reportError, initializeSentry, addBreadcrumb } = await import('./sentry.lib.mjs');
318
318
  const { parseGitHubUrl, validateGitHubEntityExistence } = await import('./github.lib.mjs');
319
- const { validateModelName, buildModelOptionDescription } = await import('./models/index.mjs');
319
+ const { validateClaudeSubAgentModelName, validateModelName, buildModelOptionDescription } = await import('./models/index.mjs');
320
320
  const { resolveIsolation, createIsolationAwareQueueCallback } = await import('./telegram-isolation.lib.mjs');
321
321
  const limitsLib = await import('./limits.lib.mjs');
322
322
  const { formatUsageMessage, formatCodexLimitsSection, getAllCachedLimits } = limitsLib;
@@ -435,6 +435,12 @@ function validateModelInArgs(args, tool = 'claude') {
435
435
  if (!validation.valid) {
436
436
  return validation.message;
437
437
  }
438
+ } else if (args[i] === '--sub-agent-model' || args[i].startsWith('--sub-agent-model=')) {
439
+ const modelName = args[i] === '--sub-agent-model' ? args[i + 1] : args[i].substring('--sub-agent-model='.length);
440
+ if (!modelName) continue;
441
+ if (tool !== 'claude') return `--sub-agent-model is only supported with --tool claude (current tool: ${tool})`;
442
+ const validation = validateClaudeSubAgentModelName(modelName);
443
+ if (!validation.valid) return `Invalid --sub-agent-model: ${validation.message}`;
438
444
  }
439
445
  }
440
446
  return null;
@@ -127,6 +127,10 @@ export function buildExecuteAndUpdateMessage(deps) {
127
127
  trackSession(session, sessionInfo, VERBOSE);
128
128
  await safeEdit(formatStartingWorkSessionMessage({ sessionName: session, isolationBackend: iso.backend, infoBlock, locale }));
129
129
  result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, tool, verbose: VERBOSE });
130
+ if (result.success && sessionInfo && Number.isFinite(result.containerFilesystemStartBytes)) {
131
+ sessionInfo.containerFilesystemStartBytes = result.containerFilesystemStartBytes;
132
+ trackSession(session, sessionInfo, VERBOSE);
133
+ }
130
134
  if (!result.success) {
131
135
  // The launch never produced a live container — drop the optimistic
132
136
  // tracking so a phantom session is not monitored or resumed.
@@ -92,6 +92,7 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
92
92
  command: item.command || 'solve',
93
93
  isolationBackend: iso.backend,
94
94
  sessionId: sid,
95
+ containerFilesystemStartBytes: Number.isFinite(r.containerFilesystemStartBytes) ? r.containerFilesystemStartBytes : null,
95
96
  tool,
96
97
  infoBlock: item.infoBlock,
97
98
  // Issue #1688: propagate URL context + requester through the queue so the