mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-13 01:47:24 +00:00
343 lines
11 KiB
JavaScript
343 lines
11 KiB
JavaScript
import { spawn } from 'child_process';
|
|
import crossSpawn from 'cross-spawn';
|
|
|
|
// Use cross-spawn on Windows for better command execution
|
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
|
|
|
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
|
|
|
const WORKSPACE_TRUST_PATTERNS = [
|
|
/workspace trust required/i,
|
|
/do you trust the contents of this directory/i,
|
|
/working with untrusted contents/i,
|
|
/pass --trust,\s*--yolo,\s*or -f/i
|
|
];
|
|
|
|
function isWorkspaceTrustPrompt(text = '') {
|
|
if (!text || typeof text !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
|
|
}
|
|
|
|
async function spawnCursor(command, options = {}, ws) {
|
|
return new Promise(async (resolve, reject) => {
|
|
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
|
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
|
let messageBuffer = ''; // Buffer for accumulating assistant messages
|
|
let hasRetriedWithTrust = false;
|
|
let settled = false;
|
|
|
|
// Use tools settings passed from frontend, or defaults
|
|
const settings = toolsSettings || {
|
|
allowedShellCommands: [],
|
|
skipPermissions: false
|
|
};
|
|
|
|
// Build Cursor CLI command
|
|
const baseArgs = [];
|
|
|
|
// Build flags allowing both resume and prompt together (reply in existing session)
|
|
// Treat presence of sessionId as intention to resume, regardless of resume flag
|
|
if (sessionId) {
|
|
baseArgs.push('--resume=' + sessionId);
|
|
}
|
|
|
|
if (command && command.trim()) {
|
|
// Provide a prompt (works for both new and resumed sessions)
|
|
baseArgs.push('-p', command);
|
|
|
|
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
|
if (!sessionId && model) {
|
|
baseArgs.push('--model', model);
|
|
}
|
|
|
|
// Request streaming JSON when we are providing a prompt
|
|
baseArgs.push('--output-format', 'stream-json');
|
|
}
|
|
|
|
// Add skip permissions flag if enabled
|
|
if (skipPermissions || settings.skipPermissions) {
|
|
baseArgs.push('-f');
|
|
console.log('Using -f flag (skip permissions)');
|
|
}
|
|
|
|
// Use cwd (actual project directory) instead of projectPath
|
|
const workingDir = cwd || projectPath || process.cwd();
|
|
|
|
// Store process reference for potential abort
|
|
const processKey = capturedSessionId || Date.now().toString();
|
|
|
|
const settleOnce = (callback) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
callback();
|
|
};
|
|
|
|
const runCursorProcess = (args, runReason = 'initial') => {
|
|
const isTrustRetry = runReason === 'trust-retry';
|
|
let runSawWorkspaceTrustPrompt = false;
|
|
|
|
if (isTrustRetry) {
|
|
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
|
|
}
|
|
|
|
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
|
|
console.log('Working directory:', workingDir);
|
|
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
|
|
|
const cursorProcess = spawnFunction('cursor-agent', args, {
|
|
cwd: workingDir,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env } // Inherit all environment variables
|
|
});
|
|
|
|
activeCursorProcesses.set(processKey, cursorProcess);
|
|
|
|
const shouldSuppressForTrustRetry = (text) => {
|
|
if (hasRetriedWithTrust || args.includes('--trust')) {
|
|
return false;
|
|
}
|
|
if (!isWorkspaceTrustPrompt(text)) {
|
|
return false;
|
|
}
|
|
|
|
runSawWorkspaceTrustPrompt = true;
|
|
return true;
|
|
};
|
|
|
|
// Handle stdout (streaming JSON responses)
|
|
cursorProcess.stdout.on('data', (data) => {
|
|
const rawOutput = data.toString();
|
|
console.log('Cursor CLI stdout:', rawOutput);
|
|
|
|
const lines = rawOutput.split('\n').filter((line) => line.trim());
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const response = JSON.parse(line);
|
|
console.log('Parsed JSON response:', response);
|
|
|
|
// Handle different message types
|
|
switch (response.type) {
|
|
case 'system':
|
|
if (response.subtype === 'init') {
|
|
// Capture session ID
|
|
if (response.session_id && !capturedSessionId) {
|
|
capturedSessionId = response.session_id;
|
|
console.log('Captured session ID:', capturedSessionId);
|
|
|
|
// Update process key with captured session ID
|
|
if (processKey !== capturedSessionId) {
|
|
activeCursorProcesses.delete(processKey);
|
|
activeCursorProcesses.set(capturedSessionId, cursorProcess);
|
|
}
|
|
|
|
// Set session ID on writer (for API endpoint compatibility)
|
|
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
|
ws.setSessionId(capturedSessionId);
|
|
}
|
|
|
|
// Send session-created event only once for new sessions
|
|
if (!sessionId && !sessionCreatedSent) {
|
|
sessionCreatedSent = true;
|
|
ws.send({
|
|
type: 'session-created',
|
|
sessionId: capturedSessionId,
|
|
model: response.model,
|
|
cwd: response.cwd
|
|
});
|
|
}
|
|
}
|
|
|
|
// Send system info to frontend
|
|
ws.send({
|
|
type: 'cursor-system',
|
|
data: response,
|
|
sessionId: capturedSessionId || sessionId || null
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'user':
|
|
// Forward user message
|
|
ws.send({
|
|
type: 'cursor-user',
|
|
data: response,
|
|
sessionId: capturedSessionId || sessionId || null
|
|
});
|
|
break;
|
|
|
|
case 'assistant':
|
|
// Accumulate assistant message chunks
|
|
if (response.message && response.message.content && response.message.content.length > 0) {
|
|
const textContent = response.message.content[0].text;
|
|
messageBuffer += textContent;
|
|
|
|
// Send as Claude-compatible format for frontend
|
|
ws.send({
|
|
type: 'claude-response',
|
|
data: {
|
|
type: 'content_block_delta',
|
|
delta: {
|
|
type: 'text_delta',
|
|
text: textContent
|
|
}
|
|
},
|
|
sessionId: capturedSessionId || sessionId || null
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'result':
|
|
// Session complete
|
|
console.log('Cursor session result:', response);
|
|
|
|
// Send final message if we have buffered content
|
|
if (messageBuffer) {
|
|
ws.send({
|
|
type: 'claude-response',
|
|
data: {
|
|
type: 'content_block_stop'
|
|
},
|
|
sessionId: capturedSessionId || sessionId || null
|
|
});
|
|
}
|
|
|
|
// Send completion event
|
|
ws.send({
|
|
type: 'cursor-result',
|
|
sessionId: capturedSessionId || sessionId,
|
|
data: response,
|
|
success: response.subtype === 'success'
|
|
});
|
|
break;
|
|
|
|
default:
|
|
// Forward any other message types
|
|
ws.send({
|
|
type: 'cursor-response',
|
|
data: response,
|
|
sessionId: capturedSessionId || sessionId || null
|
|
});
|
|
}
|
|
} catch (parseError) {
|
|
console.log('Non-JSON response:', line);
|
|
|
|
if (shouldSuppressForTrustRetry(line)) {
|
|
continue;
|
|
}
|
|
|
|
// If not JSON, send as raw text
|
|
ws.send({
|
|
type: 'cursor-output',
|
|
data: line,
|
|
sessionId: capturedSessionId || sessionId || null
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle stderr
|
|
cursorProcess.stderr.on('data', (data) => {
|
|
const stderrText = data.toString();
|
|
console.error('Cursor CLI stderr:', stderrText);
|
|
|
|
if (shouldSuppressForTrustRetry(stderrText)) {
|
|
return;
|
|
}
|
|
|
|
ws.send({
|
|
type: 'cursor-error',
|
|
error: stderrText,
|
|
sessionId: capturedSessionId || sessionId || null
|
|
});
|
|
});
|
|
|
|
// Handle process completion
|
|
cursorProcess.on('close', async (code) => {
|
|
console.log(`Cursor CLI process exited with code ${code}`);
|
|
|
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
|
activeCursorProcesses.delete(finalSessionId);
|
|
|
|
if (
|
|
runSawWorkspaceTrustPrompt &&
|
|
code !== 0 &&
|
|
!hasRetriedWithTrust &&
|
|
!args.includes('--trust')
|
|
) {
|
|
hasRetriedWithTrust = true;
|
|
runCursorProcess([...args, '--trust'], 'trust-retry');
|
|
return;
|
|
}
|
|
|
|
ws.send({
|
|
type: 'claude-complete',
|
|
sessionId: finalSessionId,
|
|
exitCode: code,
|
|
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
|
});
|
|
|
|
if (code === 0) {
|
|
settleOnce(() => resolve());
|
|
} else {
|
|
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
|
|
}
|
|
});
|
|
|
|
// Handle process errors
|
|
cursorProcess.on('error', (error) => {
|
|
console.error('Cursor CLI process error:', error);
|
|
|
|
// Clean up process reference on error
|
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
|
activeCursorProcesses.delete(finalSessionId);
|
|
|
|
ws.send({
|
|
type: 'cursor-error',
|
|
error: error.message,
|
|
sessionId: capturedSessionId || sessionId || null
|
|
});
|
|
|
|
settleOnce(() => reject(error));
|
|
});
|
|
|
|
// Close stdin since Cursor doesn't need interactive input
|
|
cursorProcess.stdin.end();
|
|
};
|
|
|
|
runCursorProcess(baseArgs, 'initial');
|
|
});
|
|
}
|
|
|
|
function abortCursorSession(sessionId) {
|
|
const process = activeCursorProcesses.get(sessionId);
|
|
if (process) {
|
|
console.log(`Aborting Cursor session: ${sessionId}`);
|
|
process.kill('SIGTERM');
|
|
activeCursorProcesses.delete(sessionId);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isCursorSessionActive(sessionId) {
|
|
return activeCursorProcesses.has(sessionId);
|
|
}
|
|
|
|
function getActiveCursorSessions() {
|
|
return Array.from(activeCursorProcesses.keys());
|
|
}
|
|
|
|
export {
|
|
spawnCursor,
|
|
abortCursorSession,
|
|
isCursorSessionActive,
|
|
getActiveCursorSessions
|
|
};
|