@link-assistant/hive-mind 2.0.5 → 2.0.7

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,58 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 6d9a2bb: feat(solve): log working-tree size before/after the AI agent and warn on Telegram when disk usage exceeds 5 GB (#1945)
8
+
9
+ `/solve` now records the size of its temporary working tree at two checkpoints:
10
+ after the repository is cloned (before the AI agent starts) and after the AI
11
+ working session ends. Both checkpoints emit a structured `📊 [DISK]` marker into
12
+ the captured solve log, so the cloned-repo size, the AI-induced delta, and the
13
+ final total are visible in `tail -f`-style debugging.
14
+
15
+ The session monitor parses those markers from the captured log and appends a
16
+ `💾 Disk usage` block to the Telegram completion message. The block raises a
17
+ warning when the cloned repository exceeds 5 GB, when the working tree grew by
18
+ more than 5 GB during the run, or when the total disk usage for the task
19
+ exceeds 5 GB — exactly the three conditions called out in the issue.
20
+
21
+ Sizing uses `du -sb` (byte-accurate on Linux), falls back to `du -sk` on BSD/
22
+ macOS, and finally to `fs.statSync` for single-file targets — no new runtime
23
+ dependency. The threshold is 5 GiB and uses a strict `>` comparison, so a tree
24
+ that lands at exactly 5 GiB does not warn.
25
+
26
+ ## 2.0.6
27
+
28
+ ### Patch Changes
29
+
30
+ - 0c63706: Stop surfacing meaningless stream fragments as tool errors (#1941). When a tool
31
+ run is interrupted mid-stream (CTRL+C / SIGINT, exit code 130), the last captured
32
+ stdout line could be a stray structural character such as a lone `}`, which leaked
33
+ into the GitHub failure comment as "CLAUDE execution failed with }". A new shared
34
+ `isMeaningfulErrorText` helper (any error with at least one Unicode letter or digit
35
+ is real; pure punctuation is not) now guards the `extractToolErrorCore` chokepoint,
36
+ and a new `buildToolErrorMessage` helper labels interruptions explicitly
37
+ ("Claude command interrupted (CTRL+C)") across the Claude and OpenCode runners.
38
+ - d4efc82: fix(playwright-mcp): do not abort the solve when the Playwright MCP preflight probe is inconclusive (#1943)
39
+
40
+ A `solve` run aborted before creating a pull request with
41
+ `❌ Playwright MCP preflight failed for Claude Code`. The local preflight ran
42
+ `timeout 5 claude mcp list`, but that command performs a live health check that
43
+ launches a browser and can take longer than five seconds; when the `timeout`
44
+ killed the probe, `ensureConnectedPlaywrightMcpServer` treated the non-zero exit
45
+ as a failure and stopped the whole run.
46
+
47
+ An inconclusive `mcp list` probe (timeout / crash / missing CLI) now falls back
48
+ to the local `@playwright/mcp` package check instead of aborting: if the package
49
+ is installed, the server connects on demand via Tool Search (issue #1901), so the
50
+ working session proceeds. The probe timeout now defaults to 30s and is overridable
51
+ via `PLAYWRIGHT_MCP_PREFLIGHT_TIMEOUT_SECONDS`, and the preflight emits verbose
52
+ diagnostics (probe exit code, matched rows, decision branch) so failures are
53
+ diagnosable from the log. The preflight still fails only when `@playwright/mcp`
54
+ is genuinely unavailable.
55
+
3
56
  ## 2.0.5
4
57
 
5
58
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -6,7 +6,7 @@ if (typeof globalThis.use === 'undefined') {
6
6
  const { $ } = await use('command-stream');
7
7
  const fs = (await use('fs')).promises;
8
8
  const path = (await use('path')).default;
9
- import { log, isENOSPC } from './lib.mjs';
9
+ import { log, isENOSPC, buildToolErrorMessage } from './lib.mjs';
10
10
  import { reportError } from './sentry.lib.mjs';
11
11
  import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET, getMaxOutputTokensForModel } from './config.lib.mjs';
12
12
  import { detectUsageLimit, formatUsageLimitMessage, isUsageLimitError } from './usage-limit.lib.mjs';
@@ -1211,8 +1211,8 @@ export const executeClaudeCommand = async params => {
1211
1211
  is503Error,
1212
1212
  anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnStuckRetry, // Issue #1104/#1886
1213
1213
  resultSummary,
1214
- // Issue #1845: surface the actual error so callers can show it to users
1215
- errorInfo: { message: lastMessage || 'API explicitly marked error as not retryable', exitCode },
1214
+ // Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
1215
+ errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: 'API explicitly marked error as not retryable', toolLabel: 'Claude' }), exitCode },
1216
1216
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1217
1217
  };
1218
1218
  }
