@link-assistant/hive-mind 1.52.1 → 1.53.1

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.
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ // Playwright MCP session-level disable/restore utilities.
3
+ if (typeof globalThis.use === 'undefined') {
4
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
5
+ }
6
+ const { $ } = await use('command-stream');
7
+ const fs = (await use('fs')).promises;
8
+ const os = await use('os');
9
+ const path = (await use('path')).default;
10
+
11
+ export const getCommandResultCode = result => result?.code ?? result?.exitCode ?? null;
12
+
13
+ export const getCommandResultOutput = result => `${result?.stdout?.toString() || ''}${result?.stderr?.toString() || ''}`;
14
+
15
+ export const isCommandResultSuccess = result => getCommandResultCode(result) === 0;
16
+
17
+ export const checkPlaywrightMcpPackageAvailability = async () => {
18
+ try {
19
+ const result = await $`timeout 5 npx --no-install @playwright/mcp --help 2>&1`.catch(() => null);
20
+ if (isCommandResultSuccess(result)) return true;
21
+ const npmResult = await $`timeout 5 npm ls -g @playwright/mcp 2>&1`.catch(() => null);
22
+ return getCommandResultOutput(npmResult).includes('@playwright/mcp');
23
+ } catch {
24
+ return false;
25
+ }
26
+ };
27
+
28
+ export const parseCodexMcpServerNames = output =>
29
+ output
30
+ .split(/\r?\n/)
31
+ .map(line => line.trim())
32
+ .filter(line => line && !line.startsWith('Name '))
33
+ .map(line => line.split(/\s+/)[0])
34
+ .filter(name => /^[A-Za-z0-9_-]+$/.test(name));
35
+
36
+ export const getCodexPlaywrightMcpDisableConfigArgs = async log => {
37
+ try {
38
+ const result = await $`timeout 5 codex mcp list 2>&1`.catch(() => null);
39
+ if (!isCommandResultSuccess(result)) return [];
40
+ const names = parseCodexMcpServerNames(getCommandResultOutput(result)).filter(name => name.toLowerCase().includes('playwright'));
41
+ if (names.length === 0) {
42
+ if (log) await log('🎭 No Codex Playwright MCP server registration found to disable for this session', { verbose: true });
43
+ return [];
44
+ }
45
+ if (log) {
46
+ await log(`🎭 Playwright MCP disabled for this Codex session via config override: ${names.join(', ')}`, {
47
+ verbose: true,
48
+ });
49
+ }
50
+ return names.flatMap(name => ['-c', `mcp_servers.${name}.enabled=false`]);
51
+ } catch (err) {
52
+ if (log) await log(`âš ī¸ Could not build Codex Playwright MCP disable override: ${err.message}`, { verbose: true });
53
+ return [];
54
+ }
55
+ };
56
+
57
+ const isPlainObject = value => value && typeof value === 'object' && !Array.isArray(value);
58
+
59
+ const mergeDeep = (base, override) => {
60
+ const result = { ...(isPlainObject(base) ? base : {}) };
61
+ for (const [key, value] of Object.entries(isPlainObject(override) ? override : {})) {
62
+ result[key] = isPlainObject(result[key]) && isPlainObject(value) ? mergeDeep(result[key], value) : value;
63
+ }
64
+ return result;
65
+ };
66
+
67
+ const stripJsonComments = input => {
68
+ let output = '';
69
+ let inString = false;
70
+ let quote = '';
71
+ let escaped = false;
72
+ for (let index = 0; index < input.length; index++) {
73
+ const char = input[index];
74
+ const next = input[index + 1];
75
+ if (inString) {
76
+ output += char;
77
+ if (escaped) {
78
+ escaped = false;
79
+ } else if (char === '\\') {
80
+ escaped = true;
81
+ } else if (char === quote) {
82
+ inString = false;
83
+ }
84
+ continue;
85
+ }
86
+ if (char === '"' || char === "'") {
87
+ inString = true;
88
+ quote = char;
89
+ output += char;
90
+ continue;
91
+ }
92
+ if (char === '/' && next === '/') {
93
+ while (index < input.length && input[index] !== '\n') index++;
94
+ output += '\n';
95
+ continue;
96
+ }
97
+ if (char === '/' && next === '*') {
98
+ index += 2;
99
+ while (index < input.length && !(input[index] === '*' && input[index + 1] === '/')) index++;
100
+ index++;
101
+ continue;
102
+ }
103
+ output += char;
104
+ }
105
+ return output;
106
+ };
107
+
108
+ const stripTrailingCommas = input => {
109
+ let output = '';
110
+ let inString = false;
111
+ let quote = '';
112
+ let escaped = false;
113
+ for (let index = 0; index < input.length; index++) {
114
+ const char = input[index];
115
+ if (inString) {
116
+ output += char;
117
+ if (escaped) {
118
+ escaped = false;
119
+ } else if (char === '\\') {
120
+ escaped = true;
121
+ } else if (char === quote) {
122
+ inString = false;
123
+ }
124
+ continue;
125
+ }
126
+ if (char === '"' || char === "'") {
127
+ inString = true;
128
+ quote = char;
129
+ output += char;
130
+ continue;
131
+ }
132
+ if (char === ',') {
133
+ let lookahead = index + 1;
134
+ while (/\s/.test(input[lookahead] || '')) lookahead++;
135
+ if (input[lookahead] === '}' || input[lookahead] === ']') continue;
136
+ }
137
+ output += char;
138
+ }
139
+ return output;
140
+ };
141
+
142
+ const parseConfigContent = content => {
143
+ if (!content || typeof content !== 'string') return {};
144
+ try {
145
+ return JSON.parse(content);
146
+ } catch {
147
+ try {
148
+ return JSON.parse(stripTrailingCommas(stripJsonComments(content)));
149
+ } catch {
150
+ return {};
151
+ }
152
+ }
153
+ };
154
+
155
+ const readConfigFile = async filePath => {
156
+ const content = await fs.readFile(filePath, 'utf-8').catch(() => null);
157
+ return parseConfigContent(content);
158
+ };
159
+
160
+ const pathExists = async filePath =>
161
+ fs
162
+ .stat(filePath)
163
+ .then(() => true)
164
+ .catch(() => false);
165
+
166
+ const findUpConfigPaths = async (startDir, filenames) => {
167
+ const results = [];
168
+ let dir = startDir || process.cwd();
169
+ while (dir) {
170
+ for (const file of filenames) results.push(path.join(dir, file));
171
+ if (await pathExists(path.join(dir, '.git'))) break;
172
+ const parent = path.dirname(dir);
173
+ if (parent === dir) break;
174
+ dir = parent;
175
+ }
176
+ return results;
177
+ };
178
+
179
+ const configFilesInDir = dir => (dir ? ['config.json', 'opencode.json', 'opencode.jsonc'].map(file => path.join(dir, file)) : []);
180
+
181
+ const isPlaywrightMcpEntry = (name, config) => {
182
+ const haystack = `${name || ''} ${JSON.stringify(config || {})}`.toLowerCase();
183
+ return haystack.includes('playwright');
184
+ };
185
+
186
+ export const collectPlaywrightMcpServerNames = (...configs) => {
187
+ const names = new Set();
188
+ for (const config of configs.flat()) {
189
+ if (!isPlainObject(config?.mcp)) continue;
190
+ for (const [name, mcpConfig] of Object.entries(config.mcp)) {
191
+ if (isPlaywrightMcpEntry(name, mcpConfig)) names.add(name);
192
+ }
193
+ }
194
+ return [...names];
195
+ };
196
+
197
+ export const buildPlaywrightMcpDisableConfig = (serverNames = []) => {
198
+ const names = [...new Set(['playwright', ...serverNames].filter(Boolean))];
199
+ const mcp = {};
200
+ const tools = {
201
+ '*playwright*': false,
202
+ 'mcp__playwright__*': false,
203
+ };
204
+ for (const name of names) {
205
+ mcp[name] = {
206
+ type: 'local',
207
+ command: ['npx', '-y', '@playwright/mcp@latest'],
208
+ enabled: false,
209
+ };
210
+ tools[`${name}_*`] = false;
211
+ tools[`mcp__${name}__*`] = false;
212
+ }
213
+ return {
214
+ $schema: 'https://opencode.ai/config.json',
215
+ mcp,
216
+ tools,
217
+ };
218
+ };
219
+
220
+ export const mergePlaywrightMcpDisableConfigContent = (existingContent = '', serverNames = []) => {
221
+ const existingConfig = parseConfigContent(existingContent);
222
+ const detectedNames = collectPlaywrightMcpServerNames(existingConfig);
223
+ const disableConfig = buildPlaywrightMcpDisableConfig([...serverNames, ...detectedNames]);
224
+ return JSON.stringify(mergeDeep(existingConfig, disableConfig), null, 2);
225
+ };
226
+
227
+ const collectPlaywrightMcpServerNamesFromFiles = async filePaths => {
228
+ const configs = [];
229
+ for (const filePath of filePaths) configs.push(await readConfigFile(filePath));
230
+ return collectPlaywrightMcpServerNames(configs);
231
+ };
232
+
233
+ const getConfigHome = env => env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
234
+
235
+ const getOpenCodeConfigFilePaths = async ({ env = process.env, cwd = process.cwd() } = {}) => [...configFilesInDir(path.join(getConfigHome(env), 'opencode')), ...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []), ...(await findUpConfigPaths(cwd, ['opencode.jsonc', 'opencode.json'])), ...configFilesInDir(env.OPENCODE_CONFIG_DIR)];
236
+
237
+ const getAgentConfigFilePaths = async ({ env = process.env, cwd = process.cwd() } = {}) => [...configFilesInDir(path.join(getConfigHome(env), 'link-assistant-agent')), ...(env.LINK_ASSISTANT_AGENT_CONFIG ? [env.LINK_ASSISTANT_AGENT_CONFIG] : []), ...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []), ...(await findUpConfigPaths(cwd, ['opencode.jsonc', 'opencode.json'])), ...configFilesInDir(path.join(cwd, '.link-assistant-agent')), ...configFilesInDir(path.join(cwd, '.opencode')), ...configFilesInDir(env.LINK_ASSISTANT_AGENT_CONFIG_DIR), ...configFilesInDir(env.OPENCODE_CONFIG_DIR)];
238
+
239
+ export const getOpenCodePlaywrightMcpDisableEnv = async ({ env = process.env, cwd = process.cwd(), includeConfigFiles = true, log } = {}) => {
240
+ const inlineConfig = env.OPENCODE_CONFIG_CONTENT || '';
241
+ const names = collectPlaywrightMcpServerNames(parseConfigContent(inlineConfig));
242
+ if (includeConfigFiles) {
243
+ names.push(...(await collectPlaywrightMcpServerNamesFromFiles(await getOpenCodeConfigFilePaths({ env, cwd }))));
244
+ }
245
+ const uniqueNames = [...new Set(names)];
246
+ const displayNames = [...new Set(['playwright', ...uniqueNames])];
247
+ if (log) await log(`🎭 OpenCode Playwright MCP disabled through OPENCODE_CONFIG_CONTENT for: ${displayNames.join(', ')}`, { verbose: true });
248
+ return {
249
+ OPENCODE_CONFIG_CONTENT: mergePlaywrightMcpDisableConfigContent(inlineConfig, uniqueNames),
250
+ };
251
+ };
252
+
253
+ export const getAgentPlaywrightMcpDisableEnv = async ({ env = process.env, cwd = process.cwd(), includeConfigFiles = true, log } = {}) => {
254
+ const agentInlineConfig = env.LINK_ASSISTANT_AGENT_CONFIG_CONTENT || env.OPENCODE_CONFIG_CONTENT || '';
255
+ const names = collectPlaywrightMcpServerNames(parseConfigContent(agentInlineConfig), parseConfigContent(env.OPENCODE_CONFIG_CONTENT || ''));
256
+ if (includeConfigFiles) {
257
+ names.push(...(await collectPlaywrightMcpServerNamesFromFiles(await getAgentConfigFilePaths({ env, cwd }))));
258
+ }
259
+ const uniqueNames = [...new Set(names)];
260
+ const displayNames = [...new Set(['playwright', ...uniqueNames])];
261
+ const configContent = mergePlaywrightMcpDisableConfigContent(agentInlineConfig, uniqueNames);
262
+ if (log) await log(`🎭 Agent Playwright MCP disabled through LINK_ASSISTANT_AGENT_CONFIG_CONTENT for: ${displayNames.join(', ')}`, { verbose: true });
263
+ return {
264
+ LINK_ASSISTANT_AGENT_CONFIG_CONTENT: configContent,
265
+ OPENCODE_CONFIG_CONTENT: mergePlaywrightMcpDisableConfigContent(env.OPENCODE_CONFIG_CONTENT || agentInlineConfig, uniqueNames),
266
+ };
267
+ };
268
+
269
+ /** Build a temporary MCP config JSON excluding Playwright, for use with --strict-mcp-config */
270
+ export const buildMcpConfigWithoutPlaywright = async log => {
271
+ try {
272
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
273
+ const claudeJson = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
274
+ const mcpServers = claudeJson.mcpServers || {};
275
+ const filtered = {};
276
+ for (const [name, config] of Object.entries(mcpServers)) {
277
+ if (name.toLowerCase().includes('playwright')) continue;
278
+ filtered[name] = config;
279
+ }
280
+ const tempConfigPath = path.join(os.tmpdir(), `claude-mcp-no-playwright-${Date.now()}-${process.pid}.json`);
281
+ await fs.writeFile(tempConfigPath, JSON.stringify({ mcpServers: filtered }, null, 2));
282
+ if (log) await log(`🎭 Created filtered MCP config (without Playwright): ${tempConfigPath}`, { verbose: true });
283
+ return tempConfigPath;
284
+ } catch (err) {
285
+ if (log) await log(`âš ī¸ Could not build filtered MCP config: ${err.message}`, { verbose: true });
286
+ return null;
287
+ }
288
+ };
289
+
290
+ /** Cascade --no-playwright-mcp to disable related flags */
291
+ export const cascadePlaywrightMcpDisable = async (argv, log) => {
292
+ if (argv.playwrightMcp === false) {
293
+ if (log) await log('🎭 Playwright MCP physically disabled via --no-playwright-mcp', { verbose: true });
294
+ argv.promptPlaywrightMcp = false;
295
+ argv.playwrightMcpAutoCleanup = false;
296
+ if (log) await log('â„šī¸ --prompt-playwright-mcp and --playwright-mcp-auto-cleanup also disabled', { verbose: true });
297
+ }
298
+ };
@@ -35,6 +35,12 @@ const { reportError } = sentryLib;
35
35
  const githubMergeLib = await import('./github-merge.lib.mjs');
