@link-assistant/hive-mind 1.53.0 → 1.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +11 -13
- package/src/github.lib.mjs +59 -43
- package/src/interactive-mode.lib.mjs +12 -2
- package/src/solve.auto-merge-helpers.lib.mjs +9 -6
- package/src/solve.auto-merge.lib.mjs +28 -20
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.mjs +12 -8
- package/src/solve.progress-monitoring.lib.mjs +16 -11
- package/src/solve.repo-setup.lib.mjs +8 -4
- package/src/solve.repository.lib.mjs +6 -4
- package/src/solve.results.lib.mjs +64 -3
- package/src/solve.session.lib.mjs +25 -13
- package/src/solve.watch.lib.mjs +7 -2
- package/src/tool-comments.lib.mjs +271 -0
- package/src/useless-tools.lib.mjs +187 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centralized definitions for GitHub comments posted by solve.mjs itself
|
|
5
|
+
* (session bookkeeping, log uploads, auto-restart notices, etc.) — as
|
|
6
|
+
* opposed to comments posted by the AI agent via its own tool calls.
|
|
7
|
+
*
|
|
8
|
+
* Issue #1625: --auto-attach-solution-summary was broken because the tool's
|
|
9
|
+
* own "AI Work Session Started" / "Solution Draft Log" / "Ready to merge"
|
|
10
|
+
* comments counted as AI-authored comments, so the summary was always
|
|
11
|
+
* suppressed even when the AI session produced zero comments of its own.
|
|
12
|
+
*
|
|
13
|
+
* This module is the single source of truth for the marker strings embedded
|
|
14
|
+
* in those comments. Posting sites use these constants to *build* comment
|
|
15
|
+
* bodies; the summary filter uses the same constants to *detect* them. If a
|
|
16
|
+
* marker needs to change, changing it here updates both sides — no more
|
|
17
|
+
* duplicate literals drifting apart.
|
|
18
|
+
*
|
|
19
|
+
* It also provides in-memory tracking: any comment posted by solve.mjs can
|
|
20
|
+
* be registered by its numeric GitHub comment ID, and checkForAiCreatedComments
|
|
21
|
+
* uses that set as the *primary* filter (marker matching is the fallback for
|
|
22
|
+
* comments whose IDs were not captured, e.g. when `gh pr comment` didn't
|
|
23
|
+
* return JSON).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ----------------------------------------------------------------------------
|
|
27
|
+
// Marker constants — single source of truth for comment header/keyphrase text.
|
|
28
|
+
// Each constant is the exact substring that both (a) appears in the posted
|
|
29
|
+
// comment body and (b) is searched for when filtering out tool-generated
|
|
30
|
+
// comments. Do NOT duplicate these literals elsewhere.
|
|
31
|
+
// ----------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
// solve.session.lib.mjs — startWorkSession() / endWorkSession()
|
|
34
|
+
export const AI_WORK_SESSION_STARTED_MARKER = 'AI Work Session Started';
|
|
35
|
+
export const AI_WORK_SESSION_COMPLETED_MARKER = 'AI Work Session Completed';
|
|
36
|
+
export const AI_WORK_SESSION_RESUMED_MARKER = 'AI Work Session Resumed';
|
|
37
|
+
|
|
38
|
+
// solve.session.lib.mjs — auto-resume / auto-restart on limit reset
|
|
39
|
+
export const AUTO_RESUME_ON_LIMIT_RESET_MARKER = 'Auto Resume (on limit reset)';
|
|
40
|
+
export const AUTO_RESTART_ON_LIMIT_RESET_MARKER = 'Auto Restart (on limit reset)';
|
|
41
|
+
|
|
42
|
+
// github.lib.mjs — attachLogToGitHub() success / resumed / truncated log comments
|
|
43
|
+
export const SOLUTION_DRAFT_LOG_MARKER = 'Solution Draft Log';
|
|
44
|
+
|
|
45
|
+
// solve.watch.lib.mjs / solve.auto-merge.lib.mjs — auto-restart notifications
|
|
46
|
+
export const AUTO_RESTART_MARKER = 'Auto-restart';
|
|
47
|
+
export const AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER = 'Auto-restart-until-mergeable Log';
|
|
48
|
+
|
|
49
|
+
// solve.auto-merge.lib.mjs — "ready to merge" status comments
|
|
50
|
+
export const READY_TO_MERGE_MARKER = 'Ready to merge';
|
|
51
|
+
|
|
52
|
+
// solve.auto-merge.lib.mjs — "auto-merged successfully" status comments
|
|
53
|
+
export const AUTO_MERGED_MARKER = 'Auto-merged';
|
|
54
|
+
|
|
55
|
+
// solve.auto-merge.lib.mjs — billing-limit notification (spending cap / free tier)
|
|
56
|
+
export const BILLING_LIMIT_MARKER = 'GitHub Actions Billing Limit';
|
|
57
|
+
|
|
58
|
+
// github.lib.mjs — fork contributor "Allow edits by maintainers" request
|
|
59
|
+
export const MAINTAINER_ACCESS_REQUEST_MARKER = 'Allow edits by maintainers';
|
|
60
|
+
|
|
61
|
+
// solve.progress-monitoring.lib.mjs — live-progress comment section markers.
|
|
62
|
+
// These are HTML comments so they don't render in the GitHub UI; they exist
|
|
63
|
+
// specifically to let the tool find its own comment later.
|
|
64
|
+
export const LIVE_PROGRESS_SECTION_START_MARKER = '<!-- LIVE-PROGRESS-START -->';
|
|
65
|
+
export const LIVE_PROGRESS_SECTION_END_MARKER = '<!-- LIVE-PROGRESS-END -->';
|
|
66
|
+
|
|
67
|
+
// claude.lib.mjs — "session force-killed due to stream timeout" notifications
|
|
68
|
+
export const SESSION_FORCE_KILLED_MARKER = 'Session Force-Killed';
|
|
69
|
+
|
|
70
|
+
// solve.repo-setup.lib.mjs / solve.repository.lib.mjs — issue comments posted
|
|
71
|
+
// when the target repository is empty / uninitialized so solving can't start.
|
|
72
|
+
export const REPOSITORY_INITIALIZATION_REQUIRED_MARKER = 'Repository Initialization Required';
|
|
73
|
+
|
|
74
|
+
// interactive-mode.lib.mjs — interactive mode session comments
|
|
75
|
+
export const INTERACTIVE_SESSION_STARTED_MARKER = 'Interactive session started';
|
|
76
|
+
export const INTERACTIVE_SESSION_ENDED_MARKER = 'Interactive session ended';
|
|
77
|
+
|
|
78
|
+
// github.lib.mjs — closing footer present in every log upload comment variant
|
|
79
|
+
export const NOW_WORKING_SESSION_IS_ENDED_MARKER = 'Now working session is ended';
|
|
80
|
+
|
|
81
|
+
// Failure-path markers (github.lib.mjs error paths)
|
|
82
|
+
export const SOLUTION_DRAFT_FAILED_MARKER = 'Solution Draft Failed';
|
|
83
|
+
export const SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER = 'Solution Draft Finished with Errors';
|
|
84
|
+
export const USAGE_LIMIT_REACHED_MARKER = 'Usage Limit Reached';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every marker that identifies a tool-posted comment. Derived from the
|
|
88
|
+
* named constants above so that adding a new marker only requires adding
|
|
89
|
+
* the constant and appending it here.
|
|
90
|
+
*/
|
|
91
|
+
export const TOOL_GENERATED_COMMENT_MARKERS = [AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, SOLUTION_DRAFT_LOG_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, READY_TO_MERGE_MARKER, AUTO_MERGED_MARKER, BILLING_LIMIT_MARKER, MAINTAINER_ACCESS_REQUEST_MARKER, LIVE_PROGRESS_SECTION_START_MARKER, SESSION_FORCE_KILLED_MARKER, REPOSITORY_INITIALIZATION_REQUIRED_MARKER, INTERACTIVE_SESSION_STARTED_MARKER, INTERACTIVE_SESSION_ENDED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Markers that indicate the end of a working session. Used by
|
|
95
|
+
* solve.auto-merge-helpers.checkForExistingComment to scope the
|
|
96
|
+
* duplicate-search window to the current session only (Issue #1584).
|
|
97
|
+
*/
|
|
98
|
+
export const SESSION_ENDING_MARKERS = [NOW_WORKING_SESSION_IS_ENDED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Determine whether a GitHub comment body matches any known tool-generated
|
|
102
|
+
* marker. Used as a fallback when a comment's ID was not captured by
|
|
103
|
+
* in-memory tracking (see below).
|
|
104
|
+
*
|
|
105
|
+
* @param {string} body - The comment body
|
|
106
|
+
* @returns {boolean} - True if the body contains a tool-generated marker
|
|
107
|
+
*/
|
|
108
|
+
export const isToolGeneratedComment = body => {
|
|
109
|
+
if (!body || typeof body !== 'string') return false;
|
|
110
|
+
return TOOL_GENERATED_COMMENT_MARKERS.some(marker => body.includes(marker));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ----------------------------------------------------------------------------
|
|
114
|
+
// In-memory tracking of comments posted by solve.mjs during this session.
|
|
115
|
+
//
|
|
116
|
+
// Every tool-initiated comment-post helper should register its comment ID
|
|
117
|
+
// via trackToolCommentId(). checkForAiCreatedComments() then uses the set
|
|
118
|
+
// as the primary filter, falling back to marker-based detection for any
|
|
119
|
+
// comment whose ID was not captured.
|
|
120
|
+
//
|
|
121
|
+
// IDs are GitHub numeric comment IDs (from issue/PR/review comment APIs),
|
|
122
|
+
// coerced to strings for consistent Set membership. Review (inline) comments
|
|
123
|
+
// and conversation comments share the same ID namespace at the API layer,
|
|
124
|
+
// but we never mix them since solve.mjs only posts to conversation + issue
|
|
125
|
+
// endpoints — review comments are AI-only.
|
|
126
|
+
// ----------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const trackedToolCommentIds = new Set();
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Register a comment ID as tool-generated. Safe to call with null/undefined
|
|
132
|
+
* (e.g., when comment posting failed or the ID couldn't be extracted).
|
|
133
|
+
* @param {string|number|null|undefined} commentId
|
|
134
|
+
*/
|
|
135
|
+
export const trackToolCommentId = commentId => {
|
|
136
|
+
if (commentId === null || commentId === undefined) return;
|
|
137
|
+
trackedToolCommentIds.add(String(commentId));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns whether a given comment ID was posted by solve.mjs itself during
|
|
142
|
+
* this session.
|
|
143
|
+
* @param {string|number|null|undefined} commentId
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
export const isToolTrackedCommentId = commentId => {
|
|
147
|
+
if (commentId === null || commentId === undefined) return false;
|
|
148
|
+
return trackedToolCommentIds.has(String(commentId));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns the set of tracked comment IDs (read-only snapshot).
|
|
153
|
+
* Primarily for tests and diagnostics.
|
|
154
|
+
* @returns {Set<string>}
|
|
155
|
+
*/
|
|
156
|
+
export const getTrackedToolCommentIds = () => new Set(trackedToolCommentIds);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reset tracking state. Primarily for tests; solve.mjs does not need to
|
|
160
|
+
* call this between real sessions because each invocation is a fresh
|
|
161
|
+
* process.
|
|
162
|
+
*/
|
|
163
|
+
export const resetTrackedToolCommentIds = () => {
|
|
164
|
+
trackedToolCommentIds.clear();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Post a GitHub comment on a PR or issue via `gh api` and return the
|
|
169
|
+
* numeric comment ID (as string). The ID is also automatically tracked in
|
|
170
|
+
* the in-memory set above.
|
|
171
|
+
*
|
|
172
|
+
* This is the preferred path for all tool-posted comments because `gh pr
|
|
173
|
+
* comment` / `gh issue comment` only print the comment URL to stdout, and
|
|
174
|
+
* extracting the numeric ID from a URL is brittle. `gh api POST` returns
|
|
175
|
+
* full JSON, from which the ID is trivial to extract.
|
|
176
|
+
*
|
|
177
|
+
* Falls back to best-effort URL parsing if JSON parsing fails, so a single
|
|
178
|
+
* API change cannot break the code path.
|
|
179
|
+
*
|
|
180
|
+
* @param {Object} options
|
|
181
|
+
* @param {Function} options.$ - command-stream tagged template (required — we
|
|
182
|
+
* accept it as a parameter so this module has no top-level dependency on
|
|
183
|
+
* `command-stream`, keeping it cheap to import from tests)
|
|
184
|
+
* @param {string} options.owner
|
|
185
|
+
* @param {string} options.repo
|
|
186
|
+
* @param {number|string} options.targetNumber - PR or issue number
|
|
187
|
+
* @param {string} options.body
|
|
188
|
+
* @returns {Promise<{ok: boolean, commentId: string|null, stderr?: string}>}
|
|
189
|
+
*/
|
|
190
|
+
export const postTrackedComment = async ({ $, owner, repo, targetNumber, body }) => {
|
|
191
|
+
if (!$) {
|
|
192
|
+
throw new Error('postTrackedComment requires a command-stream $ helper');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Use `gh api` with stdin to avoid shell-quoting problems on multi-line
|
|
196
|
+
// bodies and to get JSON back so we can extract the comment ID.
|
|
197
|
+
// We use the /issues/<n>/comments endpoint because it works identically
|
|
198
|
+
// for both PRs and issues (a PR is an issue at this endpoint).
|
|
199
|
+
const apiPath = `repos/${owner}/${repo}/issues/${targetNumber}/comments`;
|
|
200
|
+
const payload = JSON.stringify({ body });
|
|
201
|
+
|
|
202
|
+
// `gh api --input -` reads from stdin. command-stream supports .stdin(...)
|
|
203
|
+
// on the returned process handle (same API used in interactive-mode.lib.mjs
|
|
204
|
+
// via execFileAsync). We build the invocation through $ so callers can
|
|
205
|
+
// inject a mock.
|
|
206
|
+
let result;
|
|
207
|
+
try {
|
|
208
|
+
result = await $({ input: payload })`gh api ${apiPath} -X POST --input -`;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return { ok: false, commentId: null, stderr: err && err.message ? err.message : String(err) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (result.code !== 0) {
|
|
214
|
+
const stderr = result.stderr ? result.stderr.toString() : '';
|
|
215
|
+
return { ok: false, commentId: null, stderr };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stdout = result.stdout ? result.stdout.toString() : '';
|
|
219
|
+
let commentId = null;
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(stdout);
|
|
222
|
+
if (parsed && parsed.id !== undefined && parsed.id !== null) {
|
|
223
|
+
commentId = String(parsed.id);
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// Fallback: match numeric id in the JSON text, or the issuecomment-<n>
|
|
227
|
+
// fragment in the html_url, whichever shows up first.
|
|
228
|
+
const match = stdout.match(/"id"\s*:\s*(\d+)|issuecomment-(\d+)/);
|
|
229
|
+
if (match) commentId = match[1] || match[2] || null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
trackToolCommentId(commentId);
|
|
233
|
+
|
|
234
|
+
return { ok: true, commentId };
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Post a GitHub comment whose body is already written to a file on disk.
|
|
239
|
+
* Used by attachLogToGitHub() where the comment body can be tens of KB
|
|
240
|
+
* (entire execution log embedded in a <details>) — too large for inline
|
|
241
|
+
* shell arguments and awkward to pipe as stdin JSON.
|
|
242
|
+
*
|
|
243
|
+
* Reads the file and posts via postTrackedComment() so the returned comment
|
|
244
|
+
* ID is tracked exactly like any other tool-posted comment. Kept separate
|
|
245
|
+
* from postTrackedComment so callers that already have a body string don't
|
|
246
|
+
* pay for a tempfile round-trip.
|
|
247
|
+
*
|
|
248
|
+
* @param {Object} options
|
|
249
|
+
* @param {Function} options.$ - command-stream tagged template
|
|
250
|
+
* @param {string} options.owner
|
|
251
|
+
* @param {string} options.repo
|
|
252
|
+
* @param {number|string} options.targetNumber
|
|
253
|
+
* @param {string} options.bodyFile - absolute path to the comment body file
|
|
254
|
+
* @returns {Promise<{ok: boolean, commentId: string|null, stderr?: string}>}
|
|
255
|
+
*/
|
|
256
|
+
export const postTrackedCommentFromFile = async ({ $, owner, repo, targetNumber, bodyFile }) => {
|
|
257
|
+
if (!$) {
|
|
258
|
+
throw new Error('postTrackedCommentFromFile requires a command-stream $ helper');
|
|
259
|
+
}
|
|
260
|
+
if (typeof globalThis.use === 'undefined') {
|
|
261
|
+
globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
|
|
262
|
+
}
|
|
263
|
+
const fs = (await globalThis.use('fs')).promises;
|
|
264
|
+
let body;
|
|
265
|
+
try {
|
|
266
|
+
body = await fs.readFile(bodyFile, 'utf8');
|
|
267
|
+
} catch (err) {
|
|
268
|
+
return { ok: false, commentId: null, stderr: err && err.message ? err.message : String(err) };
|
|
269
|
+
}
|
|
270
|
+
return postTrackedComment({ $, owner, repo, targetNumber, body });
|
|
271
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Useless Claude Code tools and MCP servers for autonomous headless workflows.
|
|
3
|
+
//
|
|
4
|
+
// Hive-mind runs `claude` inside Docker with `--print --dangerously-skip-permissions`
|
|
5
|
+
// and no human operator. Several built-in Claude Code tools and three `claude.ai`
|
|
6
|
+
// OAuth MCP connectors are active by default but either:
|
|
7
|
+
// - wait for a human reaction that will never come (`AskUserQuestion`,
|
|
8
|
+
// `EnterPlanMode`);
|
|
9
|
+
// - have side effects that outlive the session (`CronCreate`,
|
|
10
|
+
// `EnterWorktree`);
|
|
11
|
+
// - can never complete authentication without an interactive browser
|
|
12
|
+
// (`claude.ai Gmail`, `claude.ai Google Drive`, `claude.ai Google Calendar`).
|
|
13
|
+
//
|
|
14
|
+
// This module centralises the block-list and provides helpers that both the
|
|
15
|
+
// Docker image baseline and the `solve` runtime use to disable them.
|
|
16
|
+
//
|
|
17
|
+
// Related issue: https://github.com/link-assistant/hive-mind/issues/1627
|
|
18
|
+
|
|
19
|
+
if (typeof globalThis.use === 'undefined') {
|
|
20
|
+
globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
|
|
21
|
+
}
|
|
22
|
+
const fs = (await use('fs')).promises;
|
|
23
|
+
const os = await use('os');
|
|
24
|
+
const path = (await use('path')).default;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Built-in Claude Code tools that have no value (and may be harmful) in
|
|
28
|
+
* autonomous headless hive-mind runs. Every entry is a tool name as it
|
|
29
|
+
* appears in the stream-json `tools` array emitted by `claude --verbose`.
|
|
30
|
+
*/
|
|
31
|
+
export const USELESS_CLAUDE_BUILTIN_TOOLS = Object.freeze(['AskUserQuestion', 'CronCreate', 'CronDelete', 'CronList', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Monitor', 'NotebookEdit', 'PushNotification', 'RemoteTrigger', 'ScheduleWakeup']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Name prefixes of MCP servers that are always unusable in headless Docker
|
|
35
|
+
* runs because they require interactive OAuth that cannot complete without
|
|
36
|
+
* a browser. Match is case-insensitive on the full MCP server name.
|
|
37
|
+
*/
|
|
38
|
+
export const USELESS_MCP_SERVER_NAME_PREFIXES = Object.freeze(['claude.ai gmail', 'claude.ai google drive', 'claude.ai google calendar']);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* MCP tool-name prefixes derived from {@link USELESS_MCP_SERVER_NAME_PREFIXES}.
|
|
42
|
+
* Claude Code exposes MCP tools as `mcp__<server-name-slug>__<tool-name>`,
|
|
43
|
+
* replacing non-alphanumerics in the server name with `_`. Passing these
|
|
44
|
+
* entries to `--disallowedTools` is a belt-and-braces measure that complements
|
|
45
|
+
* filtering the MCP server itself.
|
|
46
|
+
*/
|
|
47
|
+
export const USELESS_MCP_TOOL_NAME_PREFIXES = Object.freeze(['mcp__claude_ai_Gmail', 'mcp__claude_ai_Google_Drive', 'mcp__claude_ai_Google_Calendar']);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tool identifiers accepted by `claude --disallowedTools ...`. This is a
|
|
51
|
+
* flat list combining the built-in tools with the wildcard forms of the
|
|
52
|
+
* useless MCP tool-name prefixes.
|
|
53
|
+
*/
|
|
54
|
+
export const buildDisallowedToolsList = () => [...USELESS_CLAUDE_BUILTIN_TOOLS, ...USELESS_MCP_TOOL_NAME_PREFIXES.map(prefix => `${prefix}__*`)];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns true if `name` matches one of the useless MCP server prefixes.
|
|
58
|
+
*/
|
|
59
|
+
export const isUselessMcpServerName = name => {
|
|
60
|
+
if (!name || typeof name !== 'string') return false;
|
|
61
|
+
const lower = name.toLowerCase();
|
|
62
|
+
return USELESS_MCP_SERVER_NAME_PREFIXES.some(prefix => lower.startsWith(prefix));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns the set of MCP server entries from an object (typically
|
|
67
|
+
* `~/.claude.json` `mcpServers` block) with useless entries removed.
|
|
68
|
+
*/
|
|
69
|
+
export const filterMcpServersObject = (mcpServers = {}) => {
|
|
70
|
+
const filtered = {};
|
|
71
|
+
for (const [name, config] of Object.entries(mcpServers || {})) {
|
|
72
|
+
if (isUselessMcpServerName(name)) continue;
|
|
73
|
+
filtered[name] = config;
|
|
74
|
+
}
|
|
75
|
+
return filtered;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a temporary MCP config JSON file that filters out both the three
|
|
80
|
+
* `claude.ai` OAuth connectors and (optionally) Playwright.
|
|
81
|
+
*
|
|
82
|
+
* Designed to be used with `--strict-mcp-config --mcp-config <file>` so the
|
|
83
|
+
* excluded servers are not even advertised to the model for this run.
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} [options]
|
|
86
|
+
* @param {boolean} [options.excludePlaywright] - Also exclude Playwright.
|
|
87
|
+
* @param {Function} [options.log] - Async logger with (msg, opts) signature.
|
|
88
|
+
* @returns {Promise<string|null>} absolute path to the temp config, or null
|
|
89
|
+
* if the home `.claude.json` file cannot be read (fatal errors are
|
|
90
|
+
* caught — callers should treat `null` as "skip --strict-mcp-config").
|
|
91
|
+
*/
|
|
92
|
+
export const buildFilteredMcpConfig = async ({ excludePlaywright = false, log } = {}) => {
|
|
93
|
+
try {
|
|
94
|
+
const claudeJsonPath = path.join(os.homedir(), '.claude.json');
|
|
95
|
+
const claudeJson = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
|
|
96
|
+
const mcpServers = claudeJson.mcpServers || {};
|
|
97
|
+
const filtered = {};
|
|
98
|
+
for (const [name, config] of Object.entries(mcpServers)) {
|
|
99
|
+
if (isUselessMcpServerName(name)) continue;
|
|
100
|
+
if (excludePlaywright && name.toLowerCase().includes('playwright')) continue;
|
|
101
|
+
filtered[name] = config;
|
|
102
|
+
}
|
|
103
|
+
const suffix = excludePlaywright ? 'no-playwright-no-useless' : 'no-useless';
|
|
104
|
+
const tempConfigPath = path.join(os.tmpdir(), `claude-mcp-${suffix}-${Date.now()}-${process.pid}.json`);
|
|
105
|
+
await fs.writeFile(tempConfigPath, JSON.stringify({ mcpServers: filtered }, null, 2));
|
|
106
|
+
if (log) {
|
|
107
|
+
const excluded = [...USELESS_MCP_SERVER_NAME_PREFIXES.map(p => `'${p}*'`), ...(excludePlaywright ? ["'playwright*'"] : [])].join(', ');
|
|
108
|
+
await log(`🧰 Created filtered MCP config (excluding ${excluded}): ${tempConfigPath}`, { verbose: true });
|
|
109
|
+
}
|
|
110
|
+
return tempConfigPath;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (log) await log(`⚠️ Could not build filtered useless-MCP config: ${err.message}`, { verbose: true });
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the per-session Claude CLI args for the useless-tools flag and the
|
|
119
|
+
* playwright-mcp flag in a single step. Returns an object with:
|
|
120
|
+
* - mcpConfigPath (string|null): temp file path for `--strict-mcp-config --mcp-config`
|
|
121
|
+
* - disallowedToolsList (string[]): values for `--disallowedTools`
|
|
122
|
+
*
|
|
123
|
+
* Callers are expected to append the returned values to the Claude command.
|
|
124
|
+
* Extracted from claude.lib.mjs to keep that file under the 1500-line cap.
|
|
125
|
+
*/
|
|
126
|
+
export const resolveClaudeSessionToolFlags = async ({ argv, log, fallbackBuildMcpConfigWithoutPlaywright } = {}) => {
|
|
127
|
+
const uselessToolsDisabled = argv?.uselessToolsDisabled !== false;
|
|
128
|
+
const excludePlaywright = argv?.playwrightMcp === false;
|
|
129
|
+
let mcpConfigPath = null;
|
|
130
|
+
if (uselessToolsDisabled || excludePlaywright) {
|
|
131
|
+
mcpConfigPath = await buildFilteredMcpConfig({ excludePlaywright, log });
|
|
132
|
+
if (!mcpConfigPath && excludePlaywright && fallbackBuildMcpConfigWithoutPlaywright) {
|
|
133
|
+
mcpConfigPath = await fallbackBuildMcpConfigWithoutPlaywright(log);
|
|
134
|
+
}
|
|
135
|
+
if (mcpConfigPath && log) {
|
|
136
|
+
if (excludePlaywright) await log('🎭 Playwright MCP physically disabled for this session via --strict-mcp-config', { verbose: true });
|
|
137
|
+
if (uselessToolsDisabled) await log('🧰 Useless MCP servers (claude.ai Gmail/Drive/Calendar) disabled for this session via --strict-mcp-config (issue #1627)', { verbose: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const disallowedToolsList = uselessToolsDisabled ? buildDisallowedToolsList() : [];
|
|
141
|
+
if (uselessToolsDisabled && log) await log(`🧰 Disallowed ${disallowedToolsList.length} useless Claude Code tool(s) for this session (issue #1627)`, { verbose: true });
|
|
142
|
+
return { mcpConfigPath, disallowedToolsList };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Persist `disallowedTools` in `~/.claude/settings.json` so even interactive
|
|
147
|
+
* `claude` sessions launched outside of `solve` don't surface the useless
|
|
148
|
+
* tools. Existing entries in the settings file are preserved (shallow
|
|
149
|
+
* merge) and any existing `disallowedTools` list has the useless tools
|
|
150
|
+
* added to it without duplicates. Returns the set of tools that were
|
|
151
|
+
* newly added.
|
|
152
|
+
*/
|
|
153
|
+
export const ensureDisallowedToolsInSettings = async ({ settingsPath, log } = {}) => {
|
|
154
|
+
const resolvedPath = settingsPath || path.join(os.homedir(), '.claude', 'settings.json');
|
|
155
|
+
const toBlock = buildDisallowedToolsList();
|
|
156
|
+
let settings = {};
|
|
157
|
+
try {
|
|
158
|
+
const content = await fs.readFile(resolvedPath, 'utf-8');
|
|
159
|
+
settings = JSON.parse(content);
|
|
160
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) settings = {};
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err.code !== 'ENOENT' && log) {
|
|
163
|
+
await log(`⚠️ Could not read ${resolvedPath}: ${err.message}`, { verbose: true });
|
|
164
|
+
}
|
|
165
|
+
settings = {};
|
|
166
|
+
}
|
|
167
|
+
const existing = Array.isArray(settings.disallowedTools) ? settings.disallowedTools : [];
|
|
168
|
+
const merged = [...existing];
|
|
169
|
+
const added = [];
|
|
170
|
+
for (const tool of toBlock) {
|
|
171
|
+
if (!merged.includes(tool)) {
|
|
172
|
+
merged.push(tool);
|
|
173
|
+
added.push(tool);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
settings.disallowedTools = merged;
|
|
177
|
+
try {
|
|
178
|
+
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
179
|
+
await fs.writeFile(resolvedPath, JSON.stringify(settings, null, 2));
|
|
180
|
+
if (log && added.length) {
|
|
181
|
+
await log(`🧰 Added ${added.length} useless tool(s) to ${resolvedPath} disallowedTools`, { verbose: true });
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (log) await log(`⚠️ Could not write ${resolvedPath}: ${err.message}`, { verbose: true });
|
|
185
|
+
}
|
|
186
|
+
return { added, total: merged.length, path: resolvedPath };
|
|
187
|
+
};
|