@link-assistant/hive-mind 2.0.29 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4e21d2a: Fix Docker task disk-usage reporting so Telegram completion messages use task container writable-layer samples instead of filesystem-capacity resource markers from the parent deployment.
8
+ - fdbf448: Add full support for Claude Sonnet 5 (`claude-sonnet-5`) and make it the default model for `--tool claude`. The bare `sonnet` alias now resolves to `claude-sonnet-5` (previously `claude-sonnet-4-6`). Sonnet 5 supports 1M context (`[1m]`), the full effort ladder including `xhigh` and `max`, 128K max output tokens, and adaptive-thinking-only environment handling. The `sonnet-4-6`/`claude-sonnet-4-6` aliases are retained for backward compatibility. (Issue #2003)
9
+
3
10
  ## 2.0.29
4
11
 
5
12
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.29",
3
+ "version": "2.1.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -266,6 +266,22 @@ const isSonnet46OrLater = model => {
266
266
  return m === 'sonnet' || m === 'sonnet-4-6' || m.includes('sonnet-4-6') || m.includes('sonnet-5');
267
267
  };
268
268
 
269
+ /**
270
+ * Check if a model is Claude Sonnet 5 (Issue #2003)
271
+ * Sonnet 5 (`claude-sonnet-5`) is the current default for `--tool claude` (the bare
272
+ * `sonnet` alias now resolves to it). Unlike Sonnet 4.6 it supports the `xhigh` effort
273
+ * level, up to 128k output tokens, and uses adaptive thinking only (extended/manual
274
+ * thinking with an explicit budget is unavailable), matching the Fable/Mythos 5 API
275
+ * constraints. See: https://www.anthropic.com/news/claude-sonnet-5
276
+ * @param {string} model - The model name or ID
277
+ * @returns {boolean} True if the model is Claude Sonnet 5
278
+ */
279
+ export const isSonnet5 = model => {
280
+ if (!model) return false;
281
+ const m = model.toLowerCase();
282
+ return m === 'sonnet' || m === 'sonnet-5' || m.includes('sonnet-5');
283
+ };
284
+
269
285
  const isMythosPreview = model => {
270
286
  if (!model) return false;
271
287
  return model.toLowerCase().includes('mythos');
@@ -328,11 +344,11 @@ export const supportsEffortLevel = model => {
328
344
  /**
329
345
  * Check if a model supports the xhigh effort level.
330
346
  * Official docs list xhigh for Claude Fable 5, Claude Mythos 5, Claude Opus 4.7,
331
- * and Opus 4.8 (Issue #1832, Issue #1875).
347
+ * Opus 4.8, and Sonnet 5 (Issue #1832, Issue #1875, Issue #2003).
332
348
  * @param {string} model - The model name or ID
333
349
  * @returns {boolean} True if the model supports xhigh effort
334
350
  */
335
- export const supportsXHighEffortLevel = model => isFable5OrMythos5(model) || isOpus47(model);
351
+ export const supportsXHighEffortLevel = model => isFable5OrMythos5(model) || isOpus47(model) || isSonnet5(model);
336
352
 
337
353
  /**
338
354
  * Check if a model supports the max effort level.
@@ -344,14 +360,14 @@ export const supportsXHighEffortLevel = model => isFable5OrMythos5(model) || isO
344
360
  export const supportsMaxEffortLevel = model => isFable5OrMythos5(model) || isMythosPreview(model) || isOpus47OrLater(model) || isOpus46(model) || isSonnet46OrLater(model);
345
361
 
346
362
  /**
347
- * Get the max output tokens for a specific model (Issue #1221, Issue #1875)
348
- * Claude Fable 5 and Claude Mythos 5 support up to 128k output tokens, matching
349
- * the Opus 4.6+ ceiling.
363
+ * Get the max output tokens for a specific model (Issue #1221, Issue #1875, Issue #2003)
364
+ * Claude Fable 5, Claude Mythos 5, and Claude Sonnet 5 support up to 128k output tokens,
365
+ * matching the Opus 4.6+ ceiling.
350
366
  * @param {string} model - The model name or ID
351
367
  * @returns {number} The max output tokens for the model
352
368
  */
353
369
  export const getMaxOutputTokensForModel = model => {
354
- if (isOpus46OrLater(model) || isFable5OrMythos5(model)) {
370
+ if (isOpus46OrLater(model) || isFable5OrMythos5(model) || isSonnet5(model)) {
355
371
  return claudeCode.maxOutputTokensOpus46;
356
372
  }
357
373
  return claudeCode.maxOutputTokens;
@@ -564,12 +580,12 @@ export const getClaudeEnv = (options = {}) => {
564
580
 
565
581
  // Opus 4.7+ always uses adaptive thinking — MAX_THINKING_TOKENS has no effect (Issue #1620, Issue #1832)
566
582
  // Opus 4.8 inherits this constraint: adaptive thinking is the only thinking mode.
567
- // Claude Fable 5 and Claude Mythos 5 are adaptive-thinking-only too: extended/manual
568
- // thinking is unavailable and `thinking: {type: "disabled"}` is rejected, so a
569
- // MAX_THINKING_TOKENS=0 would be invalid for them (Issue #1875).
583
+ // Claude Fable 5, Claude Mythos 5, and Claude Sonnet 5 are adaptive-thinking-only too:
584
+ // extended/manual thinking is unavailable and `thinking: {type: "disabled"}` is rejected,
585
+ // so a MAX_THINKING_TOKENS=0 would be invalid for them (Issue #1875, Issue #2003).
570
586
  // For Opus 4.6 and earlier, MAX_THINKING_TOKENS controls extended thinking (Claude Code >= 2.1.12)
571
587
  // Default is 0 (thinking disabled) per Issue #1238.
572
- const adaptiveThinkingOnly = options.model && (isOpus47OrLater(options.model) || isFable5OrMythos5(options.model));
588
+ const adaptiveThinkingOnly = options.model && (isOpus47OrLater(options.model) || isFable5OrMythos5(options.model) || isSonnet5(options.model));
573
589
  if (adaptiveThinkingOnly) {
574
590
  // Remove any inherited MAX_THINKING_TOKENS from process.env — these models ignore it
575
591
  delete env.MAX_THINKING_TOKENS;
@@ -29,10 +29,10 @@ const execFileAsync = promisify(execFile);
29
29
  // ─── MODEL DATA ──────────────────────────────────────────────────────────────
30
30
 
31
31
  // Claude models (Anthropic API)
32
- // Updated for Opus 4.5/4.6/4.7/4.8, Sonnet 4.6, and Fable 5 / Mythos 5 support
33
- // (Issue #1221, Issue #1238, Issue #1329, Issue #1433, Issue #1620, Issue #1832, Issue #1875)
32
+ // Updated for Opus 4.5/4.6/4.7/4.8, Sonnet 4.6/5, and Fable 5 / Mythos 5 support
33
+ // (Issue #1221, Issue #1238, Issue #1329, Issue #1433, Issue #1620, Issue #1832, Issue #1875, Issue #2003)
34
34
  export const claudeModels = {
35
- sonnet: 'claude-sonnet-4-6', // Sonnet 4.6 (default, Issue #1329)
35
+ sonnet: 'claude-sonnet-5', // Sonnet 5 (default, Issue #2003)
36
36
  opus: 'claude-opus-4-8', // Opus 4.8 (Issue #1832)
37
37
  haiku: 'claude-haiku-4-5-20251001', // Haiku 4.5
38
38
  'haiku-3-5': 'claude-3-5-haiku-20241022', // Haiku 3.5
@@ -46,6 +46,7 @@ export const claudeModels = {
46
46
  'mythos-5': 'claude-mythos-5', // Mythos 5 short alias
47
47
  'claude-mythos-5': 'claude-mythos-5', // Mythos 5 full ID
48
48
  // Shorter version aliases (Issue #1221, Issue #1329 - PR comment feedback)
49
+ 'sonnet-5': 'claude-sonnet-5', // Sonnet 5 short alias (Issue #2003)
49
50
  'sonnet-4-6': 'claude-sonnet-4-6', // Sonnet 4.6 short alias (Issue #1329)
50
51
  'opus-4-8': 'claude-opus-4-8', // Opus 4.8 short alias (Issue #1832)
51
52
  'opus-4-7': 'claude-opus-4-7', // Opus 4.7 short alias (backward compatibility)
@@ -56,6 +57,7 @@ export const claudeModels = {
56
57
  // Version aliases for backward compatibility (Issue #1221, Issue #1329, Issue #1620, Issue #1832)
57
58
  'claude-opus-4-8': 'claude-opus-4-8', // Opus 4.8 (Issue #1832)
58
59
  'claude-opus-4-7': 'claude-opus-4-7', // Opus 4.7 (backward compatibility)
60
+ 'claude-sonnet-5': 'claude-sonnet-5', // Sonnet 5 (Issue #2003)
59
61
  'claude-sonnet-4-6': 'claude-sonnet-4-6', // Sonnet 4.6 (Issue #1329)
60
62
  'claude-opus-4-6': 'claude-opus-4-6', // Opus 4.6 (backward compatibility)
61
63
  'claude-opus-4-5': 'claude-opus-4-5-20251101', // Opus 4.5
@@ -207,10 +209,12 @@ export const MODELS_SUPPORTING_1M_CONTEXT = [
207
209
  'claude-opus-4-7', // Opus 4.7 (Issue #1620)
208
210
  'claude-opus-4-6',
209
211
  'claude-opus-4-5-20251101',
212
+ 'claude-sonnet-5', // Sonnet 5 — 1M context (Issue #2003)
210
213
  'claude-sonnet-4-6', // Sonnet 4.6 (Issue #1329)
211
214
  'claude-sonnet-4-5-20250929',
212
215
  'claude-sonnet-4-5',
213
- 'sonnet', // Now maps to Sonnet 4.6 (Issue #1329)
216
+ 'sonnet', // Now maps to Sonnet 5 (Issue #2003)
217
+ 'sonnet-5', // Short alias (Issue #2003)
214
218
  'sonnet-4-6', // Short alias (Issue #1329)
215
219
  'opus', // Now maps to Opus 4.8 (Issue #1832)
216
220
  'opus-4-8', // Short alias (Issue #1832)
@@ -1148,6 +1152,8 @@ export const defaultFallbackModels = {
1148
1152
  'claude-mythos-5': 'fable',
1149
1153
  'claude-opus-4-8': 'opus-4-7',
1150
1154
  'claude-opus-4-7': 'opus-4-6',
1155
+ // Claude Sonnet 5 falls back to the prior Sonnet generation (Issue #2003).
1156
+ 'claude-sonnet-5': 'sonnet-4-6',
1151
1157
  },
1152
1158
  codex: {
1153
1159
  'gpt-5.6-sol': 'gpt-5.5',
@@ -193,6 +193,15 @@ function isPersistableSession(sessionInfo) {
193
193
  return Boolean(sessionInfo?.isolationBackend && sessionInfo?.sessionId);
194
194
  }
195
195
 
196
+ function persistSessionSnapshot(sessionName, sessionInfo) {
197
+ if (!sessionStore || !isPersistableSession(sessionInfo)) return;
198
+ try {
199
+ sessionStore.persist(sessionName, sessionInfo);
200
+ } catch {
201
+ /* best effort — persistence must never break monitoring */
202
+ }
203
+ }
204
+
196
205
  /**
197
206
  * Look up the in-memory record for a session id (UUID for isolation sessions
198
207
  * or the screen session name for non-isolation sessions). Returns null when no
@@ -347,7 +356,6 @@ export async function buildDiskDiagnosticsExtraSection(logPath, { verbose = fals
347
356
  if (!logPath && !Number.isFinite(containerFilesystemStartBytes) && !Number.isFinite(containerFilesystemAfterBytes)) return '';
348
357
  try {
349
358
  const diskLib = await import('./solve.disk-diagnostics.lib.mjs');
350
- const resourceLib = await import('./solve.resource-diagnostics.lib.mjs');
351
359
  let logText = '';
352
360
  if (logPath) {
353
361
  try {
@@ -359,17 +367,11 @@ export async function buildDiskDiagnosticsExtraSection(logPath, { verbose = fals
359
367
  }
360
368
  }
361
369
  const parsed = diskLib.parseDiskMarkers(logText);
362
- const parsedResources = resourceLib.parseResourceMarkers(logText);
363
- const bestResourceMarker = resourceLib.selectBestDiskResourceMarker(parsedResources);
364
- const solveStartResourceMarker = parsedResources.byPhase?.[resourceLib.RESOURCE_PHASE_SOLVE_START] || null;
365
- const useResourceFallback = String(isolationBackend || '').toLowerCase() === 'docker' && !Number.isFinite(containerFilesystemAfterBytes) && Number.isFinite(bestResourceMarker?.disk?.usedBytes);
366
- const effectiveContainerFilesystemAfterBytes = useResourceFallback ? bestResourceMarker.disk.usedBytes : containerFilesystemAfterBytes;
367
- const effectiveContainerFilesystemStartBytes = useResourceFallback && Number.isFinite(solveStartResourceMarker?.disk?.usedBytes) ? solveStartResourceMarker.disk.usedBytes : containerFilesystemStartBytes;
368
- if (!parsed.afterClone && !parsed.afterAgent && !Number.isFinite(effectiveContainerFilesystemStartBytes) && !Number.isFinite(effectiveContainerFilesystemAfterBytes)) return '';
370
+ if (!parsed.afterClone && !parsed.afterAgent && !Number.isFinite(containerFilesystemStartBytes) && !Number.isFinite(containerFilesystemAfterBytes)) return '';
369
371
  return diskLib.formatDiskDiagnosticsBlock(parsed, {
370
372
  isolationBackend,
371
- containerFilesystemStartBytes: effectiveContainerFilesystemStartBytes,
372
- containerFilesystemAfterBytes: effectiveContainerFilesystemAfterBytes,
373
+ containerFilesystemStartBytes,
374
+ containerFilesystemAfterBytes,
373
375
  });
374
376
  } catch (error) {
375
377
  if (verbose) {
@@ -399,6 +401,19 @@ async function getDockerContainerFilesystemSizeForSession(sessionName, sessionIn
399
401
  }
400
402
  }
401
403
 
404
+ async function refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
405
+ const bytes = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose, sizeProvider });
406
+ if (!Number.isFinite(bytes)) return null;
407
+ sessionInfo.containerFilesystemLastBytes = bytes;
408
+ sessionInfo.containerFilesystemLastObservedAt = new Date().toISOString();
409
+ persistSessionSnapshot(sessionName, sessionInfo);
410
+ return bytes;
411
+ }
412
+
413
+ function getLastKnownDockerContainerFilesystemSize(sessionInfo) {
414
+ return Number.isFinite(sessionInfo?.containerFilesystemLastBytes) ? sessionInfo.containerFilesystemLastBytes : null;
415
+ }
416
+
402
417
  function isSuccessfulTaskCompletion({ exitCode = null, status = null } = {}) {
403
418
  const outcome = classifySessionOutcome({ exitCode, status });
404
419
  if (outcome.failed) return false;
@@ -664,6 +679,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
664
679
  let exitCode = null;
665
680
  let statusResult = null;
666
681
  let resolvedStatus = null;
682
+ let observedContainerFilesystemBytes = null;
667
683
 
668
684
  if (sessionInfo.isolationBackend && sessionInfo.sessionId) {
669
685
  // Isolation mode: use $ --status, with screen -ls only as a fallback
@@ -689,13 +705,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
689
705
  // the log footer to learn whether it was killed.
690
706
  if (statusResult?.logPath && sessionInfo.logPath !== statusResult.logPath) {
691
707
  sessionInfo.logPath = statusResult.logPath;
692
- if (sessionStore) {
693
- try {
694
- sessionStore.persist(sessionName, sessionInfo);
695
- } catch {
696
- /* best effort — persistence must never break monitoring */
697
- }
698
- }
708
+ persistSessionSnapshot(sessionName, sessionInfo);
699
709
  }
700
710
  } else {
701
711
  // Issue #1586: Non-isolation screen sessions cannot reliably detect
@@ -717,6 +727,13 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
717
727
  }
718
728
  }
719
729
 
730
+ if (sessionInfo?.isolationBackend === 'docker') {
731
+ observedContainerFilesystemBytes = await refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
732
+ verbose,
733
+ sizeProvider: options.dockerContainerSizeProvider,
734
+ });
735
+ }
736
+
720
737
  if (!stillRunning) {
721
738
  console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
722
739
 
@@ -828,10 +845,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
828
845
  const diskExtraSections = [];
829
846
  try {
830
847
  const diskLogPath = statusResult?.logPath || sessionInfo?.logPath || null;
831
- const containerFilesystemAfterBytes = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
832
- verbose,
833
- sizeProvider: options.dockerContainerSizeProvider,
834
- });
848
+ const containerFilesystemAfterBytes = Number.isFinite(observedContainerFilesystemBytes) ? observedContainerFilesystemBytes : getLastKnownDockerContainerFilesystemSize(sessionInfo);
835
849
  const diskBlock = await buildDiskDiagnosticsExtraSection(diskLogPath, {
836
850
  verbose,
837
851
  readFile: options.readFile,
@@ -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', 'containerFilesystemStartBytes', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
36
+ const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
37
37
 
38
38
  /**
39
39
  * Resolve the directory durable bot state is written to. Honors
@@ -299,7 +299,7 @@ export const SOLVE_OPTION_DEFINITIONS = {
299
299
  },
300
300
  think: {
301
301
  type: 'string',
302
- description: 'Thinking level hint. For Claude, translated to --thinking-budget for Claude Code >= 2.1.12 (off=0, low=~8000, medium=~16000, high=~24000, xhigh/max=31999) and to CLAUDE_CODE_EFFORT_LEVEL when supported. Fable 5/Mythos 5/Opus 4.8/4.7 support xhigh and max; Opus 4.6/Sonnet 4.6/Mythos Preview support max; Opus 4.5 uses high for xhigh/max. For Codex, mapped to reasoning effort (off=none, low=low, medium=medium, high=high, xhigh/max=xhigh).',
302
+ description: 'Thinking level hint. For Claude, translated to --thinking-budget for Claude Code >= 2.1.12 (off=0, low=~8000, medium=~16000, high=~24000, xhigh/max=31999) and to CLAUDE_CODE_EFFORT_LEVEL when supported. Fable 5/Mythos 5/Sonnet 5/Opus 4.8/4.7 support xhigh and max; Opus 4.6/Sonnet 4.6/Mythos Preview support max; Opus 4.5 uses high for xhigh/max. For Codex, mapped to reasoning effort (off=none, low=low, medium=medium, high=high, xhigh/max=xhigh).',
303
303
  choices: ['off', 'low', 'medium', 'high', 'xhigh', 'max'],
304
304
  default: undefined,
305
305
  },
@@ -72,8 +72,10 @@ const TIER_ALIASES = {
72
72
  'claude-haiku-4-5': 'haiku',
73
73
  'claude-haiku-4-5-20251001': 'haiku',
74
74
  sonnet: 'sonnet',
75
+ 'sonnet-5': 'sonnet',
75
76
  'sonnet-4-6': 'sonnet',
76
77
  'sonnet-4-5': 'sonnet',
78
+ 'claude-sonnet-5': 'sonnet',
77
79
  'claude-sonnet-4-6': 'sonnet',
78
80
  'claude-sonnet-4-5': 'sonnet',
79
81
  opus: 'opus',
package/src/solve.mjs CHANGED
@@ -106,7 +106,7 @@ configureGitHubRateLimitLogging({
106
106
  enabled: argv.githubRateLimitsLogging === true,
107
107
  log,
108
108
  });
109
- await recordResourceSnapshot({ phase: RESOURCE_PHASE_SOLVE_START, log, diskPath: '/', label: 'solve start' });
109
+ await recordResourceSnapshot({ phase: RESOURCE_PHASE_SOLVE_START, log, diskPath: '/', label: 'solve start', logExecutionContext: true }); // #2001: detect+report container context
110
110
 
111
111
  // Early logs go to cwd; custom log dir takes effect after argv is parsed
112
112
  // Conditionally import tool-specific functions after argv is parsed
@@ -34,6 +34,110 @@ function readLinuxMemAvailableBytes(readFileSync = fs.readFileSync, platform = p
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * Detect where the current process is running so the solve command can scope
39
+ * per-task disk usage to the correct context (issue #2001).
40
+ *
41
+ * When solve runs inside a Docker/container isolation backend, the per-task
42
+ * disk measurement must come from container-scoped signals (the cloned working
43
+ * tree measured with `du` inside the container, plus the host-side
44
+ * `docker inspect --size` writable-layer size) rather than the whole-VM
45
+ * filesystem reported by `statfs('/')`. This helper makes that detection
46
+ * explicit and observable in the solve log.
47
+ *
48
+ * It is intentionally defensive: any injected `existsSync`/`readFileSync`
49
+ * implementation that lacks a method or throws is treated as "signal absent"
50
+ * rather than propagating an error, so callers never crash on detection.
51
+ *
52
+ * @returns {{ inContainer: boolean, runtime: string|null, indicators: string[] }}
53
+ */
54
+ export function detectExecutionContext(options = {}) {
55
+ const { existsSync = fs.existsSync, readFileSync = fs.readFileSync, env = process.env, platform = process.platform } = options;
56
+
57
+ const indicators = [];
58
+ let runtime = null;
59
+
60
+ const safeExists = target => {
61
+ try {
62
+ return typeof existsSync === 'function' ? existsSync(target) === true : false;
63
+ } catch {
64
+ return false;
65
+ }
66
+ };
67
+
68
+ const safeRead = target => {
69
+ try {
70
+ return typeof readFileSync === 'function' ? String(readFileSync(target, 'utf8')) : '';
71
+ } catch {
72
+ return '';
73
+ }
74
+ };
75
+
76
+ if (safeExists('/.dockerenv')) {
77
+ indicators.push('/.dockerenv');
78
+ runtime = runtime || 'docker';
79
+ }
80
+ if (safeExists('/run/.containerenv')) {
81
+ indicators.push('/run/.containerenv');
82
+ runtime = runtime || 'podman';
83
+ }
84
+
85
+ if (platform === 'linux') {
86
+ const cgroup = safeRead('/proc/1/cgroup');
87
+ if (cgroup) {
88
+ if (/\bdocker\b/.test(cgroup)) {
89
+ indicators.push('cgroup:docker');
90
+ runtime = runtime || 'docker';
91
+ }
92
+ if (/kubepods/.test(cgroup)) {
93
+ indicators.push('cgroup:kubepods');
94
+ runtime = runtime || 'kubernetes';
95
+ }
96
+ if (/libpod|podman/.test(cgroup)) {
97
+ indicators.push('cgroup:podman');
98
+ runtime = runtime || 'podman';
99
+ }
100
+ if (/containerd/.test(cgroup)) {
101
+ indicators.push('cgroup:containerd');
102
+ runtime = runtime || 'containerd';
103
+ }
104
+ }
105
+ }
106
+
107
+ const envObj = env && typeof env === 'object' ? env : {};
108
+ if (envObj.HIVE_MIND_ISOLATION || envObj.HIVE_MIND_CONTAINER) {
109
+ indicators.push('env:hive-mind-isolation');
110
+ runtime = runtime || 'container';
111
+ }
112
+ if (envObj.KUBERNETES_SERVICE_HOST) {
113
+ indicators.push('env:kubernetes');
114
+ runtime = runtime || 'kubernetes';
115
+ }
116
+
117
+ return {
118
+ inContainer: indicators.length > 0,
119
+ runtime,
120
+ indicators,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Human-readable one-liner describing the detected execution context, used to
126
+ * make the disk-usage scope explicit in the solve log.
127
+ *
128
+ * @param {ReturnType<typeof detectExecutionContext>} context
129
+ * @returns {string}
130
+ */
131
+ export function formatExecutionContextForLog(context) {
132
+ const ctx = context || detectExecutionContext();
133
+ if (ctx.inContainer) {
134
+ const runtime = ctx.runtime || 'container';
135
+ const detail = ctx.indicators.length ? ` (indicators: ${ctx.indicators.join(', ')})` : '';
136
+ return `🧭 Execution context: ${runtime} container${detail} — per-task disk usage is scoped to this container.`;
137
+ }
138
+ return '🧭 Execution context: host (no container isolation detected) — per-task disk usage is scoped to the working tree.';
139
+ }
140
+
37
141
  export function captureResourceSnapshot(options = {}) {
38
142
  const { phase = 'snapshot', diskPath = '/', now = () => new Date(), osImpl = os, fsImpl = fs, processImpl = process } = options;
39
143
 
@@ -278,9 +382,18 @@ export function formatResourceSnapshotForLog(snapshot, label = null) {
278
382
  return lines.join('\n');
279
383
  }
280
384
 
281
- export async function recordResourceSnapshot({ phase, log, diskPath = '/', label = null, capture = captureResourceSnapshot } = {}) {
385
+ export async function recordResourceSnapshot({ phase, log, diskPath = '/', label = null, capture = captureResourceSnapshot, logExecutionContext = false, detectContext = detectExecutionContext } = {}) {
282
386
  if (typeof log !== 'function') return null;
283
387
  try {
388
+ // Issue #2001: optionally report the execution context (host vs container)
389
+ // so it is explicit that per-task disk usage is scoped to the container.
390
+ if (logExecutionContext) {
391
+ try {
392
+ await log(formatExecutionContextForLog(detectContext()));
393
+ } catch {
394
+ /* context detection is best-effort and must never block the snapshot */
395
+ }
396
+ }
284
397
  const snapshot = capture({ phase, diskPath });
285
398
  await log(formatResourceSnapshotForLog(snapshot, label));
286
399
  return snapshot;