@@ -1260,8 +1260,8 @@ export const executeClaudeCommand = async params => {
1260
1260
  is503Error, // preserve for callers that check this
1261
1261
  anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnRetriesExhausted, // Issue #1104/#1886: Include cumulative cost even on failure
1262
1262
  resultSummary, // Issue #1263: Include result summary
1263
- // Issue #1845: surface the actual error so callers can show it to users
1264
- errorInfo: { message: lastMessage || `Transient API error persisted after ${maxRetries} retries`, exitCode },
1263
+ // Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
1264
+ errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Transient API error persisted after ${maxRetries} retries`, toolLabel: 'Claude' }), exitCode },
1265
1265
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1266
1266
  };
1267
1267
  }
@@ -1327,9 +1327,9 @@ export const executeClaudeCommand = async params => {
1327
1327
  errorDuringExecution,
1328
1328
  anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnFailure, // Issue #1104/#1886: cumulative cost even on failure
1329
1329
  resultSummary, // Issue #1263: Include result summary
1330
- // Issue #1845: surface the core error (e.g. "API Error: Output blocked by content
1331
- // filtering policy") so users see what actually went wrong, not just a generic message.
1332
- errorInfo: { message: lastMessage || `Claude command failed with exit code ${exitCode}`, exitCode },
1330
+ // Issue #1845: surface the core error (e.g. "API Error: Output blocked by content filtering policy").
1331
+ // Issue #1941: a lone "}" fragment at interrupt time must not become "CLAUDE execution failed with }".
1332
+ errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Claude command failed with exit code ${exitCode}`, toolLabel: 'Claude' }), exitCode },
1333
1333
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1334
1334
  };
1335
1335
  }
@@ -10,7 +10,7 @@ if (typeof globalThis.use === 'undefined') {
10
10
 
11
11
  const { $ } = await use('command-stream');
12
12
 
13
- import { log } from './lib.mjs';
13
+ import { log, buildToolErrorMessage } from './lib.mjs';
14
14
  import { reportError } from './sentry.lib.mjs';
15
15
  import { timeouts, retryLimits } from './config.lib.mjs';
16
16
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
@@ -576,8 +576,8 @@ export const executeGeminiCommand = async params => {
576
576
  pricingInfo: { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null },
577
577
  publicPricingEstimate: null,
578
578
  resultSummary: geminiJsonState.resultSummary || null,
579
- // Issue #1845: surface the actual error so callers can show it to users
580
- errorInfo: { message: errorText || `Gemini command failed with exit code ${exitCode}`, exitCode },
579
+ // Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
580
+ errorInfo: { message: buildToolErrorMessage({ lastMessage: errorText, exitCode, fallback: `Gemini command failed with exit code ${exitCode}`, toolLabel: 'Gemini' }), exitCode },
581
581
  };
582
582
  }
583
583
 
package/src/lib.mjs CHANGED
@@ -665,6 +665,53 @@ export const cleanErrorMessage = error => {
665
665
  return message;
666
666
  };
667
667
 
