squabble-mcp
Version:
Engineer-driven development with critical-thinking PM collaboration - MCP server for Claude
345 lines • 13.5 kB
JavaScript
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs-extra';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { EventEmitter } from 'events';
import readline from 'readline';
import { env } from 'process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// MCP configuration for PM server
const isDevelopment = env.NODE_ENV === 'development' || !env.NODE_ENV;
const PM_MCP_CONFIG = {
mcpServers: {
'squabble-pm': {
command: isDevelopment ? 'node' : 'npx',
args: isDevelopment
? [path.join(__dirname, '../../../dist/mcp-server/server.js'), '--role', 'pm']
: ['-y', 'squabble-mcp', '--role', 'pm']
}
}
};
/**
* Enhanced PM Session Manager with real-time streaming
*/
export class StreamingPMSessionManager extends EventEmitter {
workspaceManager;
activeProcess;
currentSessionId;
constructor(workspaceManager) {
super();
this.workspaceManager = workspaceManager;
}
/**
* Spawns a PM session with real-time streaming
*/
async consultPMStreaming(prompt, systemPrompt, resumeSessionId) {
// Write MCP config to workspace
const mcpConfigPath = path.join(this.workspaceManager.getWorkspaceRoot(), 'mcp-config-pm.json');
await fs.writeJson(mcpConfigPath, PM_MCP_CONFIG, { spaces: 2 });
const args = [
'-p',
'--system-prompt',
systemPrompt,
'--mcp-config',
mcpConfigPath,
'--allowedTools',
'mcp__squabble-pm__pm_update_tasks,Read,Write,Edit,MultiEdit,Bash,Grep,Glob,LS,WebFetch,Task',
'--output-format',
'stream-json',
'--verbose'
];
if (resumeSessionId) {
args.push('--resume', resumeSessionId);
}
// Spawn claude process
const claudeProcess = spawn('claude', args, {
stdio: ['pipe', 'pipe', 'pipe']
});
this.activeProcess = claudeProcess;
// Write the prompt to stdin
claudeProcess.stdin.write(prompt);
claudeProcess.stdin.end();
// Set up streaming for stdout
this.setupStreaming(claudeProcess);
// Handle errors
claudeProcess.on('error', (error) => {
this.emit('event', {
timestamp: new Date().toISOString(),
type: 'error',
message: `Process error: ${error.message}`,
sessionId: this.currentSessionId
});
});
claudeProcess.on('exit', (code, signal) => {
if (this.currentSessionId) {
this.emit('event', {
timestamp: new Date().toISOString(),
type: 'session_end',
sessionId: this.currentSessionId,
message: `Process exited with code ${code}`
});
}
this.activeProcess = undefined;
});
// Wait for session ID from the stream
const sessionId = await this.waitForSessionId(claudeProcess);
this.currentSessionId = sessionId;
return { sessionId, process: claudeProcess };
}
/**
* Set up real-time streaming of JSON events
*/
setupStreaming(process) {
if (!process.stdout)
return;
const rl = readline.createInterface({
input: process.stdout,
crlfDelay: Infinity
});
rl.on('line', (line) => {
if (!line.trim())
return;
try {
const event = JSON.parse(line);
// Debug logging to see what events we're getting
if (env.DEBUG_STREAMING) {
console.log('[Stream Debug] Event type:', event.type, 'subtype:', event.subtype);
}
this.handleStreamEvent(event);
}
catch (error) {
// Not JSON, might be plain text output
console.error('Failed to parse JSON line:', line);
}
});
// Also handle stderr
if (process.stderr) {
const rlErr = readline.createInterface({
input: process.stderr,
crlfDelay: Infinity
});
rlErr.on('line', (line) => {
console.error('PM stderr:', line);
});
}
}
/**
* Handle a streaming event from Claude CLI
*/
handleStreamEvent(event) {
const timestamp = new Date().toISOString();
switch (event.type) {
case 'system':
if (event.subtype === 'init' && event.session_id) {
this.currentSessionId = event.session_id;
this.emit('event', {
timestamp,
type: 'session_start',
sessionId: event.session_id,
message: `PM Session Started (ID: ${event.session_id})`
});
}
break;
case 'tool_use':
this.emit('event', {
timestamp,
type: 'tool_use',
sessionId: this.currentSessionId,
tool: event.name,
args: event.input,
id: event.id,
message: this.formatToolUse(event)
});
break;
case 'tool_result':
const resultSummary = this.formatToolResult(event);
if (resultSummary) {
this.emit('event', {
timestamp,
type: 'tool_result',
sessionId: this.currentSessionId,
id: event.tool_use_id,
result: resultSummary,
message: resultSummary
});
}
break;
case 'content':
if (event.text && event.text.trim()) {
this.emit('event', {
timestamp,
type: 'pm_message',
sessionId: this.currentSessionId,
message: event.text
});
}
break;
case 'assistant':
// Handle assistant messages from stream-json format
if (event.message && event.message.content) {
// Extract text content from the assistant message
const textContent = event.message.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n');
if (textContent.trim()) {
this.emit('event', {
timestamp,
type: 'pm_message',
sessionId: this.currentSessionId,
message: textContent
});
}
}
break;
case 'user':
// Handle tool results that come back as user messages
if (event.message && event.message.content) {
event.message.content.forEach((content) => {
if (content.type === 'tool_result') {
// Extract the actual result content
let resultContent = '';
if (typeof content.content === 'string') {
resultContent = content.content;
}
else if (Array.isArray(content.content)) {
// Handle array of content blocks
resultContent = content.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n');
}
if (resultContent) {
this.emit('event', {
timestamp,
type: 'tool_result',
sessionId: this.currentSessionId,
id: content.tool_use_id,
result: resultContent,
message: this.formatToolResultForDisplay(resultContent)
});
}
}
});
}
break;
}
}
/**
* Wait for session ID from the stream
*/
waitForSessionId(process) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Timeout waiting for session ID'));
}, 10000);
const checkSessionId = () => {
if (this.currentSessionId) {
clearTimeout(timeout);
resolve(this.currentSessionId);
}
else {
setTimeout(checkSessionId, 100);
}
};
checkSessionId();
});
}
/**
* Format tool use for display
*/
formatToolUse(event) {
const tool = event.name;
const args = event.input || {};
switch (tool) {
case 'Read':
return `Read: ${args.file_path}`;
case 'Bash':
return `Bash: ${args.command}`;
case 'Grep':
return `Grep: "${args.pattern}" in ${args.include || args.path || '*'}`;
case 'mcp__squabble-pm__pm_update_tasks':
const modCount = args.modifications?.length || 0;
const modTypes = args.modifications?.map((m) => m.type).join(', ') || '';
return `PM Update Tasks: ${modCount} modifications (${modTypes})`;
default:
return `${tool}: ${JSON.stringify(args)}`;
}
}
/**
* Format tool result for display
*/
formatToolResult(event) {
const toolName = event.tool || event.name;
const resultContent = event.content?.[0]?.text || event.result || '';
if (!resultContent || resultContent.trim() === '') {
return null;
}
// Don't truncate result content for logs
const preview = resultContent;
switch (toolName) {
case 'Grep':
const matches = resultContent.match(/\d+ matches?/);
if (matches)
return `Found: ${matches[0]}`;
return `Grep result: ${preview}`;
case 'Read':
const lineCount = resultContent.split('\n').length;
return `Read ${lineCount} lines from file`;
case 'LS':
const items = resultContent.split('\n').filter((l) => l.trim()).length;
return `Found ${items} items`;
case 'Bash':
// Only treat as error if it's actually a command error
if ((resultContent.includes('command not found') ||
resultContent.includes('No such file') ||
resultContent.includes('Permission denied') ||
resultContent.includes('fatal:') ||
resultContent.startsWith('Error:')) &&
resultContent.length < 500) { // Short messages are more likely to be errors
return `Error: ${resultContent}`;
}
return `Command output: ${preview}`;
default:
return `${toolName} result: ${preview}`;
}
}
/**
* Format tool result for display from content
*/
formatToolResultForDisplay(resultContent) {
// Try to extract meaningful summary
const lineMatch = resultContent.match(/(\d+) lines?/);
if (lineMatch) {
return `Read ${lineMatch[1]} lines`;
}
const matchesMatch = resultContent.match(/(\d+) matches?/);
if (matchesMatch) {
return `Found ${matchesMatch[1]} match${matchesMatch[1] === '1' ? '' : 'es'}`;
}
const itemsMatch = resultContent.match(/(\d+) items?/);
if (itemsMatch) {
return `Listed ${itemsMatch[1]} item${itemsMatch[1] === '1' ? '' : 's'}`;
}
// Only treat as error if it looks like an actual error message
if ((resultContent.includes('error:') || resultContent.includes('Error:') ||
resultContent.startsWith('Error ') || resultContent.startsWith('error ')) &&
!resultContent.includes('import ')) {
return `Error: ${resultContent}`;
}
// Return full result content
return resultContent;
}
/**
* Stop the current PM session
*/
stopCurrentSession() {
if (this.activeProcess && !this.activeProcess.killed) {
this.activeProcess.kill();
this.activeProcess = undefined;
}
}
}
//# sourceMappingURL=streaming-session-manager.js.map