@brutalist/mcp 0.1.3 ā 0.5.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/README.md +227 -49
- package/dist/brutalist-server.d.ts +9 -3
- package/dist/brutalist-server.d.ts.map +1 -1
- package/dist/brutalist-server.js +730 -207
- package/dist/brutalist-server.js.map +1 -1
- package/dist/cli-agents.d.ts +73 -0
- package/dist/cli-agents.d.ts.map +1 -0
- package/dist/cli-agents.js +691 -0
- package/dist/cli-agents.js.map +1 -0
- package/dist/constants.d.ts +3 -9
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -13
- package/dist/constants.js.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/types/brutalist.d.ts +84 -15
- package/dist/types/brutalist.d.ts.map +1 -1
- package/dist/utils/pagination.d.ts +48 -0
- package/dist/utils/pagination.d.ts.map +1 -0
- package/dist/utils/pagination.js +171 -0
- package/dist/utils/pagination.js.map +1 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +25 -0
- package/dist/utils.js.map +1 -0
- package/package.json +26 -10
- package/dist/model-fetcher.d.ts +0 -14
- package/dist/model-fetcher.d.ts.map +0 -1
- package/dist/model-fetcher.js +0 -71
- package/dist/model-fetcher.js.map +0 -1
- package/dist/openrouter.d.ts +0 -14
- package/dist/openrouter.d.ts.map +0 -1
- package/dist/openrouter.js +0 -123
- package/dist/openrouter.js.map +0 -1
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
// Configurable timeouts and limits
|
|
4
|
+
const DEFAULT_TIMEOUT = parseInt(process.env.BRUTALIST_TIMEOUT || '300000', 10); // 5 minutes default
|
|
5
|
+
const CLI_CHECK_TIMEOUT = parseInt(process.env.BRUTALIST_CLI_CHECK_TIMEOUT || '5000', 10); // 5 seconds for CLI checks
|
|
6
|
+
const MAX_BUFFER_SIZE = parseInt(process.env.BRUTALIST_MAX_BUFFER || String(10 * 1024 * 1024), 10); // 10MB default
|
|
7
|
+
const MAX_CONCURRENT_CLIS = parseInt(process.env.BRUTALIST_MAX_CONCURRENT || '3', 10); // 3 concurrent CLIs
|
|
8
|
+
// Available models for each CLI
|
|
9
|
+
export const AVAILABLE_MODELS = {
|
|
10
|
+
claude: {
|
|
11
|
+
default: undefined, // Uses user's configured model
|
|
12
|
+
aliases: ['opus', 'sonnet', 'haiku'],
|
|
13
|
+
full: ['claude-opus-4-1-20250805', 'claude-sonnet-4-20250514']
|
|
14
|
+
},
|
|
15
|
+
codex: {
|
|
16
|
+
default: 'gpt-5', // Fast default reasoning
|
|
17
|
+
models: ['gpt-5', 'gpt-5-codex', 'o3', 'o3-mini', 'o3-pro', 'o4-mini']
|
|
18
|
+
},
|
|
19
|
+
gemini: {
|
|
20
|
+
default: 'gemini-2.5-flash', // Best price/performance
|
|
21
|
+
models: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite']
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
// Safe command execution helper using spawn instead of exec to prevent command injection
|
|
25
|
+
async function spawnAsync(command, args, options = {}) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
// Use working directory as-is - let CLI tools handle their own sandboxing
|
|
28
|
+
const cwd = options.cwd || process.cwd();
|
|
29
|
+
const child = spawn(command, args, {
|
|
30
|
+
cwd: cwd,
|
|
31
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
32
|
+
shell: false, // CRITICAL: disable shell to prevent injection
|
|
33
|
+
detached: command !== 'gemini', // Disable detached for Gemini CLI to fix macOS sandbox issue
|
|
34
|
+
env: options.env || process.env
|
|
35
|
+
});
|
|
36
|
+
let stdout = '';
|
|
37
|
+
let stderr = '';
|
|
38
|
+
let timedOut = false;
|
|
39
|
+
let killed = false;
|
|
40
|
+
// Set up timeout with SIGKILL escalation
|
|
41
|
+
const timeoutMs = options.timeout || DEFAULT_TIMEOUT;
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
timedOut = true;
|
|
44
|
+
// First try SIGTERM
|
|
45
|
+
child.kill('SIGTERM');
|
|
46
|
+
// If still running after 5 seconds, escalate to SIGKILL
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
if (!killed) {
|
|
49
|
+
try {
|
|
50
|
+
if (command === 'gemini' || process.platform === 'win32') {
|
|
51
|
+
// Gemini runs non-detached, and Windows doesn't support process groups
|
|
52
|
+
child.kill('SIGKILL');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// Other CLIs on Unix-like systems: kill process group
|
|
56
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
// Process may have already exited
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}, 5000);
|
|
64
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`));
|
|
65
|
+
}, timeoutMs);
|
|
66
|
+
// Collect output
|
|
67
|
+
// NOTE: maxBuffer (default 10MB) can lead to high memory usage if CLI agents produce large outputs.
|
|
68
|
+
// Consider making this configurable or dynamically adjusting based on expected output size.
|
|
69
|
+
child.stdout?.on('data', (data) => {
|
|
70
|
+
const chunk = data.toString();
|
|
71
|
+
stdout += chunk;
|
|
72
|
+
// Call progress callback if provided
|
|
73
|
+
if (options.onProgress) {
|
|
74
|
+
options.onProgress(chunk, 'stdout');
|
|
75
|
+
}
|
|
76
|
+
if (options.maxBuffer && stdout.length > options.maxBuffer) {
|
|
77
|
+
child.kill('SIGTERM');
|
|
78
|
+
reject(new Error(`stdout exceeded maxBuffer size: ${options.maxBuffer}`));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
child.stderr?.on('data', (data) => {
|
|
82
|
+
const chunk = data.toString();
|
|
83
|
+
stderr += chunk;
|
|
84
|
+
// Call progress callback if provided
|
|
85
|
+
if (options.onProgress) {
|
|
86
|
+
options.onProgress(chunk, 'stderr');
|
|
87
|
+
}
|
|
88
|
+
// Apply same buffer limit to stderr to prevent DoS
|
|
89
|
+
if (options.maxBuffer && stderr.length > options.maxBuffer) {
|
|
90
|
+
child.kill('SIGTERM');
|
|
91
|
+
reject(new Error(`stderr exceeded maxBuffer size: ${options.maxBuffer}`));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
// Handle completion
|
|
95
|
+
child.on('close', (code) => {
|
|
96
|
+
killed = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
if (!timedOut) {
|
|
99
|
+
if (code === 0) {
|
|
100
|
+
resolve({ stdout, stderr });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const error = new Error(`Command failed with exit code ${code}: ${command} ${args.join(' ')}`);
|
|
104
|
+
error.code = code || undefined;
|
|
105
|
+
error.stdout = stdout;
|
|
106
|
+
error.stderr = stderr;
|
|
107
|
+
reject(error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
child.on('error', (error) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(error);
|
|
114
|
+
});
|
|
115
|
+
// Send input if provided
|
|
116
|
+
if (options.input) {
|
|
117
|
+
child.stdin?.write(options.input);
|
|
118
|
+
child.stdin?.end();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
export class CLIAgentOrchestrator {
|
|
123
|
+
defaultTimeout = 1500000; // 25 minutes - thorough analysis takes time
|
|
124
|
+
defaultWorkingDir = process.cwd();
|
|
125
|
+
cliContext = { availableCLIs: [] };
|
|
126
|
+
cliContextCached = false;
|
|
127
|
+
cliContextCacheTime = 0;
|
|
128
|
+
CLI_CACHE_TTL = 300000; // 5 minutes cache
|
|
129
|
+
runningCLIs = 0; // Track concurrent CLI executions
|
|
130
|
+
MAX_CONCURRENT_CLIS = MAX_CONCURRENT_CLIS; // Configurable concurrency limit
|
|
131
|
+
// Streaming throttle properties
|
|
132
|
+
streamingBuffers = new Map();
|
|
133
|
+
STREAMING_FLUSH_INTERVAL = 200; // 200ms
|
|
134
|
+
MAX_CHUNK_SIZE = 2048; // 2KB per event
|
|
135
|
+
constructor() {
|
|
136
|
+
// Log configuration at startup
|
|
137
|
+
logger.info(`š§ Brutalist MCP Configuration:`);
|
|
138
|
+
logger.info(` - Default timeout: ${DEFAULT_TIMEOUT}ms`);
|
|
139
|
+
logger.info(` - CLI check timeout: ${CLI_CHECK_TIMEOUT}ms`);
|
|
140
|
+
logger.info(` - Max buffer size: ${MAX_BUFFER_SIZE} bytes`);
|
|
141
|
+
logger.info(` - Max concurrent CLIs: ${MAX_CONCURRENT_CLIS}`);
|
|
142
|
+
// Detect CLI context at startup and cache it
|
|
143
|
+
this.detectCLIContext().catch(error => {
|
|
144
|
+
logger.error("Failed to detect CLI context at startup:", error);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
parseClaudeStreamOutput(chunk, options) {
|
|
148
|
+
// Parse Claude's stream-json output to extract only model content
|
|
149
|
+
try {
|
|
150
|
+
const jsonChunk = JSON.parse(chunk.trim());
|
|
151
|
+
if (jsonChunk.type === 'assistant' && jsonChunk.message?.content) {
|
|
152
|
+
// Extract text content from assistant messages
|
|
153
|
+
const textContent = jsonChunk.message.content
|
|
154
|
+
.filter((c) => c.type === 'text')
|
|
155
|
+
.map((c) => c.text)
|
|
156
|
+
.join('');
|
|
157
|
+
if (textContent.trim()) {
|
|
158
|
+
return textContent;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Ignore system messages, init messages, etc.
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
// Not JSON, return as-is for non-streaming mode
|
|
166
|
+
return chunk;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Decode Claude's stream-json NDJSON output into plain text
|
|
170
|
+
decodeClaudeStreamJson(ndjsonOutput) {
|
|
171
|
+
if (!ndjsonOutput || !ndjsonOutput.trim()) {
|
|
172
|
+
return '';
|
|
173
|
+
}
|
|
174
|
+
const textParts = [];
|
|
175
|
+
const lines = ndjsonOutput.split('\n');
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
if (!line.trim())
|
|
178
|
+
continue;
|
|
179
|
+
try {
|
|
180
|
+
const event = JSON.parse(line);
|
|
181
|
+
// Handle different event types from Claude's stream-json format
|
|
182
|
+
if (event.type === 'message' && event.message?.content) {
|
|
183
|
+
// Full message event
|
|
184
|
+
const content = event.message.content;
|
|
185
|
+
if (Array.isArray(content)) {
|
|
186
|
+
for (const item of content) {
|
|
187
|
+
if (item.type === 'text' && item.text) {
|
|
188
|
+
textParts.push(item.text);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
194
|
+
// Incremental text delta
|
|
195
|
+
textParts.push(event.delta.text);
|
|
196
|
+
}
|
|
197
|
+
else if (event.type === 'assistant' && event.message?.content) {
|
|
198
|
+
// Assistant message format (same as parseClaudeStreamOutput)
|
|
199
|
+
const content = event.message.content;
|
|
200
|
+
if (Array.isArray(content)) {
|
|
201
|
+
for (const item of content) {
|
|
202
|
+
if (item.type === 'text' && item.text) {
|
|
203
|
+
textParts.push(item.text);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Skip invalid JSON lines
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return textParts.join('');
|
|
215
|
+
}
|
|
216
|
+
emitThrottledStreamingEvent(agent, type, content, onStreamingEvent, options) {
|
|
217
|
+
if (!onStreamingEvent)
|
|
218
|
+
return;
|
|
219
|
+
// Filter Claude stream output to only show model content
|
|
220
|
+
let processedContent = content;
|
|
221
|
+
if (agent === 'claude' && options?.progressToken) {
|
|
222
|
+
const filtered = this.parseClaudeStreamOutput(content, options);
|
|
223
|
+
if (!filtered)
|
|
224
|
+
return; // Skip non-content events
|
|
225
|
+
processedContent = filtered;
|
|
226
|
+
}
|
|
227
|
+
const key = `${agent}-${type}`;
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
// Truncate content to prevent huge events
|
|
230
|
+
const truncatedContent = processedContent.length > this.MAX_CHUNK_SIZE
|
|
231
|
+
? processedContent.substring(0, this.MAX_CHUNK_SIZE) + '...[truncated]'
|
|
232
|
+
: processedContent;
|
|
233
|
+
// Get or create buffer for this agent+type
|
|
234
|
+
if (!this.streamingBuffers.has(key)) {
|
|
235
|
+
this.streamingBuffers.set(key, { chunks: [], lastFlush: now });
|
|
236
|
+
}
|
|
237
|
+
const buffer = this.streamingBuffers.get(key);
|
|
238
|
+
buffer.chunks.push(truncatedContent);
|
|
239
|
+
// For progress notifications, emit immediately and also call onProgress
|
|
240
|
+
if (options?.progressToken && options?.onProgress && type === 'agent_progress') {
|
|
241
|
+
// Estimate progress based on content length (rough approximation)
|
|
242
|
+
const currentProgress = buffer.chunks.length * 10; // rough estimate
|
|
243
|
+
const totalProgress = 100;
|
|
244
|
+
options.onProgress(currentProgress, totalProgress, `${agent.toUpperCase()}: ${truncatedContent.substring(0, 50)}...`);
|
|
245
|
+
}
|
|
246
|
+
// Flush if enough time has passed or buffer is getting large
|
|
247
|
+
if (now - buffer.lastFlush > this.STREAMING_FLUSH_INTERVAL || buffer.chunks.length > 10) {
|
|
248
|
+
const combinedContent = buffer.chunks.join('\n');
|
|
249
|
+
onStreamingEvent({
|
|
250
|
+
type,
|
|
251
|
+
agent,
|
|
252
|
+
content: combinedContent,
|
|
253
|
+
timestamp: now
|
|
254
|
+
});
|
|
255
|
+
// Reset buffer
|
|
256
|
+
buffer.chunks = [];
|
|
257
|
+
buffer.lastFlush = now;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async detectCLIContext() {
|
|
261
|
+
// Return cached context if still valid
|
|
262
|
+
if (this.cliContextCached && Date.now() - this.cliContextCacheTime < this.CLI_CACHE_TTL) {
|
|
263
|
+
logger.debug('Using cached CLI context');
|
|
264
|
+
return this.cliContext;
|
|
265
|
+
}
|
|
266
|
+
const availableCLIs = [];
|
|
267
|
+
let currentCLI;
|
|
268
|
+
// Check for available CLIs
|
|
269
|
+
const cliChecks = [
|
|
270
|
+
{ name: 'claude', command: 'claude --version' },
|
|
271
|
+
{ name: 'codex', command: 'codex --version' },
|
|
272
|
+
{ name: 'gemini', command: 'gemini --version' }
|
|
273
|
+
];
|
|
274
|
+
const results = await Promise.allSettled(cliChecks.map(async (check) => {
|
|
275
|
+
try {
|
|
276
|
+
await spawnAsync(check.name, ['--version'], { timeout: CLI_CHECK_TIMEOUT });
|
|
277
|
+
logger.debug(`CLI available: ${check.name}`);
|
|
278
|
+
return check.name;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
logger.debug(`CLI not available: ${check.name}`);
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}));
|
|
285
|
+
const detectedCLIs = results
|
|
286
|
+
.filter(result => result.status === 'fulfilled' && result.value !== null)
|
|
287
|
+
.map(result => result.value);
|
|
288
|
+
availableCLIs.push(...detectedCLIs);
|
|
289
|
+
// Detect current CLI context from environment or process
|
|
290
|
+
currentCLI = this.detectCurrentCLI();
|
|
291
|
+
this.cliContext = { currentCLI, availableCLIs };
|
|
292
|
+
this.cliContextCached = true;
|
|
293
|
+
this.cliContextCacheTime = Date.now();
|
|
294
|
+
return this.cliContext;
|
|
295
|
+
}
|
|
296
|
+
detectCurrentCLI() {
|
|
297
|
+
// Check environment variables that might indicate current CLI
|
|
298
|
+
if (process.env.CLAUDE_CODE_SESSION || process.env.CLAUDE_CONFIG_DIR) {
|
|
299
|
+
return 'claude';
|
|
300
|
+
}
|
|
301
|
+
if (process.env.CODEX_SESSION || process.env.OPENAI_CODEX_SESSION) {
|
|
302
|
+
return 'codex';
|
|
303
|
+
}
|
|
304
|
+
if (process.env.GEMINI_SESSION || process.env.GEMINI_API_KEY) {
|
|
305
|
+
return 'gemini';
|
|
306
|
+
}
|
|
307
|
+
// Check process parent/ancestry for CLI indicators
|
|
308
|
+
try {
|
|
309
|
+
const processInfo = process.env._;
|
|
310
|
+
if (processInfo?.includes('claude'))
|
|
311
|
+
return 'claude';
|
|
312
|
+
if (processInfo?.includes('codex'))
|
|
313
|
+
return 'codex';
|
|
314
|
+
if (processInfo?.includes('gemini'))
|
|
315
|
+
return 'gemini';
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
logger.debug('Could not detect current CLI from process info');
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
selectSingleCLI(preferredCLI, analysisType) {
|
|
323
|
+
// 1. Honor explicit preference if available (allow even if current CLI to avoid blocking)
|
|
324
|
+
if (preferredCLI && this.cliContext.availableCLIs.includes(preferredCLI)) {
|
|
325
|
+
logger.info(`ā
Using preferred CLI: ${preferredCLI}`);
|
|
326
|
+
return preferredCLI;
|
|
327
|
+
}
|
|
328
|
+
// 2. Smart selection based on analysis type
|
|
329
|
+
const selectionRules = {
|
|
330
|
+
'code': ['claude', 'codex', 'gemini'],
|
|
331
|
+
'architecture': ['gemini', 'claude', 'codex'],
|
|
332
|
+
'research': ['claude', 'gemini', 'codex'],
|
|
333
|
+
'security': ['codex', 'claude', 'gemini'],
|
|
334
|
+
'data': ['gemini', 'claude', 'codex'],
|
|
335
|
+
'product': ['claude', 'gemini', 'codex'],
|
|
336
|
+
'infrastructure': ['gemini', 'codex', 'claude'],
|
|
337
|
+
'idea': ['claude', 'gemini', 'codex'],
|
|
338
|
+
'debate': ['claude', 'gemini', 'codex'],
|
|
339
|
+
'default': ['claude', 'gemini', 'codex']
|
|
340
|
+
};
|
|
341
|
+
const priority = selectionRules[analysisType || 'default'] || selectionRules.default;
|
|
342
|
+
// 3. Filter available CLIs, exclude current CLI only for auto-selection to prevent recursion
|
|
343
|
+
const currentCLI = this.cliContext.currentCLI;
|
|
344
|
+
const candidates = this.cliContext.availableCLIs.filter(cli => cli !== currentCLI);
|
|
345
|
+
// If no candidates after filtering, fall back to available CLIs (allow recursion if necessary)
|
|
346
|
+
const finalCandidates = candidates.length > 0 ? candidates : this.cliContext.availableCLIs;
|
|
347
|
+
if (finalCandidates.length === 0) {
|
|
348
|
+
throw new Error('No CLI agents available');
|
|
349
|
+
}
|
|
350
|
+
// 4. Select by priority
|
|
351
|
+
for (const cli of priority) {
|
|
352
|
+
if (finalCandidates.includes(cli)) {
|
|
353
|
+
const recursionWarning = candidates.length === 0 ? ' (allowing recursion)' : '';
|
|
354
|
+
logger.info(`šÆ Auto-selected ${cli} for ${analysisType || 'general'} analysis${recursionWarning}`);
|
|
355
|
+
return cli;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Fallback to first available
|
|
359
|
+
logger.warn(`ā ļø Using fallback CLI: ${finalCandidates[0]}`);
|
|
360
|
+
return finalCandidates[0];
|
|
361
|
+
}
|
|
362
|
+
async _executeCLI(cliName, userPrompt, systemPromptSpec, options = {}, commandBuilder) {
|
|
363
|
+
const startTime = Date.now();
|
|
364
|
+
const workingDir = options.workingDirectory || this.defaultWorkingDir;
|
|
365
|
+
const timeout = options.timeout || this.defaultTimeout;
|
|
366
|
+
try {
|
|
367
|
+
logger.info(`š¤ Executing ${cliName.toUpperCase()} CLI`);
|
|
368
|
+
logger.debug(`${cliName.toUpperCase()} prompt`, { prompt: userPrompt.substring(0, 100) });
|
|
369
|
+
// Emit agent start event
|
|
370
|
+
if (options.onStreamingEvent) {
|
|
371
|
+
options.onStreamingEvent({
|
|
372
|
+
type: 'agent_start',
|
|
373
|
+
agent: cliName,
|
|
374
|
+
content: `Starting ${cliName.toUpperCase()} analysis...`,
|
|
375
|
+
timestamp: Date.now()
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
// WARNING: Claude CLI does not have a native --sandbox flag.
|
|
379
|
+
// If options.sandbox is true, it is assumed that the environment
|
|
380
|
+
// running this Brutalist MCP server provides the sandboxing (e.g., Docker, VM).
|
|
381
|
+
// Running Claude without external sandboxing can be a security risk.
|
|
382
|
+
if (cliName === 'claude' && options.sandbox) {
|
|
383
|
+
logger.warn("ā ļø Claude CLI requested with sandbox: true, but Claude CLI does not support native sandboxing. Ensure external sandboxing is in place.");
|
|
384
|
+
}
|
|
385
|
+
const { command, args, env, input } = commandBuilder(userPrompt, systemPromptSpec, options);
|
|
386
|
+
logger.info(`š Command: ${command} ${args.join(' ')}`);
|
|
387
|
+
logger.info(`š Working directory: ${workingDir}`);
|
|
388
|
+
logger.info(`ā±ļø Timeout: ${timeout}ms`);
|
|
389
|
+
if (input) {
|
|
390
|
+
logger.info(`š Using stdin for prompt (${input.length} characters)`);
|
|
391
|
+
}
|
|
392
|
+
const { stdout, stderr } = await spawnAsync(command, args, {
|
|
393
|
+
cwd: workingDir,
|
|
394
|
+
timeout: timeout,
|
|
395
|
+
maxBuffer: MAX_BUFFER_SIZE, // Configurable buffer for model outputs
|
|
396
|
+
env: env,
|
|
397
|
+
input: input,
|
|
398
|
+
onProgress: (chunk, type) => {
|
|
399
|
+
// Stream output in real-time with agent identification
|
|
400
|
+
if (type === 'stdout' && chunk.trim()) {
|
|
401
|
+
logger.info(`š¤ ${cliName.toUpperCase()}: ${chunk.trim()}`);
|
|
402
|
+
// Emit throttled streaming event for real-time updates
|
|
403
|
+
this.emitThrottledStreamingEvent(cliName, 'agent_progress', chunk.trim(), options.onStreamingEvent, options);
|
|
404
|
+
}
|
|
405
|
+
else if (type === 'stderr' && chunk.trim()) {
|
|
406
|
+
logger.warn(`ā ļø ${cliName.toUpperCase()} stderr: ${chunk.trim()}`);
|
|
407
|
+
// Emit throttled error streaming event
|
|
408
|
+
this.emitThrottledStreamingEvent(cliName, 'agent_error', chunk.trim(), options.onStreamingEvent, options);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
logger.info(`ā
${cliName.toUpperCase()} completed (${Date.now() - startTime}ms)`);
|
|
413
|
+
// Emit completion event
|
|
414
|
+
if (options.onStreamingEvent) {
|
|
415
|
+
options.onStreamingEvent({
|
|
416
|
+
type: 'agent_complete',
|
|
417
|
+
agent: cliName,
|
|
418
|
+
content: `${cliName.toUpperCase()} analysis completed (${Date.now() - startTime}ms)`,
|
|
419
|
+
timestamp: Date.now()
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
// Post-process Claude stream-json output if needed
|
|
423
|
+
let finalOutput = stdout;
|
|
424
|
+
// If Claude was run with stream-json format, decode the NDJSON to extract text
|
|
425
|
+
if (cliName === 'claude' && args.includes('--output-format') && args.includes('stream-json')) {
|
|
426
|
+
const decodedText = this.decodeClaudeStreamJson(stdout);
|
|
427
|
+
if (decodedText) {
|
|
428
|
+
finalOutput = decodedText;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Fallback: If stdout is empty but stderr has content and exit was successful,
|
|
432
|
+
// Claude might have written to stderr (common in non-TTY environments)
|
|
433
|
+
if (!finalOutput.trim() && stderr && stderr.trim()) {
|
|
434
|
+
logger.info(`š Using stderr as output for ${cliName} (stdout was empty)`);
|
|
435
|
+
finalOutput = stderr;
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
agent: cliName,
|
|
439
|
+
success: true,
|
|
440
|
+
output: finalOutput,
|
|
441
|
+
error: stderr || undefined,
|
|
442
|
+
executionTime: Date.now() - startTime,
|
|
443
|
+
command: `${command} ${args.join(' ')}`,
|
|
444
|
+
workingDirectory: workingDir,
|
|
445
|
+
exitCode: 0
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
const execError = error;
|
|
450
|
+
const exitCode = execError.code || -1;
|
|
451
|
+
// Detect rate limiting errors for Gemini
|
|
452
|
+
const isRateLimit = cliName === 'gemini' && (execError.stderr?.includes('429') ||
|
|
453
|
+
execError.message?.includes('rateLimitExceeded') ||
|
|
454
|
+
execError.stderr?.includes('rate limit'));
|
|
455
|
+
if (isRateLimit) {
|
|
456
|
+
logger.warn(`ā±ļø ${cliName.toUpperCase()} CLI hit rate limit (${Date.now() - startTime}ms)`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
logger.error(`ā ${cliName.toUpperCase()} execution failed (${Date.now() - startTime}ms)`, {
|
|
460
|
+
error: "Redacted: See internal logs for full error details.",
|
|
461
|
+
exitCode,
|
|
462
|
+
stderr: "Redacted: See internal logs for full stderr output."
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
// Emit error event
|
|
466
|
+
if (options.onStreamingEvent) {
|
|
467
|
+
options.onStreamingEvent({
|
|
468
|
+
type: 'agent_error',
|
|
469
|
+
agent: cliName,
|
|
470
|
+
content: `${cliName.toUpperCase()} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
471
|
+
timestamp: Date.now()
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
agent: cliName,
|
|
476
|
+
success: false,
|
|
477
|
+
output: '',
|
|
478
|
+
error: error instanceof Error ? error.message : String(error),
|
|
479
|
+
executionTime: Date.now() - startTime,
|
|
480
|
+
command: `(redacted command for ${cliName})`,
|
|
481
|
+
workingDirectory: workingDir,
|
|
482
|
+
exitCode
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async executeClaudeCode(userPrompt, systemPromptSpec, options = {}) {
|
|
487
|
+
return this._executeCLI('claude', userPrompt, systemPromptSpec, options, (userPrompt, systemPromptSpec, options) => {
|
|
488
|
+
const combinedPrompt = `${systemPromptSpec}\n\n${userPrompt}`;
|
|
489
|
+
const args = ['--print'];
|
|
490
|
+
// Enable streaming for real-time progress if progress notifications are enabled
|
|
491
|
+
if (options.progressToken) {
|
|
492
|
+
args.push('--output-format', 'stream-json', '--verbose');
|
|
493
|
+
}
|
|
494
|
+
// Use provided model or let Claude use its default
|
|
495
|
+
const model = options.models?.claude || AVAILABLE_MODELS.claude.default;
|
|
496
|
+
if (model) {
|
|
497
|
+
args.push('--model', model);
|
|
498
|
+
}
|
|
499
|
+
// Pass prompt as argument - Claude CLI works better this way
|
|
500
|
+
args.push(combinedPrompt);
|
|
501
|
+
// Set environment to ensure consistent output behavior
|
|
502
|
+
const env = {
|
|
503
|
+
...process.env,
|
|
504
|
+
TERM: 'dumb', // Disable fancy terminal output
|
|
505
|
+
NO_COLOR: '1', // Disable colored output
|
|
506
|
+
CI: 'true' // Indicate non-interactive environment
|
|
507
|
+
};
|
|
508
|
+
return { command: 'claude', args, env };
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
async executeCodex(userPrompt, systemPromptSpec, options = {}) {
|
|
512
|
+
return this._executeCLI('codex', userPrompt, systemPromptSpec, { ...options, sandbox: true }, // Ensure sandbox is always true for Codex
|
|
513
|
+
(userPrompt, systemPromptSpec, options) => {
|
|
514
|
+
const combinedPrompt = `CONTEXT AND INSTRUCTIONS:\n${systemPromptSpec}\n\nANALYZE:\n${userPrompt}`;
|
|
515
|
+
const args = ['exec'];
|
|
516
|
+
// Use provided model or default to gpt-5
|
|
517
|
+
const model = options.models?.codex || AVAILABLE_MODELS.codex.default;
|
|
518
|
+
args.push('--model', model);
|
|
519
|
+
if (options.sandbox) {
|
|
520
|
+
args.push('--sandbox', 'read-only');
|
|
521
|
+
}
|
|
522
|
+
// Use stdin for the prompt instead of argv to avoid ARG_MAX limits
|
|
523
|
+
return {
|
|
524
|
+
command: 'codex',
|
|
525
|
+
args,
|
|
526
|
+
input: combinedPrompt
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async executeGemini(userPrompt, systemPromptSpec, options = {}) {
|
|
531
|
+
return this._executeCLI('gemini', userPrompt, systemPromptSpec, { ...options, sandbox: true }, // Ensure sandbox is always true for Gemini
|
|
532
|
+
(userPrompt, systemPromptSpec, options) => {
|
|
533
|
+
const args = [];
|
|
534
|
+
// Use provided model or default to gemini-2.5-flash
|
|
535
|
+
const modelName = options.models?.gemini || AVAILABLE_MODELS.gemini.default;
|
|
536
|
+
args.push('--model', modelName);
|
|
537
|
+
if (options.sandbox) {
|
|
538
|
+
args.push('--sandbox');
|
|
539
|
+
}
|
|
540
|
+
const combinedPrompt = `${systemPromptSpec}\n\n${userPrompt}`;
|
|
541
|
+
args.push(combinedPrompt);
|
|
542
|
+
return {
|
|
543
|
+
command: 'gemini',
|
|
544
|
+
args: args,
|
|
545
|
+
env: {
|
|
546
|
+
...process.env,
|
|
547
|
+
TERM: 'dumb',
|
|
548
|
+
NO_COLOR: '1',
|
|
549
|
+
CI: 'true'
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
async executeSingleCLI(cli, userPrompt, systemPromptSpec, options = {}) {
|
|
555
|
+
// Wait for available slot to prevent resource exhaustion
|
|
556
|
+
await this.waitForAvailableSlot();
|
|
557
|
+
this.runningCLIs++;
|
|
558
|
+
logger.info(`šÆ Executing ${cli} (${this.runningCLIs}/${this.MAX_CONCURRENT_CLIS} slots used)`);
|
|
559
|
+
try {
|
|
560
|
+
switch (cli) {
|
|
561
|
+
case 'claude':
|
|
562
|
+
return await this.executeClaudeCode(userPrompt, systemPromptSpec, options);
|
|
563
|
+
case 'codex':
|
|
564
|
+
return await this.executeCodex(userPrompt, systemPromptSpec, { ...options, sandbox: true });
|
|
565
|
+
case 'gemini':
|
|
566
|
+
return await this.executeGemini(userPrompt, systemPromptSpec, { ...options, sandbox: true });
|
|
567
|
+
default:
|
|
568
|
+
throw new Error(`Unknown CLI: ${cli}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
this.runningCLIs--;
|
|
573
|
+
logger.info(`ā
Released CLI slot (${this.runningCLIs}/${this.MAX_CONCURRENT_CLIS} slots used)`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async waitForAvailableSlot() {
|
|
577
|
+
let waitTime = 100; // Start with 100ms wait time
|
|
578
|
+
while (this.runningCLIs >= this.MAX_CONCURRENT_CLIS) {
|
|
579
|
+
logger.info(`ā³ Waiting for available CLI slot (${this.runningCLIs}/${this.MAX_CONCURRENT_CLIS} in use). Next check in ${waitTime}ms...`);
|
|
580
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
581
|
+
waitTime = Math.min(waitTime * 2, 5000); // Exponential backoff, max 5 seconds
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async executeBrutalistAnalysis(analysisType, targetPath, systemPromptSpec, context, options = {}) {
|
|
585
|
+
const userPrompt = this.constructUserPrompt(analysisType, targetPath, context);
|
|
586
|
+
// If preferred CLI is specified, use single CLI mode
|
|
587
|
+
if (options.preferredCLI) {
|
|
588
|
+
const selectedCLI = this.selectSingleCLI(options.preferredCLI, options.analysisType);
|
|
589
|
+
logger.info(`ā
Using preferred CLI: ${selectedCLI}`);
|
|
590
|
+
const response = await this.executeSingleCLI(selectedCLI, userPrompt, systemPromptSpec, options);
|
|
591
|
+
return [{
|
|
592
|
+
...response,
|
|
593
|
+
selectionMethod: 'user-specified',
|
|
594
|
+
analysisType
|
|
595
|
+
}];
|
|
596
|
+
}
|
|
597
|
+
// Multi-CLI execution (default behavior)
|
|
598
|
+
logger.info(`š Executing multi-CLI analysis`);
|
|
599
|
+
// Only exclude current CLI if we have other options
|
|
600
|
+
let availableCLIs = [...this.cliContext.availableCLIs];
|
|
601
|
+
if (this.cliContext.currentCLI && this.cliContext.availableCLIs.length > 1) {
|
|
602
|
+
// Exclude current CLI to prevent recursion, but only if we have alternatives
|
|
603
|
+
availableCLIs = availableCLIs.filter(cli => cli !== this.cliContext.currentCLI);
|
|
604
|
+
logger.info(`š Excluding current CLI (${this.cliContext.currentCLI}) to prevent recursion`);
|
|
605
|
+
}
|
|
606
|
+
else if (this.cliContext.currentCLI && this.cliContext.availableCLIs.length === 1) {
|
|
607
|
+
logger.info(`š Using current CLI (${this.cliContext.currentCLI}) - spawning separate process`);
|
|
608
|
+
}
|
|
609
|
+
if (availableCLIs.length === 0) {
|
|
610
|
+
throw new Error('No CLI agents available for analysis');
|
|
611
|
+
}
|
|
612
|
+
logger.info(`š Available CLIs: ${availableCLIs.join(', ')}`);
|
|
613
|
+
// Execute all available CLIs in parallel with allSettled for better error handling
|
|
614
|
+
const promises = availableCLIs.map(async (cli) => {
|
|
615
|
+
try {
|
|
616
|
+
const response = await this.executeSingleCLI(cli, userPrompt, systemPromptSpec, options);
|
|
617
|
+
return {
|
|
618
|
+
...response,
|
|
619
|
+
selectionMethod: 'multi-cli',
|
|
620
|
+
analysisType
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
logger.error(`ā ${cli} execution failed:`, error);
|
|
625
|
+
return {
|
|
626
|
+
agent: cli,
|
|
627
|
+
success: false,
|
|
628
|
+
output: '',
|
|
629
|
+
error: error instanceof Error ? error.message : String(error),
|
|
630
|
+
executionTime: 0,
|
|
631
|
+
selectionMethod: 'multi-cli',
|
|
632
|
+
analysisType
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
// Use allSettled to handle partial failures gracefully
|
|
637
|
+
const results = await Promise.allSettled(promises);
|
|
638
|
+
const responses = results
|
|
639
|
+
.filter(result => result.status === 'fulfilled')
|
|
640
|
+
.map(result => result.value);
|
|
641
|
+
logger.info(`ā
Multi-CLI analysis complete: ${responses.filter(r => r.success).length}/${responses.length} successful`);
|
|
642
|
+
return responses;
|
|
643
|
+
}
|
|
644
|
+
synthesizeBrutalistFeedback(responses, analysisType) {
|
|
645
|
+
const successfulResponses = responses.filter(r => r.success);
|
|
646
|
+
const failedResponses = responses.filter(r => !r.success);
|
|
647
|
+
if (successfulResponses.length === 0) {
|
|
648
|
+
return `# Brutalist Analysis Failed\n\nā All CLI agents failed to analyze\n${failedResponses.map(r => `- ${r.agent.toUpperCase()}: ${r.error}`).join('\n')}`;
|
|
649
|
+
}
|
|
650
|
+
let synthesis = `${successfulResponses.length} AI critics have systematically demolished your work.\n\n`;
|
|
651
|
+
successfulResponses.forEach((response, index) => {
|
|
652
|
+
synthesis += `## Critic ${index + 1}: ${response.agent.toUpperCase()}\n`;
|
|
653
|
+
synthesis += `*Execution time: ${response.executionTime}ms*\n\n`;
|
|
654
|
+
synthesis += response.output;
|
|
655
|
+
synthesis += '\n\n---\n\n';
|
|
656
|
+
});
|
|
657
|
+
if (failedResponses.length > 0) {
|
|
658
|
+
synthesis += `## Failed Critics\n`;
|
|
659
|
+
synthesis += `${failedResponses.length} critics failed to complete their destruction:\n`;
|
|
660
|
+
failedResponses.forEach(r => {
|
|
661
|
+
synthesis += `- **${r.agent.toUpperCase()}**: ${r.error}\n`;
|
|
662
|
+
});
|
|
663
|
+
synthesis += '\n';
|
|
664
|
+
}
|
|
665
|
+
return synthesis.trim();
|
|
666
|
+
}
|
|
667
|
+
constructUserPrompt(analysisType, targetPath, context) {
|
|
668
|
+
// Trust CLI tools to handle their own security
|
|
669
|
+
const sanitizedTargetPath = targetPath;
|
|
670
|
+
const sanitizedContext = context || 'No additional context provided';
|
|
671
|
+
const prompts = {
|
|
672
|
+
code: `Analyze the codebase at ${sanitizedTargetPath} for issues. Context: ${sanitizedContext}`,
|
|
673
|
+
codebase: `Analyze the codebase directory at ${sanitizedTargetPath} for security vulnerabilities, performance issues, and architectural problems. Context: ${sanitizedContext}`,
|
|
674
|
+
architecture: `Review the architecture: ${sanitizedTargetPath}. Find every scaling failure and cost explosion.`,
|
|
675
|
+
idea: `Analyze this idea: ${sanitizedTargetPath}. Find where imagination fails to become reality.`,
|
|
676
|
+
research: `Review this research: ${sanitizedTargetPath}. Find every methodological flaw and reproducibility issue.`,
|
|
677
|
+
data: `Analyze this data/model: ${sanitizedTargetPath}. Find every overfitting issue, bias, and correlation fallacy.`,
|
|
678
|
+
security: `Security audit of: ${sanitizedTargetPath}. Find every attack vector and vulnerability.`,
|
|
679
|
+
product: `Product review: ${sanitizedTargetPath}. Find every UX disaster and adoption barrier.`,
|
|
680
|
+
infrastructure: `Infrastructure review: ${sanitizedTargetPath}. Find every single point of failure.`,
|
|
681
|
+
debate: `Debate topic: ${sanitizedTargetPath}. Take opposing positions and argue until truth emerges.`,
|
|
682
|
+
fileStructure: `Analyze the directory structure at ${sanitizedTargetPath}. Find organizational disasters and naming failures.`,
|
|
683
|
+
dependencies: `Analyze dependencies at ${sanitizedTargetPath}. Find version conflicts and security vulnerabilities.`,
|
|
684
|
+
gitHistory: `Analyze git history at ${sanitizedTargetPath}. Find commit disasters and workflow failures.`,
|
|
685
|
+
testCoverage: `Analyze test coverage at ${sanitizedTargetPath}. Find testing gaps and quality issues.`
|
|
686
|
+
};
|
|
687
|
+
const specificPrompt = prompts[analysisType] || `Analyze ${sanitizedTargetPath} for ${analysisType} issues.`;
|
|
688
|
+
return `${specificPrompt} ${context ? `Context: ${sanitizedContext}` : ''}`;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
//# sourceMappingURL=cli-agents.js.map
|