mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-23 09:57:32 +00:00
722 lines
23 KiB
JavaScript
722 lines
23 KiB
JavaScript
/**
|
|
* 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<string>} 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<string>} 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<Object>} {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<string>} 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<void>}
|
|
*/
|
|
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<string>} Array of active session IDs
|
|
*/
|
|
function getActiveClaudeSDKSessions() {
|
|
return getAllSessions();
|
|
}
|
|
|
|
// Export public API
|
|
export {
|
|
queryClaudeSDK,
|
|
abortClaudeSDKSession,
|
|
isClaudeSDKSessionActive,
|
|
getActiveClaudeSDKSessions,
|
|
resolveToolApproval
|
|
};
|