668
+ /**
669
+ * Decide whether a string looks like a meaningful, human-readable error message
670
+ * rather than a stray structural fragment (Issue #1941).
671
+ *
672
+ * When a tool process is interrupted mid-stream (CTRL+C / SIGINT) or killed, the
673
+ * last captured stdout line can be a lone JSON-structural character left over
674
+ * from a truncated stream — for example a bare `}` or `{`. Surfacing that as the
675
+ * "core error" produced nonsense failure messages such as
676
+ * "CLAUDE execution failed with }" / "failed by {". A real error message always
677
+ * contains at least one letter or digit (in any script), so we treat fragments
678
+ * that contain none as not meaningful.
679
+ *
680
+ * @param {*} value - Candidate error string
681
+ * @returns {boolean} True when the value contains usable error text
682
+ */
683
+ export const isMeaningfulErrorText = value => {
684
+ if (!value || typeof value !== 'string') return false;
685
+ const collapsed = value.replace(/\s+/g, ' ').trim();
686
+ if (!collapsed) return false;
687
+ // Require at least one Unicode letter or number; pure punctuation/brackets
688
+ // (e.g. "}", "{", "[]", ",") are stream fragments, not real errors.
689
+ return /[\p{L}\p{N}]/u.test(collapsed);
690
+ };
691
+
692
+ /**
693
+ * Build a clean tool error message for `errorInfo.message`, rejecting
694
+ * meaningless stream fragments (Issue #1941).
695
+ *
696
+ * Picks the tool-reported `lastMessage` only when it is meaningful; otherwise
697
+ * falls back to an interrupt label (exit code 130 = SIGINT/CTRL+C) or the
698
+ * provided generic fallback. This keeps junk like a lone `}` out of the stored
699
+ * error so every downstream surface (GitHub comment, terminal, retry logic)
700
+ * shows something honest.
701
+ *
702
+ * @param {Object} options
703
+ * @param {string} [options.lastMessage] - The last message captured from the tool stream
704
+ * @param {number} [options.exitCode] - Process exit code
705
+ * @param {string} [options.fallback] - Generic fallback message
706
+ * @param {string} [options.toolLabel='Tool'] - Human tool label for the interrupt message
707
+ * @returns {string} A clean, meaningful error message
708
+ */
709
+ export const buildToolErrorMessage = ({ lastMessage, exitCode, fallback, toolLabel = 'Tool' } = {}) => {
710
+ if (isMeaningfulErrorText(lastMessage)) return lastMessage.replace(/\s+/g, ' ').trim();
711
+ if (exitCode === 130) return `${toolLabel} command interrupted (CTRL+C)`;
712
+ return fallback;
713
+ };
714
+
668
715
  /**
669
716
  * Extract the core/root error string from a tool runner result (Issue #1845).
670
717
  *
@@ -675,6 +722,10 @@ export const cleanErrorMessage = error => {
675
722
  * (GitHub comments / exit message) and the terminal "Error details:" lines in
676
723
  * watch / auto-merge so they never diverge.
677
724
  *
725
+ * Issue #1941: a meaningless structural fragment (e.g. a lone `}` captured when
726
+ * a tool is interrupted mid-stream) is treated as "no usable error" so callers
727
+ * fall back to the generic phrase instead of "execution failed with }".
728
+ *
678
729
  * @param {Object} options
679
730
  * @param {Object} [options.toolResult] - Result object returned by the tool runner
680
731
  * @returns {string|null} The core error string, or null when none is available
@@ -688,6 +739,9 @@ export const extractToolErrorCore = ({ toolResult } = {}) => {
688
739
 
689
740
  if (!rawCore || typeof rawCore !== 'string') return null;
690
741
 
742
+ // Issue #1941: reject stray fragments with no letters/digits (e.g. "}").
743
+ if (!isMeaningfulErrorText(rawCore)) return null;
744
+
691
745
  // Collapse to a single clean line and strip noise.
692
746
  const core = rawCore.replace(/\s+/g, ' ').trim();
693
747
  return core || null;
@@ -14,7 +14,7 @@ const path = (await use('path')).default;
14
14
  const os = (await use('os')).default;
15
15
 
16
16
  // Import log from general lib
17
- import { log } from './lib.mjs';
17
+ import { log, isMeaningfulErrorText, buildToolErrorMessage } from './lib.mjs';
18
18
  import { reportError } from './sentry.lib.mjs';
19
19
  import { timeouts, retryLimits } from './config.lib.mjs';
20
20
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
@@ -532,7 +532,8 @@ export const executeOpenCodeCommand = async params => {
532
532
  ...pricingResult,
533
533
  resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
534
534
  // Issue #1845: surface the actual error so callers can show it to users
535
- errorInfo: { message: lastMessage || allOutput || `OpenCode command failed with exit code ${exitCode}`, exitCode },
535
+ // Issue #1941: reject meaningless stream fragments (e.g. a lone "}").
536
+ errorInfo: { message: buildToolErrorMessage({ lastMessage: isMeaningfulErrorText(lastMessage) ? lastMessage : allOutput, exitCode, fallback: `OpenCode command failed with exit code ${exitCode}`, toolLabel: 'OpenCode' }), exitCode },
536
537
  };
537
538
  }
538
539
 
@@ -30,44 +30,109 @@ export const hasConnectedPlaywrightMcpServer = output => {
30
30
  return rows.some(row => PLAYWRIGHT_MCP_CONNECTED_PATTERN.test(row) && !PLAYWRIGHT_MCP_UNAVAILABLE_PATTERN.test(row));
31
31
  };
32
32
 
33
- export const checkPlaywrightMcpPackageAvailability = async () => {
33
+ // `claude mcp list` / `codex mcp list` perform live health checks against every
34
+ // registered MCP server (Playwright MCP launches a browser to report status),
35
+ // which can take noticeably longer than a couple of seconds on a cold cache or
36
+ // a busy CI host. A too-aggressive `timeout` kills the probe before it answers,
37
+ // which previously aborted the entire solve (issue #1943). The probe timeout is
38
+ // therefore generous by default and overridable for slow/fast environments.
39
+ export const PLAYWRIGHT_MCP_LIST_TIMEOUT_SECONDS_DEFAULT = 30;
40
+
41
+ export const getPlaywrightMcpListTimeoutSeconds = (env = process.env) => {
42
+ const parsed = Number.parseInt(env?.PLAYWRIGHT_MCP_PREFLIGHT_TIMEOUT_SECONDS ?? '', 10);
43
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : PLAYWRIGHT_MCP_LIST_TIMEOUT_SECONDS_DEFAULT;
44
+ };
45
+
46
+ const diagLog =
47
+ (log, opts = { verbose: true }) =>
48
+ async message => {
49
+ if (log) await log(message, opts);
50
+ };
51
+
52
+ export const checkPlaywrightMcpPackageAvailability = async ({ log } = {}) => {
53
+ const diag = diagLog(log);
34
54
  try {
35
- const result = await $`timeout 5 npx --no-install @playwright/mcp --help 2>&1`.catch(() => null);
36
- if (isCommandResultSuccess(result)) return true;
37
- const npmResult = await $`timeout 5 npm ls -g @playwright/mcp 2>&1`.catch(() => null);
38
- return getCommandResultOutput(npmResult).includes('@playwright/mcp');
39
- } catch {
55
+ const timeoutSeconds = getPlaywrightMcpListTimeoutSeconds();
56
+ const result = await $`timeout ${timeoutSeconds} npx --no-install @playwright/mcp --help 2>&1`.catch(() => null);
57
+ if (isCommandResultSuccess(result)) {
58
+ await diag('🎭 @playwright/mcp package is available via npx');
59
+ return true;
60
+ }
61
+ const npmResult = await $`timeout ${timeoutSeconds} npm ls -g @playwright/mcp 2>&1`.catch(() => null);
62
+ const available = getCommandResultOutput(npmResult).includes('@playwright/mcp');
63
+ await diag(`🎭 @playwright/mcp global package ${available ? 'is installed' : 'was not found'}`);
64
+ return available;
65
+ } catch (error) {
66
+ await diag(`⚠️ @playwright/mcp package availability check threw: ${error.message}`);
40
67
  return false;
41
68
  }
42
69
  };
43
70
 
44
- export const ensureConnectedPlaywrightMcpServer = async ({ list, add, hasPackage = checkPlaywrightMcpPackageAvailability }) => {
71
+ export const ensureConnectedPlaywrightMcpServer = async ({ list, add, hasPackage = checkPlaywrightMcpPackageAvailability, log } = {}) => {
72
+ const diag = diagLog(log);
45
73
  try {
46
74
  const result = await list().catch(() => null);
47
- if (!isCommandResultSuccess(result)) return false;
75
+ const code = getCommandResultCode(result);
48
76
  const output = getCommandResultOutput(result);
49
- if (hasConnectedPlaywrightMcpServer(output)) return true;
50
- if (getPlaywrightMcpListRows(output).length > 0) return false;
51
- if (!(await hasPackage())) return false;
77
+ const rows = getPlaywrightMcpListRows(output);
78
+ await diag(`🎭 Playwright MCP probe: 'mcp list' exit=${code === null ? 'timeout/none' : code}, playwright rows=${rows.length}${rows.length ? ` [${rows.join(' | ')}]` : ''}`);
79
+
80
+ // Inconclusive probe: the `mcp list` command itself failed (timed out,
81
+ // crashed, or its binary is missing). That tells us NOTHING about whether
82
+ // Playwright MCP actually works, so it must not abort the whole solve as it
83
+ // did in issue #1943. A still-connecting server is normal — Tool Search
84
+ // loads MCP tools on demand (issue #1901) — so fall back to the local
85
+ // @playwright/mcp package check: if the package is installed, the server can
86
+ // connect on demand and the working session should proceed.
87
+ if (!isCommandResultSuccess(result)) {
88
+ const packageAvailable = await hasPackage({ log });
89
+ await diag(`⚠️ Playwright MCP 'mcp list' probe was inconclusive (exit=${code === null ? 'timeout' : code}); @playwright/mcp package ${packageAvailable ? 'is installed, so Tool Search can connect it on demand — preflight passes' : 'is NOT installed — preflight fails'}`);
90
+ return packageAvailable;
91
+ }
52
92
 
93
+ if (hasConnectedPlaywrightMcpServer(output)) {
94
+ await diag('🎭 Playwright MCP reported as connected by mcp list');
95
+ return true;
96
+ }
97
+
98
+ // A registration row exists but is not reported connected (pending /
99
+ // disabled / failed). Leave it untouched (do not overwrite an intentional
100
+ // or in-progress registration) and let the caller decide.
101
+ if (rows.length > 0) {
102
+ await diag('🎭 Playwright MCP is registered but not reported connected by mcp list; leaving registration unchanged');
103
+ return false;
104
+ }
105
+
106
+ // No registration at all → register the default server when the package is
107
+ // available, then re-probe.
108
+ if (!(await hasPackage({ log }))) {
109
+ await diag('⚠️ No Playwright MCP registration found and @playwright/mcp package is unavailable');
110
+ return false;
111
+ }
112
+ await diag('🎭 No Playwright MCP registration found; registering the default server...');
53
113
  await add().catch(() => null);
54
114
  const retryResult = await list().catch(() => null);
55
- return isCommandResultSuccess(retryResult) && hasConnectedPlaywrightMcpServer(getCommandResultOutput(retryResult));
56
- } catch {
115
+ const connected = isCommandResultSuccess(retryResult) && hasConnectedPlaywrightMcpServer(getCommandResultOutput(retryResult));
116
+ await diag(`🎭 Playwright MCP registration ${connected ? 'succeeded and is now connected' : 'did not report connected after add'}`);
117
+ return connected;
118
+ } catch (error) {
119
+ await diag(`⚠️ Playwright MCP preflight probe threw: ${error.message}`);
57
120
  return false;
58
121
  }
59
122
  };
60
123
 
61
- export const ensureClaudePlaywrightMcpServer = async () =>
124
+ export const ensureClaudePlaywrightMcpServer = async ({ log } = {}) =>
62
125
  ensureConnectedPlaywrightMcpServer({
63
- list: () => $`timeout 5 claude mcp list 2>&1`,
126
+ list: () => $`timeout ${getPlaywrightMcpListTimeoutSeconds()} claude mcp list 2>&1`,
64
127
  add: () => $`claude mcp add playwright -s user -- npx -y @playwright/mcp@latest --isolated --headless --no-sandbox --timeout-action=600000 --viewport-size 1920x1080`,
128
+ log,
65
129
  });
66
130
 
67
- export const ensureCodexPlaywrightMcpServer = async () =>
131
+ export const ensureCodexPlaywrightMcpServer = async ({ log } = {}) =>
68
132
  ensureConnectedPlaywrightMcpServer({
69
- list: () => $`timeout 5 codex mcp list 2>&1`,
133
+ list: () => $`timeout ${getPlaywrightMcpListTimeoutSeconds()} codex mcp list 2>&1`,
70
134
  add: () => $`codex mcp add playwright -- npx -y @playwright/mcp@latest --isolated --headless --no-sandbox --timeout-action=600000 --viewport-size 1920x1080`,
135
+ log,
71
136
  });
72
137
 
73
138
  const SOLVE_PLAYWRIGHT_MCP_CHECKS = {
@@ -102,7 +167,7 @@ export const ensureSolvePlaywrightMcpReady = async ({ argv = {}, log = async ()
102
167
  await log(`🎭 Checking Playwright MCP preflight for ${label}...`, { verbose: true });
103
168
 
104
169
  try {
105
- if (await checkFn()) {
170
+ if (await checkFn({ log })) {
106
171
  await log(`🎭 Playwright MCP ready for ${label}`, { verbose: true });
107
172
  return { ok: true, checkedTools: [tool], skipped: false };
108
173
  }
package/src/qwen.lib.mjs CHANGED
@@ -13,7 +13,7 @@ const fs = (await use('fs')).promises;
13
13
  const path = (await use('path')).default;
14
14
  const os = (await use('os')).default;
15
15
 
16
- import { log } from './lib.mjs';
16
+ import { log, buildToolErrorMessage } from './lib.mjs';
17
17
  import { reportError } from './sentry.lib.mjs';
18
18
  import { timeouts, retryLimits } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
@@ -633,8 +633,8 @@ export const executeQwenCommand = async params => {
633
633
  limitResetTime: null,
634
634
  ...usageResult,
635
635
  resultSummary,
636
- // Issue #1845: surface the actual error so callers can show it to users
637
- errorInfo: { message: combinedErrorText || errorMessage || `Qwen Code command failed${exitCode !== 0 ? ` with exit code ${exitCode}` : ''}`, exitCode },
636
+ // Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
637
+ errorInfo: { message: buildToolErrorMessage({ lastMessage: combinedErrorText || errorMessage, exitCode, fallback: `Qwen Code command failed${exitCode !== 0 ? ` with exit code ${exitCode}` : ''}`, toolLabel: 'Qwen Code' }), exitCode },
638
638
  };
639
639
  }
640
640
 
@@ -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
+ };
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.