36
36
  const { checkPRMergeable, checkForBillingLimitError, getDetailedCIStatus, getWorkflowRunsForSha, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers, checkPreviousPRCommitsHadCI } = githubMergeLib;
37
37
 
38
+ // Issue #1625: Import centralized session-ending markers so the duplicate-
39
+ // search scope for checkForExistingComment() stays in lock-step with the
40
+ // markers actually embedded in tool-posted comments.
41
+ const toolComments = await import('./tool-comments.lib.mjs');
42
+ const { SESSION_ENDING_MARKERS } = toolComments;
43
+
38
44
  /**
39
45
  * Issue #1323: Check if a comment with specific content already exists on the PR
40
46
  * This prevents duplicate status comments when multiple processes or restarts occur
@@ -85,14 +91,11 @@ export const checkForExistingComment = async (owner, repo, prNumber, commentSign
85
91
  // Session-ending markers indicate the end of a working session,
86
92
  // so any "Ready to merge" before it belongs to a previous session.
87
93
  //
88
- // Session-ending markers:
89
- // - "Now working session is ended" — in all log upload comments
90
- // (Solution Draft Log, Auto-restart Log, Auto-restart-until-mergeable Log, etc.)
91
- // - "AI Work Session Completed" — posted when logs are not attached
92
- const sessionEndingMarkers = ['Now working session is ended', 'AI Work Session Completed'];
94
+ // Issue #1625: Session-ending markers are now imported from
95
+ // tool-comments.lib.mjs (single source of truth for all markers).
93
96
  let searchStartIndex = 0;
94
97
  for (let i = commentBodies.length - 1; i >= 0; i--) {
95
- if (commentBodies[i] && sessionEndingMarkers.some(marker => commentBodies[i].includes(marker))) {
98
+ if (commentBodies[i] && SESSION_ENDING_MARKERS.some(marker => commentBodies[i].includes(marker))) {
96
99
  searchStartIndex = i + 1;
97
100
  if (verbose) {
98
101
  console.log(`[VERBOSE] Found last session-ending comment at index ${i}, searching from index ${searchStartIndex}`);
@@ -54,6 +54,10 @@ import { limitReset } from './config.lib.mjs';
54
54
  const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
55
55
  const { checkForExistingComment, checkForNonBotComments, getMergeBlockers } = autoMergeHelpers;
56
56
 
57
+ // Issue #1625: Shared marker constants + posting/tracking helpers
58
+ const toolComments = await import('./tool-comments.lib.mjs');
59
+ const { READY_TO_MERGE_MARKER, AUTO_RESTART_MARKER, AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
60
+
57
61
  // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
58
62
  const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
59
63
 
@@ -263,8 +267,8 @@ export const watchUntilMergeable = async params => {
263
267
  try {
264
268
  // Issue #1345: Differentiate message when no CI is configured
265
269
  const ciLine = noCiConfigured ? '- No CI/CD checks are configured for this repository' : noCiTriggered ? (workflowRunConclusions ? `- CI workflows completed without executing (${workflowRunConclusions})` : '- CI workflows exist but were not triggered for this commit') : '- All CI checks have passed';
266
- const commentBody = `## 🎉 Auto-merged\n\nThis pull request has been automatically merged by hive-mind.\n${ciLine}\n\n---\n*Auto-merged by hive-mind with --auto-merge flag*`;
267
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
270
+ const commentBody = `## 🎉 ${AUTO_MERGED_MARKER}\n\nThis pull request has been automatically merged by hive-mind.\n${ciLine}\n\n---\n*Auto-merged by hive-mind with --auto-merge flag*`;
271
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
268
272
  } catch {
269
273
  // Don't fail if comment posting fails
270
274
  }
@@ -291,19 +295,20 @@ export const watchUntilMergeable = async params => {
291
295
  // Issue #1567: Cross-process deduplication — check if another process already
292
296
  // posted a "Ready to merge" comment. This catches the case where two concurrent
293
297
  // watchUntilMergeable processes both detect mergeability simultaneously.
294
- const hasExistingReadyComment = await checkForExistingComment(owner, repo, prNumber, '## ✅ Ready to merge', argv.verbose);
298
+ const hasExistingReadyComment = await checkForExistingComment(owner, repo, prNumber, `## ✅ ${READY_TO_MERGE_MARKER}`, argv.verbose);
295
299
  if (hasExistingReadyComment) {
296
- await log(formatAligned('', 'Skipping duplicate "Ready to merge" comment (already posted by another process)', '', 2));
300
+ await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment (already posted by another process)`, '', 2));
297
301
  readyToMergeCommentPosted = true;
298
302
  } else {
299
303
  // Issue #1345: Differentiate message when no CI is configured
300
304
  const ciLine = noCiConfigured ? '- No CI/CD checks are configured for this repository' : noCiTriggered ? (workflowRunConclusions ? `- CI workflows completed without executing (${workflowRunConclusions})` : '- CI workflows exist but were not triggered for this commit') : '- All CI checks have passed';
301
- const commentBody = `## ✅ Ready to merge\n\nThis pull request is now ready to be merged:\n${ciLine}\n- No merge conflicts\n- No pending changes\n\n---\n*Monitored by hive-mind with --auto-restart-until-mergeable flag*`;
302
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
305
+ const commentBody = `## ✅ ${READY_TO_MERGE_MARKER}\n\nThis pull request is now ready to be merged:\n${ciLine}\n- No merge conflicts\n- No pending changes\n\n---\n*Monitored by hive-mind with --auto-restart-until-mergeable flag*`;
306
+ // Issue #1625: Track this comment ID so it can't falsely count as an AI-authored comment
307
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
303
308
  readyToMergeCommentPosted = true;
304
309
  }
305
310
  } else {
306
- await log(formatAligned('', 'Skipping duplicate "Ready to merge" comment (already posted this session)', '', 2));
311
+ await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment (already posted this session)`, '', 2));
307
312
  }
308
313
  } catch {
309
314
  // Don't fail if comment posting fails
@@ -370,7 +375,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
370
375
 
371
376
  ---
372
377
  *Detected by hive-mind with --auto-restart-until-mergeable flag. This is NOT a code issue - human intervention is required.*`;
373
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
378
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
374
379
  await log(formatAligned('', 'đŸ’Ŧ Posted billing limit notification to PR', '', 2));
375
380
  } catch (commentError) {
376
381
  reportError(commentError, {
@@ -488,8 +493,9 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
488
493
  // Post a comment to PR about the restart
489
494
  // Issue #1356: Include restart count for tracking and add deduplication
490
495
  try {
491
- const commentBody = `## 🔄 Auto-restart triggered (iteration ${restartCount})\n\n**Reason:** ${restartReason}\n\nStarting new session to address the issues.\n\n---\n*Auto-restart-until-mergeable mode is active. Will continue until PR becomes mergeable.*`;
492
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
496
+ const commentBody = `## 🔄 ${AUTO_RESTART_MARKER} triggered (iteration ${restartCount})\n\n**Reason:** ${restartReason}\n\nStarting new session to address the issues.\n\n---\n*Auto-restart-until-mergeable mode is active. Will continue until PR becomes mergeable.*`;
497
+ // Issue #1625: Track so this doesn't falsely count as an AI-authored comment
498
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
493
499
  await log(formatAligned('', 'đŸ’Ŧ Posted auto-restart notification to PR', '', 2));
494
500
  } catch (commentError) {
495
501
  reportError(commentError, {
@@ -910,8 +916,8 @@ export const attemptAutoMerge = async params => {
910
916
 
911
917
  // Post success comment
912
918
  try {
913
- const commentBody = `## 🎉 Auto-merged\n\nThis pull request has been automatically merged by hive-mind after all CI checks passed and the PR became mergeable.\n\n---\n*Auto-merged by hive-mind with --auto-merge flag*`;
914
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
919
+ const commentBody = `## 🎉 ${AUTO_MERGED_MARKER}\n\nThis pull request has been automatically merged by hive-mind after all CI checks passed and the PR became mergeable.\n\n---\n*Auto-merged by hive-mind with --auto-merge flag*`;
920
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
915
921
  } catch {
916
922
  // Don't fail if comment posting fails
917
923
  }
@@ -954,14 +960,15 @@ export const startAutoRestartUntilMergeable = async params => {
954
960
 
955
961
  // Issue #1323: Post a comment to the PR notifying the maintainer (with deduplication)
956
962
  try {
957
- const readyToMergeSignature = '## ✅ Ready to merge';
963
+ const readyToMergeSignature = `## ✅ ${READY_TO_MERGE_MARKER}`;
958
964
  const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
959
965
  if (!hasExistingComment) {
960
- const commentBody = `## ✅ Ready to merge\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because this PR was created from a fork (no write access to the target repository).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag (fork mode)*`;
961
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
966
+ const commentBody = `## ✅ ${READY_TO_MERGE_MARKER}\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because this PR was created from a fork (no write access to the target repository).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag (fork mode)*`;
967
+ // Issue #1625: Track so this doesn't falsely count as AI-authored.
968
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
962
969
  await log(formatAligned('', 'đŸ’Ŧ Posted merge readiness notification to PR', '', 2));
963
970
  } else {
964
- await log(formatAligned('', 'Skipping duplicate "Ready to merge" comment', '', 2));
971
+ await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment`, '', 2));
965
972
  }
966
973
  } catch {
967
974
  // Don't fail if comment posting fails
@@ -983,14 +990,15 @@ export const startAutoRestartUntilMergeable = async params => {
983
990
 
984
991
  // Issue #1323: Post a comment to the PR notifying the maintainer (with deduplication)
985
992
  try {
986
- const readyToMergeSignature = '## ✅ Ready to merge';
993
+ const readyToMergeSignature = `## ✅ ${READY_TO_MERGE_MARKER}`;
987
994
  const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
988
995
  if (!hasExistingComment) {
989
- const commentBody = `## ✅ Ready to merge\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because the authenticated user lacks write access to \`${owner}/${repo}\` (current permission: \`${permission || 'unknown'}\`).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag*`;
990
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
996
+ const commentBody = `## ✅ ${READY_TO_MERGE_MARKER}\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because the authenticated user lacks write access to \`${owner}/${repo}\` (current permission: \`${permission || 'unknown'}\`).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag*`;
997
+ // Issue #1625: Track so this doesn't falsely count as AI-authored.
998
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
991
999
  await log(formatAligned('', 'đŸ’Ŧ Posted merge readiness notification to PR', '', 2));
992
1000
  } else {
993
- await log(formatAligned('', 'Skipping duplicate "Ready to merge" comment', '', 2));
1001
+ await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment`, '', 2));
994
1002
  }
995
1003
  } catch {
996
1004
  // Don't fail if comment posting fails
@@ -366,7 +366,7 @@ export const SOLVE_OPTION_DEFINITIONS = {
366
366
  },
367
367
  'prompt-playwright-mcp': {
368
368
  type: 'boolean',
369
- description: 'Enable Playwright MCP browser automation hints in system prompt (enabled by default, only takes effect if Playwright MCP is installed). Use --no-prompt-playwright-mcp to disable. Supported for --tool claude and --tool codex.',
369
+ description: 'Enable Playwright MCP browser automation hints in system prompt (enabled by default, only takes effect if Playwright MCP is installed). Use --no-prompt-playwright-mcp to disable. Supported for --tool claude, --tool codex, --tool opencode, and --tool agent.',
370
370
  default: true,
371
371
  },
372
372
  'prompt-check-sibling-pull-requests': {
@@ -384,6 +384,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
384
384
  description: 'Path to examples folder used in system prompt. Set to empty string to disable examples folder prompt. Default: ./examples',
385
385
  default: './examples',
386
386
  },
387
+ 'playwright-mcp': {
388
+ type: 'boolean',
389
+ description: 'Enable Playwright MCP server connection for this session (enabled by default). Use --no-playwright-mcp to physically disable the Playwright MCP server without affecting the global MCP registration. When disabled, also disables --prompt-playwright-mcp and --playwright-mcp-auto-cleanup. Supported for --tool claude, --tool codex, --tool opencode, and --tool agent.',
390
+ default: true,
391
+ },
387
392
  'playwright-mcp-auto-cleanup': {
388
393
  type: 'boolean',
389
394
  description: 'Automatically remove .playwright-mcp/ folder before checking for uncommitted changes. This prevents browser automation artifacts from triggering auto-restart. Use --no-playwright-mcp-auto-cleanup to keep the folder for debugging.',
package/src/solve.mjs CHANGED
@@ -54,6 +54,10 @@ const { handleAutoPrCreation } = await import('./solve.auto-pr.lib.mjs');
54
54
  const { setupRepositoryAndClone, verifyDefaultBranchAndStatus } = await import('./solve.repo-setup.lib.mjs');
55
55
  const { createOrCheckoutBranch } = await import('./solve.branch.lib.mjs');
56
56
  const { startWorkSession, endWorkSession, SESSION_TYPES } = await import('./solve.session.lib.mjs');
57
+ // Issue #1625: centralized markers + tracked comment posting for solve.mjs's
58
+ // own usage-limit notifications (so they're excluded from the
59
+ // "did the AI post anything?" check in --auto-attach-solution-summary).
60
+ const { postTrackedComment, USAGE_LIMIT_REACHED_MARKER } = await import('./tool-comments.lib.mjs');
57
61
  const { prepareFeedbackAndTimestamps, checkUncommittedChanges, checkForkActions } = await import('./solve.preparation.lib.mjs');
58
62
  const { validateAndExitOnInvalidModel } = await import('./models/index.mjs');
59
63
  const { autoAcceptInviteForRepo } = await import('./solve.accept-invite.lib.mjs');
@@ -707,12 +711,31 @@ try {
707
711
  $,
708
712
  });
709
713
 
714
+ const { cascadePlaywrightMcpDisable } = await import('./playwright-mcp.lib.mjs');
715
+ await cascadePlaywrightMcpDisable(argv, log);
716
+
717
+ async function resolvePlaywrightMcp(checkFn) {
718
+ if (argv.playwrightMcp === false) return;
719
+ if (argv.promptPlaywrightMcp) {
720
+ const available = await checkFn();
721
+ if (available) {
722
+ await log('🎭 Playwright MCP detected - enabling browser automation hints', { verbose: true });
723
+ } else {
724
+ await log('â„šī¸ Playwright MCP not detected - browser automation hints will be disabled', { verbose: true });
725
+ argv.promptPlaywrightMcp = false;
726
+ }
727
+ } else {
728
+ await log('â„šī¸ Playwright MCP explicitly disabled via --no-prompt-playwright-mcp', { verbose: true });
729
+ }
730
+ }
731
+
710
732
  // Execute tool command with all prompts and settings
711
733
  let toolResult;
712
734
  if (argv.tool === 'opencode') {
713
735
  const opencodeLib = await import('./opencode.lib.mjs');
714
- const { executeOpenCode } = opencodeLib;
736
+ const { executeOpenCode, checkPlaywrightMcpAvailability: checkOpenCodePlaywrightMcp } = opencodeLib;
715
737
  const opencodePath = process.env.OPENCODE_PATH || 'opencode';
738
+ await resolvePlaywrightMcp(checkOpenCodePlaywrightMcp);
716
739
 
717
740
  toolResult = await executeOpenCode({
718
741
  issueUrl,
@@ -742,18 +765,7 @@ try {
742
765
  const codexLib = await import('./codex.lib.mjs');
743
766
  const { executeCodex, checkPlaywrightMcpAvailability } = codexLib;
744
767
  const codexPath = process.env.CODEX_PATH || 'codex';
745
-
746
- if (argv.promptPlaywrightMcp) {
747
- const playwrightMcpAvailable = await checkPlaywrightMcpAvailability();
748
- if (playwrightMcpAvailable) {
749
- await log('🎭 Playwright MCP detected - enabling browser automation hints', { verbose: true });
750
- } else {
751
- await log('â„šī¸ Playwright MCP not detected - browser automation hints will be disabled', { verbose: true });
752
- argv.promptPlaywrightMcp = false;
753
- }
754
- } else {
755
- await log('â„šī¸ Playwright MCP explicitly disabled via --no-prompt-playwright-mcp', { verbose: true });
756
- }
768
+ await resolvePlaywrightMcp(checkPlaywrightMcpAvailability);
757
769
 
758
770
  toolResult = await executeCodex({
759
771
  issueUrl,
@@ -781,8 +793,9 @@ try {
781
793
  });
782
794
  } else if (argv.tool === 'agent') {
783
795
  const agentLib = await import('./agent.lib.mjs');
784
- const { executeAgent } = agentLib;
796
+ const { executeAgent, checkPlaywrightMcpAvailability: checkAgentPlaywrightMcp } = agentLib;
785
797
  const agentPath = process.env.AGENT_PATH || 'agent';
798
+ await resolvePlaywrightMcp(checkAgentPlaywrightMcp);
786
799
 
787
800
  toolResult = await executeAgent({
788
801
  issueUrl,
@@ -810,20 +823,8 @@ try {
810
823
  });
811
824
  } else {
812
825
  // Default to Claude
813
- // Check for Playwright MCP availability if using Claude tool
814
826
  if (argv.tool === 'claude' || !argv.tool) {
815
- // If flag is true (default), check if Playwright MCP is actually available
816
- if (argv.promptPlaywrightMcp) {
817
- const playwrightMcpAvailable = await checkPlaywrightMcpAvailability();
818
- if (playwrightMcpAvailable) {
819
- await log('🎭 Playwright MCP detected - enabling browser automation hints', { verbose: true });
820
- } else {
821
- await log('â„šī¸ Playwright MCP not detected - browser automation hints will be disabled', { verbose: true });
822
- argv.promptPlaywrightMcp = false;
823
- }
824
- } else {
825
- await log('â„šī¸ Playwright MCP explicitly disabled via --no-prompt-playwright-mcp', { verbose: true });
826
- }
827
+ await resolvePlaywrightMcp(checkPlaywrightMcpAvailability);
827
828
  }
828
829
  const claudeResult = await executeClaude({
829
830
  issueUrl,
@@ -967,11 +968,11 @@ try {
967
968
  // Format the reset time with relative time and UTC conversion if available
968
969
  const timezone = global.limitTimezone || null;
969
970
  const formattedResetTime = resetTime ? formatResetTimeWithRelative(resetTime, timezone) : null;
970
- const failureComment = formattedResetTime ? `❌ **Usage Limit Reached**\n\nThe AI tool has reached its usage limit. The limit will reset at: **${formattedResetTime}**\n\n${resumeSection}` : `❌ **Usage Limit Reached**\n\nThe AI tool has reached its usage limit. Please wait for the limit to reset.\n\n${resumeSection}`;
971
+ const failureComment = formattedResetTime ? `❌ **${USAGE_LIMIT_REACHED_MARKER}**\n\nThe AI tool has reached its usage limit. The limit will reset at: **${formattedResetTime}**\n\n${resumeSection}` : `❌ **${USAGE_LIMIT_REACHED_MARKER}**\n\nThe AI tool has reached its usage limit. Please wait for the limit to reset.\n\n${resumeSection}`;
971
972
 
972
- const commentResult = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${failureComment}`;
973
- if (commentResult.code === 0) {
974
- await log(' Posted failure comment to PR');
973
+ const posted = await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: failureComment });
974
+ if (posted.ok) {
975
+ await log(` Posted failure comment to PR${posted.commentId ? ` (id=${posted.commentId})` : ''}`);
975
976
  }
976
977
  } catch (error) {
977
978
  await log(` Warning: Could not post failure comment: ${cleanErrorMessage(error)}`, { verbose: true });
@@ -1052,11 +1053,11 @@ try {
1052
1053
  // Format reset time with relative time and UTC for better user understanding
1053
1054
  // See: https://github.com/link-assistant/hive-mind/issues/1236
1054
1055
  const waitingResetTimeFormatted = formatResetTimeWithRelative(global.limitResetTime, global.limitTimezone || null) || global.limitResetTime;
1055
- const waitingComment = `âŗ **Usage Limit Reached - Waiting to ${limitContinueMode === 'restart' ? 'Restart' : 'Continue'}**\n\nThe AI tool has reached its usage limit. ${continueModeName} is enabled.\n\n**Reset time:** ${waitingResetTimeFormatted}\n**Wait time:** ${formatWaitTime(waitMs)} (days:hours:minutes:seconds)\n\n${continueDescription}\n\nSession ID: \`${sessionId}\``;
1056
+ const waitingComment = `âŗ **${USAGE_LIMIT_REACHED_MARKER} - Waiting to ${limitContinueMode === 'restart' ? 'Restart' : 'Continue'}**\n\nThe AI tool has reached its usage limit. ${continueModeName} is enabled.\n\n**Reset time:** ${waitingResetTimeFormatted}\n**Wait time:** ${formatWaitTime(waitMs)} (days:hours:minutes:seconds)\n\n${continueDescription}\n\nSession ID: \`${sessionId}\``;
1056
1057
 
1057
- const commentResult = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${waitingComment}`;
1058
- if (commentResult.code === 0) {
1059
- await log(' Posted waiting comment to PR');
1058
+ const posted = await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: waitingComment });
1059
+ if (posted.ok) {
1060
+ await log(` Posted waiting comment to PR${posted.commentId ? ` (id=${posted.commentId})` : ''}`);
1060
1061
  }
1061
1062
  } catch (error) {
1062
1063
  await log(` Warning: Could not post waiting comment: ${cleanErrorMessage(error)}`, { verbose: true });