@link-assistant/hive-mind 2.0.4 → 2.0.6

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,47 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 0c63706: Stop surfacing meaningless stream fragments as tool errors (#1941). When a tool
8
+ run is interrupted mid-stream (CTRL+C / SIGINT, exit code 130), the last captured
9
+ stdout line could be a stray structural character such as a lone `}`, which leaked
10
+ into the GitHub failure comment as "CLAUDE execution failed with }". A new shared
11
+ `isMeaningfulErrorText` helper (any error with at least one Unicode letter or digit
12
+ is real; pure punctuation is not) now guards the `extractToolErrorCore` chokepoint,
13
+ and a new `buildToolErrorMessage` helper labels interruptions explicitly
14
+ ("Claude command interrupted (CTRL+C)") across the Claude and OpenCode runners.
15
+ - d4efc82: fix(playwright-mcp): do not abort the solve when the Playwright MCP preflight probe is inconclusive (#1943)
16
+
17
+ A `solve` run aborted before creating a pull request with
18
+ `❌ Playwright MCP preflight failed for Claude Code`. The local preflight ran
19
+ `timeout 5 claude mcp list`, but that command performs a live health check that
20
+ launches a browser and can take longer than five seconds; when the `timeout`
21
+ killed the probe, `ensureConnectedPlaywrightMcpServer` treated the non-zero exit
22
+ as a failure and stopped the whole run.
23
+
24
+ An inconclusive `mcp list` probe (timeout / crash / missing CLI) now falls back
25
+ to the local `@playwright/mcp` package check instead of aborting: if the package
26
+ is installed, the server connects on demand via Tool Search (issue #1901), so the
27
+ working session proceeds. The probe timeout now defaults to 30s and is overridable
28
+ via `PLAYWRIGHT_MCP_PREFLIGHT_TIMEOUT_SECONDS`, and the preflight emits verbose
29
+ diagnostics (probe exit code, matched rows, decision branch) so failures are
30
+ diagnosable from the log. The preflight still fails only when `@playwright/mcp`
31
+ is genuinely unavailable.
32
+
33
+ ## 2.0.5
34
+
35
+ ### Patch Changes
36
+
37
+ - d815c7d: Treat a Claude Code `pending` Playwright MCP `system.init` status as a normal
38
+ still-connecting state instead of a failure (#1901). Claude Code enables Tool
39
+ Search by default, so the deferred `mcp__playwright__*` browser tools load on
40
+ demand and Claude waits for the connecting server before using them. Hive Mind
41
+ no longer aborts the working session on a `pending` status; only a terminal
42
+ `failed`/`error` status surfaces a non-blocking diagnostic in the session-start
43
+ comment. See `docs/case-studies/issue-1901`.
44
+
3
45
  ## 2.0.4
4
46
 
5
47
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
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
 
@@ -1,21 +1,36 @@
1
1
  const PLAYWRIGHT_TOOL_PREFIX = 'mcp__playwright__';
2
2
 
3
- export const isUnavailableMcpStatus = status => {
3
+ // A `pending` (or `connecting`) MCP server is still being connected/reconnected
4
+ // in the background. It is NOT a failure: Claude Code enables Tool Search by
5
+ // default, so MCP tools are deferred and load on demand, and Claude waits for a
6
+ // still-connecting server before it uses one of that server's tools. See
7
+ // https://code.claude.com/docs/en/mcp and issue #1901.
8
+ export const isConnectingMcpStatus = status => /\b(pending|connecting)\b/i.test(String(status || ''));
9
+
10
+ // Terminal/unhealthy states where the MCP client has given up (or the server is
11
+ // turned off). Claude Code reconnects an HTTP/SSE server with exponential
12
+ // backoff and only marks it `failed` after the attempts are exhausted; at that
13
+ // point the server's tools never load.
14
+ export const isFailedMcpStatus = status => {
4
15
  const normalized = String(status || '').toLowerCase();
5
- return /\b(pending|disabled|failed|error|disconnected|not[-_\s]+connected|unavailable|timed[-_\s]+out)\b|(?:^|[^a-z0-9_-])timeout(?:$|[^a-z0-9_-])/.test(normalized);
16
+ return /\b(disabled|failed|error|disconnected|not[-_\s]+connected|unavailable|timed[-_\s]+out)\b|(?:^|[^a-z0-9_-])timeout(?:$|[^a-z0-9_-])/.test(normalized);
6
17
  };
7
18
 
19
+ // Backwards-compatible umbrella: any non-connected status (still connecting OR
20
+ // failed). Prefer the narrower helpers above when the connecting/failed
21
+ // distinction matters (e.g. whether to warn a human reviewer).
22
+ export const isUnavailableMcpStatus = status => isConnectingMcpStatus(status) || isFailedMcpStatus(status);
23
+
8
24
  export const hasPlaywrightMcpTools = tools => (Array.isArray(tools) ? tools : []).some(tool => String(tool || '').startsWith(PLAYWRIGHT_TOOL_PREFIX));
9
25
 
10
26
  export const formatInteractiveMcpServerStatus = server => {
11
27
  const name = server?.name || 'unknown';
12
28
  const status = String(server?.status || 'unknown').trim() || 'unknown';
13
- const normalizedStatus = status.toLowerCase();
14
29
  let displayStatus = status;
15
30
 
16
- if (normalizedStatus === 'pending') {
17
- displayStatus = 'pending - not connected; MCP tools unavailable';
18
- } else if (isUnavailableMcpStatus(status)) {
31
+ if (isConnectingMcpStatus(status)) {
32
+ displayStatus = `${status} - connecting; tools load on demand via Tool Search`;
33
+ } else if (isFailedMcpStatus(status)) {
19
34
  displayStatus = `${status} - MCP tools unavailable`;
20
35
  }
21
36
 
@@ -29,10 +44,16 @@ export const getInteractiveMcpDiagnostics = (mcpServers = [], tools = []) => {
29
44
  for (const server of servers) {
30
45
  const name = String(server?.name || '').toLowerCase();
31
46
  if (!name.includes('playwright')) continue;
32
- if (!isUnavailableMcpStatus(server?.status)) continue;
47
+ // With Tool Search the deferred `mcp__playwright__*` tools are intentionally
48
+ // absent from system.init `tools`, so their absence is not a problem by
49
+ // itself. If they are already present the server is fully connected.
33
50
  if (hasPlaywrightMcpTools(tools)) continue;
51
+ // `pending`/`connecting` is the normal startup state — Claude waits for the
52
+ // server before using a browser tool — so only warn when the MCP client has
53
+ // actually failed to connect.
54
+ if (!isFailedMcpStatus(server?.status)) continue;
34
55
 
35
- diagnostics.push(`⚠️ Playwright MCP server is ${server?.status || 'unknown'}, but no \`${PLAYWRIGHT_TOOL_PREFIX}*\` browser tools were exposed. Browser automation hints are disabled until the MCP client reports the server as connected.`);
56
+ diagnostics.push(`⚠️ Playwright MCP server is ${server?.status || 'unknown'} (failed to connect), so no \`${PLAYWRIGHT_TOOL_PREFIX}*\` browser tools are available. Browser automation stays disabled until the MCP server connects.`);
36
57
  }
37
58
 
38
59
  return diagnostics;
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