/** * Claude SDK Integration * * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk. * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance * and maintainability. * * Key features: * - Direct SDK integration without child processes * - Session management with abort capability * - Options mapping between CLI and SDK formats * - WebSocket message streaming */ import { query } from '@anthropic-ai/claude-agent-sdk'; // Used to mint unique approval request IDs when randomUUID is not available. // This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees. import crypto from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { CLAUDE_MODELS } from '../shared/modelConstants.js'; // Session tracking: Map of session IDs to active query instances const activeSessions = new Map(); // In-memory registry of pending tool approvals keyed by requestId. // This does not persist approvals or share across processes; it exists so the // SDK can pause tool execution while the UI decides what to do. const pendingToolApprovals = new Map(); // Default approval timeout kept under the SDK's 60s control timeout. // This does not change SDK limits; it only defines how long we wait for the UI, // introduced to avoid hanging the run when no decision arrives. const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; // Generate a stable request ID for UI approval flows. // This does not encode tool details or get shown to users; it exists so the UI // can respond to the correct pending request without collisions. function createRequestId() { // if clause is used because randomUUID is not available in older Node.js versions if (typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return crypto.randomBytes(16).toString('hex'); } // Wait for a UI approval decision, honoring SDK cancellation. // This does not auto-approve or auto-deny; it only resolves with UI input, // and it cleans up the pending map to avoid leaks, introduced to prevent // replying after the SDK cancels the control request. function waitForToolApproval(requestId, options = {}) { const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options; return new Promise(resolve => { let settled = false; const finalize = (decision) => { if (settled) return; settled = true; cleanup(); resolve(decision); }; const cleanup = () => { pendingToolApprovals.delete(requestId); clearTimeout(timeout); if (signal && abortHandler) { signal.removeEventListener('abort', abortHandler); } }; // Timeout is local to this process; it does not override SDK timing. // It exists to prevent the UI prompt from lingering indefinitely. const timeout = setTimeout(() => { onCancel?.('timeout'); finalize(null); }, timeoutMs); const abortHandler = () => { // If the SDK cancels the control request, stop waiting to avoid // replying after the process is no longer ready for writes. onCancel?.('cancelled'); finalize({ cancelled: true }); }; if (signal) { if (signal.aborted) { onCancel?.('cancelled'); finalize({ cancelled: true }); return; } signal.addEventListener('abort', abortHandler, { once: true }); } pendingToolApprovals.set(requestId, (decision) => { finalize(decision); }); }); } // Resolve a pending approval. This does not validate the decision payload; // validation and tool matching remain in canUseTool, which keeps this as a // lightweight WebSocket -> SDK relay. function resolveToolApproval(requestId, decision) { const resolver = pendingToolApprovals.get(requestId); if (resolver) { resolver(decision); } } // Match stored permission entries against a tool + input combo. // This only supports exact tool names and the Bash(command:*) shorthand // used by the UI; it intentionally does not implement full glob semantics, // introduced to stay consistent with the UI's "Allow rule" format. function matchesToolPermission(entry, toolName, input) { if (!entry || !toolName) { return false; } if (entry === toolName) { return true; } const bashMatch = entry.match(/^Bash\((.+):\*\)$/); if (toolName === 'Bash' && bashMatch) { const allowedPrefix = bashMatch[1]; let command = ''; if (typeof input === 'string') { command = input.trim(); } else if (input && typeof input === 'object' && typeof input.command === 'string') { command = input.command.trim(); } if (!command) { return false; } return command.startsWith(allowedPrefix); } return false; } /** * Maps CLI options to SDK-compatible options format * @param {Object} options - CLI options * @returns {Object} SDK-compatible options */ function mapCliOptionsToSDK(options = {}) { const { sessionId, cwd, toolsSettings, permissionMode, images } = options; const sdkOptions = {}; // Map working directory if (cwd) { sdkOptions.cwd = cwd; } // Map permission mode if (permissionMode && permissionMode !== 'default') { sdkOptions.permissionMode = permissionMode; } // Map tool settings const settings = toolsSettings || { allowedTools: [], disallowedTools: [], skipPermissions: false }; // Handle tool permissions if (settings.skipPermissions && permissionMode !== 'plan') { // When skipping permissions, use bypassPermissions mode sdkOptions.permissionMode = 'bypassPermissions'; } // Map allowed tools (always set to avoid implicit "allow all" defaults). // This does not grant permissions by itself; it just configures the SDK, // introduced because leaving it undefined made the SDK treat it as "all tools allowed." let allowedTools = [...(settings.allowedTools || [])]; // Add plan mode default tools if (permissionMode === 'plan') { const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; for (const tool of planModeTools) { if (!allowedTools.includes(tool)) { allowedTools.push(tool); } } } sdkOptions.allowedTools = allowedTools; // Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive). // This does not override allowlists; it only feeds the canUseTool gate. sdkOptions.disallowedTools = settings.disallowedTools || []; // Map model (default to sonnet) // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT; console.log(`Using model: ${sdkOptions.model}`); // Map system prompt configuration sdkOptions.systemPrompt = { type: 'preset', preset: 'claude_code' // Required to use CLAUDE.md }; // Map setting sources for CLAUDE.md loading // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories sdkOptions.settingSources = ['project', 'user', 'local']; // Map resume session if (sessionId) { sdkOptions.resume = sessionId; } return sdkOptions; } /** * Adds a session to the active sessions map * @param {string} sessionId - Session identifier * @param {Object} queryInstance - SDK query instance * @param {Array} tempImagePaths - Temp image file paths for cleanup * @param {string} tempDir - Temp directory for cleanup */ function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) { activeSessions.set(sessionId, { instance: queryInstance, startTime: Date.now(), status: 'active', tempImagePaths, tempDir }); } /** * Removes a session from the active sessions map * @param {string} sessionId - Session identifier */ function removeSession(sessionId) { activeSessions.delete(sessionId); } /** * Gets a session from the active sessions map * @param {string} sessionId - Session identifier * @returns {Object|undefined} Session data or undefined */ function getSession(sessionId) { return activeSessions.get(sessionId); } /** * Gets all active session IDs * @returns {Array} Array of active session IDs */ function getAllSessions() { return Array.from(activeSessions.keys()); } /** * Transforms SDK messages to WebSocket format expected by frontend * @param {Object} sdkMessage - SDK message object * @returns {Object} Transformed message ready for WebSocket */ function transformMessage(sdkMessage) { // SDK messages are already in a format compatible with the frontend // The CLI sends them wrapped in {type: 'claude-response', data: message} // We'll do the same here to maintain compatibility return sdkMessage; } /** * Extracts token usage from SDK result messages * @param {Object} resultMessage - SDK result message * @returns {Object|null} Token budget object or null */ function extractTokenBudget(resultMessage) { if (resultMessage.type !== 'result' || !resultMessage.modelUsage) { return null; } // Get the first model's usage data const modelKey = Object.keys(resultMessage.modelUsage)[0]; const modelData = resultMessage.modelUsage[modelKey]; if (!modelData) { return null; } // Use cumulative tokens if available (tracks total for the session) // Otherwise fall back to per-request tokens const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0; const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0; const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0; const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0; // Total used = input + output + cache tokens const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens; // Use configured context window budget from environment (default 160000) // This is the user's budget limit, not the model's context window const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000; console.log(`Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`); return { used: totalUsed, total: contextWindow }; } /** * Handles image processing for SDK queries * Saves base64 images to temporary files and returns modified prompt with file paths * @param {string} command - Original user prompt * @param {Array} images - Array of image objects with base64 data * @param {string} cwd - Working directory for temp file creation * @returns {Promise} {modifiedCommand, tempImagePaths, tempDir} */ async function handleImages(command, images, cwd) { const tempImagePaths = []; let tempDir = null; if (!images || images.length === 0) { return { modifiedCommand: command, tempImagePaths, tempDir }; } try { // Create temp directory in the project directory const workingDir = cwd || process.cwd(); tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString()); await fs.mkdir(tempDir, { recursive: true }); // Save each image to a temp file for (const [index, image] of images.entries()) { // Extract base64 data and mime type const matches = image.data.match(/^data:([^;]+);base64,(.+)$/); if (!matches) { console.error('Invalid image data format'); continue; } const [, mimeType, base64Data] = matches; const extension = mimeType.split('/')[1] || 'png'; const filename = `image_${index}.${extension}`; const filepath = path.join(tempDir, filename); // Write base64 data to file await fs.writeFile(filepath, Buffer.from(base64Data, 'base64')); tempImagePaths.push(filepath); } // Include the full image paths in the prompt let modifiedCommand = command; if (tempImagePaths.length > 0 && command && command.trim()) { const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`; modifiedCommand = command + imageNote; } console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`); return { modifiedCommand, tempImagePaths, tempDir }; } catch (error) { console.error('Error processing images for SDK:', error); return { modifiedCommand: command, tempImagePaths, tempDir }; } } /** * Cleans up temporary image files * @param {Array} tempImagePaths - Array of temp file paths to delete * @param {string} tempDir - Temp directory to remove */ async function cleanupTempFiles(tempImagePaths, tempDir) { if (!tempImagePaths || tempImagePaths.length === 0) { return; } try { // Delete individual temp files for (const imagePath of tempImagePaths) { await fs.unlink(imagePath).catch(err => console.error(`Failed to delete temp image ${imagePath}:`, err) ); } // Delete temp directory if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }).catch(err => console.error(`Failed to delete temp directory ${tempDir}:`, err) ); } console.log(`Cleaned up ${tempImagePaths.length} temp image files`); } catch (error) { console.error('Error during temp file cleanup:', error); } } /** * Loads MCP server configurations from ~/.claude.json * @param {string} cwd - Current working directory for project-specific configs * @returns {Object|null} MCP servers object or null if none found */ async function loadMcpConfig(cwd) { try { const claudeConfigPath = path.join(os.homedir(), '.claude.json'); // Check if config file exists try { await fs.access(claudeConfigPath); } catch (error) { // File doesn't exist, return null console.log('No ~/.claude.json found, proceeding without MCP servers'); return null; } // Read and parse config file let claudeConfig; try { const configContent = await fs.readFile(claudeConfigPath, 'utf8'); claudeConfig = JSON.parse(configContent); } catch (error) { console.error('Failed to parse ~/.claude.json:', error.message); return null; } // Extract MCP servers (merge global and project-specific) let mcpServers = {}; // Add global MCP servers if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') { mcpServers = { ...claudeConfig.mcpServers }; console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`); } // Add/override with project-specific MCP servers if (claudeConfig.claudeProjects && cwd) { const projectConfig = claudeConfig.claudeProjects[cwd]; if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') { mcpServers = { ...mcpServers, ...projectConfig.mcpServers }; console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`); } } // Return null if no servers found if (Object.keys(mcpServers).length === 0) { console.log('No MCP servers configured'); return null; } console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`); return mcpServers; } catch (error) { console.error('Error loading MCP config:', error.message); return null; } } /** * Executes a Claude query using the SDK * @param {string} command - User prompt/command * @param {Object} options - Query options * @param {Object} ws - WebSocket connection * @returns {Promise} */ async function queryClaudeSDK(command, options = {}, ws) { const { sessionId } = options; let capturedSessionId = sessionId; let sessionCreatedSent = false; let tempImagePaths = []; let tempDir = null; try { // Map CLI options to SDK format const sdkOptions = mapCliOptionsToSDK(options); // Load MCP configuration const mcpServers = await loadMcpConfig(options.cwd); if (mcpServers) { sdkOptions.mcpServers = mcpServers; } // Handle images - save to temp files and modify prompt const imageResult = await handleImages(command, options.images, options.cwd); const finalCommand = imageResult.modifiedCommand; tempImagePaths = imageResult.tempImagePaths; tempDir = imageResult.tempDir; // Gate tool usage with explicit UI approval when not auto-approved. // This does not render UI or persist permissions; it only bridges to the UI // via WebSocket and waits for the response, introduced so tool calls pause // instead of auto-running when the allowlist is empty. sdkOptions.canUseTool = async (toolName, input, context) => { if (sdkOptions.permissionMode === 'bypassPermissions') { return { behavior: 'allow', updatedInput: input }; } const isDisallowed = (sdkOptions.disallowedTools || []).some(entry => matchesToolPermission(entry, toolName, input) ); if (isDisallowed) { return { behavior: 'deny', message: 'Tool disallowed by settings' }; } const isAllowed = (sdkOptions.allowedTools || []).some(entry => matchesToolPermission(entry, toolName, input) ); if (isAllowed) { return { behavior: 'allow', updatedInput: input }; } const requestId = createRequestId(); ws.send({ type: 'claude-permission-request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null }); // Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner. // This does not retry or resurface the prompt; it just reflects the cancellation. const decision = await waitForToolApproval(requestId, { signal: context?.signal, onCancel: (reason) => { ws.send({ type: 'claude-permission-cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null }); } }); if (!decision) { return { behavior: 'deny', message: 'Permission request timed out' }; } if (decision.cancelled) { return { behavior: 'deny', message: 'Permission request cancelled' }; } if (decision.allow) { // rememberEntry only updates this run's in-memory allowlist to prevent // repeated prompts in the same session; persistence is handled by the UI. if (decision.rememberEntry && typeof decision.rememberEntry === 'string') { if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) { sdkOptions.allowedTools.push(decision.rememberEntry); } if (Array.isArray(sdkOptions.disallowedTools)) { sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry); } } return { behavior: 'allow', updatedInput: decision.updatedInput ?? input }; } return { behavior: 'deny', message: decision.message ?? 'User denied tool use' }; }; // Create SDK query instance const queryInstance = query({ prompt: finalCommand, options: sdkOptions }); // Track the query instance for abort capability if (capturedSessionId) { addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); } // Process streaming messages console.log('Starting async generator loop for session:', capturedSessionId || 'NEW'); for await (const message of queryInstance) { // Capture session ID from first message if (message.session_id && !capturedSessionId) { capturedSessionId = message.session_id; addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); // Set session ID on writer 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 }); } else { console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent); } } else { console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId); } // Transform and send message to WebSocket const transformedMessage = transformMessage(message); ws.send({ type: 'claude-response', data: transformedMessage }); // Extract and send token budget updates from result messages if (message.type === 'result') { const tokenBudget = extractTokenBudget(message); if (tokenBudget) { console.log('Token budget from modelUsage:', tokenBudget); ws.send({ type: 'token-budget', data: tokenBudget }); } } } // Clean up session on completion if (capturedSessionId) { removeSession(capturedSessionId); } // Clean up temporary image files await cleanupTempFiles(tempImagePaths, tempDir); // Send completion event console.log('Streaming complete, sending claude-complete event'); ws.send({ type: 'claude-complete', sessionId: capturedSessionId, exitCode: 0, isNewSession: !sessionId && !!command }); console.log('claude-complete event sent'); } catch (error) { console.error('SDK query error:', error); // Clean up session on error if (capturedSessionId) { removeSession(capturedSessionId); } // Clean up temporary image files on error await cleanupTempFiles(tempImagePaths, tempDir); // Send error to WebSocket ws.send({ type: 'claude-error', error: error.message }); throw error; } } /** * Aborts an active SDK session * @param {string} sessionId - Session identifier * @returns {boolean} True if session was aborted, false if not found */ async function abortClaudeSDKSession(sessionId) { const session = getSession(sessionId); if (!session) { console.log(`Session ${sessionId} not found`); return false; } try { console.log(`Aborting SDK session: ${sessionId}`); // Call interrupt() on the query instance await session.instance.interrupt(); // Update session status session.status = 'aborted'; // Clean up temporary image files await cleanupTempFiles(session.tempImagePaths, session.tempDir); // Clean up session removeSession(sessionId); return true; } catch (error) { console.error(`Error aborting session ${sessionId}:`, error); return false; } } /** * Checks if an SDK session is currently active * @param {string} sessionId - Session identifier * @returns {boolean} True if session is active */ function isClaudeSDKSessionActive(sessionId) { const session = getSession(sessionId); return session && session.status === 'active'; } /** * Gets all active SDK session IDs * @returns {Array} Array of active session IDs */ function getActiveClaudeSDKSessions() { return getAllSessions(); } // Export public API export { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval };