UNPKG

mcp-chrome-bridge

Version:

Chrome Native-Messaging host (Node)

917 lines 69.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ClaudeEngine = void 0; const node_crypto_1 = require("node:crypto"); const node_path_1 = __importDefault(require("node:path")); const ccr_detector_1 = require("../ccr-detector"); const project_service_1 = require("../project-service"); const constant_1 = require("../../constant"); /** * Map of tool names to their corresponding actions. */ const TOOL_NAME_ACTION_MAP = { read: 'Read', read_file: 'Read', write: 'Created', write_file: 'Created', create_file: 'Created', edit: 'Edited', edit_file: 'Edited', apply_patch: 'Edited', patch_file: 'Edited', remove_file: 'Deleted', delete_file: 'Deleted', list_files: 'Searched', glob: 'Searched', glob_files: 'Searched', search_files: 'Searched', grep: 'Searched', bash: 'Executed', run: 'Executed', shell: 'Executed', todo_write: 'Generated', plan_write: 'Generated', }; /** * ClaudeEngine integrates the Claude Agent SDK as an AgentEngine implementation. * * This engine uses the @anthropic-ai/claude-agent-sdk to interact with Claude, * streaming events back to the sidepanel UI via RealtimeEvent envelopes. */ class ClaudeEngine { constructor() { this.name = 'claude'; this.supportsMcp = true; } async initializeAndRun(options, ctx) { var _a; const { sessionId, instruction, model, projectRoot, requestId, signal, attachments, resolvedImagePaths, projectId, permissionMode, allowDangerouslySkipPermissions, systemPromptConfig, optionsConfig, resumeClaudeSessionId, useCcr, } = options; const repoPath = this.resolveRepoPath(projectRoot); // Check if already aborted if (signal === null || signal === void 0 ? void 0 : signal.aborted) { throw new Error('ClaudeEngine: execution was cancelled'); } const normalizedInstruction = instruction.trim(); if (!normalizedInstruction) { throw new Error('ClaudeEngine: instruction must not be empty'); } // Dynamically import the Claude Agent SDK // Images are passed via temp file paths appended to the prompt string let query; try { // Dynamic import to avoid hard dependency - install @anthropic-ai/claude-agent-sdk to use this engine // Use string variable to bypass TypeScript module resolution const sdkModuleName = '@anthropic-ai/claude-agent-sdk'; const sdk = await Function('moduleName', 'return import(moduleName)')(sdkModuleName); query = sdk.query; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`ClaudeEngine: Failed to load Claude Agent SDK. Please install @anthropic-ai/claude-agent-sdk. Error: ${message}`); } // Resolve model const resolvedModel = (model === null || model === void 0 ? void 0 : model.trim()) || process.env.CLAUDE_DEFAULT_MODEL || 'claude-sonnet-4-20250514'; // State management const stderrBuffer = []; let assistantBuffer = ''; let assistantMessageId = null; let assistantCreatedAt = null; let lastAssistantEmitted = null; const streamedToolHashes = new Set(); // Tool input accumulation for streaming tool_use blocks // Key: content block index, Value: { toolName, toolId, inputJson } const pendingToolInputs = new Map(); let currentContentBlockIndex = -1; /** * Emit assistant message to the stream. * Includes deduplication to prevent multiple identical final emissions. */ const emitAssistant = (isFinal) => { const content = assistantBuffer.trim(); if (!content) return; // Deduplicate: skip if same content and isFinal state was already emitted if (lastAssistantEmitted && lastAssistantEmitted.content === content && lastAssistantEmitted.isFinal === isFinal) { return; } lastAssistantEmitted = { content, isFinal }; if (!assistantMessageId) { assistantMessageId = (0, node_crypto_1.randomUUID)(); } if (!assistantCreatedAt) { assistantCreatedAt = new Date().toISOString(); } const message = { id: assistantMessageId, sessionId, role: 'assistant', content, messageType: 'chat', cliSource: this.name, requestId, isStreaming: !isFinal, isFinal, createdAt: assistantCreatedAt, }; ctx.emit({ type: 'message', data: message }); }; /** * Emit tool message with deduplication. */ const dispatchToolMessage = (content, metadata, messageType, isStreaming) => { const trimmed = content.trim(); if (!trimmed) return; const hash = this.encodeHash(`${messageType}:${trimmed}:${JSON.stringify(metadata)}:${sessionId}:${requestId || ''}`).slice(0, 16); if (streamedToolHashes.has(hash)) return; streamedToolHashes.add(hash); const message = { id: (0, node_crypto_1.randomUUID)(), sessionId, role: 'tool', content: trimmed, messageType, cliSource: this.name, requestId, isStreaming, isFinal: !isStreaming, createdAt: new Date().toISOString(), metadata: { cli_type: 'claude', ...metadata }, }; ctx.emit({ type: 'message', data: message }); }; /** * Infer tool action from tool name. */ const inferActionFromToolName = (toolName) => { var _a; if (typeof toolName !== 'string') return undefined; const normalized = toolName.trim().toLowerCase(); if (!normalized) return undefined; if (TOOL_NAME_ACTION_MAP[normalized]) { return TOOL_NAME_ACTION_MAP[normalized]; } // Try suffix after colon (e.g., "mcp__server__tool" -> "tool") const suffix = (_a = normalized.split(':').pop()) !== null && _a !== void 0 ? _a : normalized; if (suffix && TOOL_NAME_ACTION_MAP[suffix]) { return TOOL_NAME_ACTION_MAP[suffix]; } // Infer from name patterns if (normalized.includes('edit') || normalized.includes('modify') || normalized.includes('patch')) { return 'Edited'; } if (normalized.includes('write') || normalized.includes('create')) { return 'Created'; } if (normalized.includes('read') || normalized.includes('view')) { return 'Read'; } if (normalized.includes('delete') || normalized.includes('remove')) { return 'Deleted'; } if (normalized.includes('search') || normalized.includes('find') || normalized.includes('glob') || normalized.includes('grep')) { return 'Searched'; } if (normalized.includes('bash') || normalized.includes('shell') || normalized.includes('exec')) { return 'Executed'; } if (normalized.includes('todo') || normalized.includes('plan')) { return 'Generated'; } return undefined; }; /** * Build tool metadata from content block with detailed tool-specific information. */ const buildToolMetadata = (contentBlock) => { const toolName = this.pickFirstString(contentBlock.name) || 'unknown'; const toolId = this.pickFirstString(contentBlock.id); const input = contentBlock.input; const action = inferActionFromToolName(toolName); const metadata = { toolName, tool_name: toolName, toolId, action, }; if (!input) { return metadata; } // Extract tool-specific details const normalizedName = toolName.toLowerCase(); // File operations (read, write, edit) if (typeof input.file_path === 'string') { metadata.filePath = input.file_path; } // Edit tool - extract diff information if (normalizedName.includes('edit') || normalizedName === 'apply_patch' || normalizedName === 'patch_file') { if (typeof input.old_string === 'string') { metadata.oldString = input.old_string; metadata.deletedLines = input.old_string.split('\n').length; } if (typeof input.new_string === 'string') { metadata.newString = input.new_string; metadata.addedLines = input.new_string.split('\n').length; } if (typeof input.replace_all === 'boolean') { metadata.replaceAll = input.replace_all; } } // Write tool - content preview if (normalizedName.includes('write') || normalizedName === 'create_file') { if (typeof input.content === 'string') { metadata.contentPreview = input.content.slice(0, 200); metadata.totalLines = input.content.split('\n').length; } } // Read tool - offset/limit if (normalizedName.includes('read')) { if (typeof input.offset === 'number') metadata.offset = input.offset; if (typeof input.limit === 'number') metadata.limit = input.limit; } // Bash/shell - command if (normalizedName === 'bash' || normalizedName.includes('shell') || normalizedName === 'run') { if (typeof input.command === 'string') { metadata.command = input.command; } if (typeof input.description === 'string') { metadata.commandDescription = input.description; } } // Search tools (grep, glob) if (normalizedName === 'grep' || normalizedName.includes('search')) { if (typeof input.pattern === 'string') metadata.pattern = input.pattern; if (typeof input.path === 'string') metadata.searchPath = input.path; if (typeof input.glob === 'string') metadata.glob = input.glob; if (typeof input.output_mode === 'string') metadata.outputMode = input.output_mode; } if (normalizedName === 'glob' || normalizedName === 'glob_files') { if (typeof input.pattern === 'string') metadata.pattern = input.pattern; if (typeof input.path === 'string') metadata.searchPath = input.path; } // TodoWrite if (normalizedName === 'todo_write' || normalizedName === 'todowrite') { if (Array.isArray(input.todos)) { metadata.todoCount = input.todos.length; metadata.todos = input.todos; } } // Store raw input for debugging (truncated) metadata.rawInput = JSON.stringify(input).slice(0, 1000); return metadata; }; // State for temp file cleanup const tempFiles = []; const cleanupTempFiles = async () => { if (tempFiles.length === 0) return; try { const fs = await import('node:fs/promises'); for (const filePath of tempFiles) { try { await fs.unlink(filePath); console.error(`[ClaudeEngine] Cleaned up temp file: ${filePath}`); } catch (err) { // Best-effort cleanup; ignore failures (file may already be deleted) console.error(`[ClaudeEngine] Failed to cleanup temp file ${filePath}:`, err); } } } catch (err) { console.error('[ClaudeEngine] Failed to cleanup temp files:', err); } }; // Build prompt instruction (may be modified if images are attached) let promptInstruction = normalizedInstruction; try { // Use console.error for logging to avoid polluting stdout (Native Messaging protocol) console.error(`[ClaudeEngine] Starting query with model: ${resolvedModel}`); console.error(`[ClaudeEngine] Working directory: ${repoPath}`); // Check for image attachments - prefer resolvedImagePaths (persisted), fallback to temp files const hasResolvedPaths = resolvedImagePaths && resolvedImagePaths.length > 0; const imageAttachments = (attachments !== null && attachments !== void 0 ? attachments : []).filter((a) => a.type === 'image'); const hasImages = hasResolvedPaths || imageAttachments.length > 0; if (hasImages) { // Strip any legacy "Image #N path:" lines to avoid duplicating references const instructionWithoutLegacyPaths = normalizedInstruction .replace(/\n*Image #\d+ path: [^\n]+/g, '') .trim(); const imageLines = []; if (hasResolvedPaths) { // Use pre-resolved persistent paths (preferred - no temp files needed) console.error(`[ClaudeEngine] Using ${resolvedImagePaths.length} pre-resolved image path(s)`); for (let index = 0; index < resolvedImagePaths.length; index++) { imageLines.push(`Image #${index + 1} path: ${resolvedImagePaths[index]}`); } } else { // Fallback: write base64 to temp files (legacy behavior) console.error(`[ClaudeEngine] Writing ${imageAttachments.length} image attachment(s) to temp files (fallback)`); for (let index = 0; index < imageAttachments.length; index++) { const attachment = imageAttachments[index]; const tempFilePath = await this.writeAttachmentToTemp(attachment); tempFiles.push(tempFilePath); imageLines.push(`Image #${index + 1} path: ${tempFilePath}`); } } // Build final instruction with image paths appended promptInstruction = [instructionWithoutLegacyPaths, imageLines.join('\n')] .filter((segment) => segment && segment.trim().length > 0) .join('\n\n') .trim(); console.error(`[ClaudeEngine] Prompt with image paths: ${promptInstruction.slice(0, 200)}...`); } // Start Claude Agent SDK query // Session resumption: if resumeClaudeSessionId is provided (from sessions.engineSessionId or legacy project), // pass it as 'resume' to continue a previous Claude conversation. // If not provided, SDK will create a new session. // Build environment for Claude Code Router support // SDK treats options.env as a complete replacement, so we must merge with process.env // Reference: https://github.com/musistudio/claude-code-router/issues/855 const claudeEnv = await this.buildClaudeEnv(useCcr); // Validate CCR configuration and emit friendly warning before calling into CCR // This prevents users from seeing cryptic "includes of undefined" errors if (useCcr) { await this.validateAndWarnCcrConfig(sessionId, requestId, ctx); } // Resolve permission mode from session config or use default // SDK default is 'default', but AgentChat defaults to 'bypassPermissions' for headless operation const allowedPermissionModes = new Set([ 'default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk', ]); const normalizedPermissionMode = typeof permissionMode === 'string' ? permissionMode.trim() : ''; let resolvedPermissionMode; if (normalizedPermissionMode === '') { // No permission mode specified - use AgentChat default for headless operation resolvedPermissionMode = 'bypassPermissions'; } else if (allowedPermissionModes.has(normalizedPermissionMode)) { // Valid permission mode - use as specified resolvedPermissionMode = normalizedPermissionMode; } else { // Invalid permission mode - fall back to SDK default and warn console.error(`[ClaudeEngine] Invalid permissionMode "${normalizedPermissionMode}", falling back to SDK default "default"`); resolvedPermissionMode = 'default'; } // allowDangerouslySkipPermissions must be true when using bypassPermissions mode // SDK requirement: bypass mode requires explicit acknowledgment via allowDangerouslySkipPermissions=true const resolvedAllowDangerouslySkipPermissions = (() => { const explicitValue = typeof allowDangerouslySkipPermissions === 'boolean' ? allowDangerouslySkipPermissions : undefined; if (resolvedPermissionMode === 'bypassPermissions') { // Force true for bypassPermissions mode - SDK requirement if (explicitValue === false) { console.error('[ClaudeEngine] Warning: allowDangerouslySkipPermissions=false is incompatible with bypassPermissions mode, forcing to true'); } return true; } // For non-bypass modes, use explicit value or default to false return explicitValue !== null && explicitValue !== void 0 ? explicitValue : false; })(); // Parse optionsConfig for additional SDK options const optionsRecord = optionsConfig && typeof optionsConfig === 'object' && !Array.isArray(optionsConfig) ? optionsConfig : undefined; // Resolve project-scoped Chrome MCP toggle (default: enabled) const enableChromeMcp = await (async () => { if (!projectId) return true; try { const project = await (0, project_service_1.getProject)(projectId); return (project === null || project === void 0 ? void 0 : project.enableChromeMcp) !== false; } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`[ClaudeEngine] Failed to load project enableChromeMcp, defaulting to enabled: ${message}`); return true; } })(); // Resolve setting sources // SDK isolation mode: settingSources=[] prevents loading any filesystem settings // Default behavior: include 'project' to load CLAUDE.md const resolvedSettingSources = (() => { const allowedSettingSources = new Set(['user', 'project', 'local']); const raw = optionsRecord === null || optionsRecord === void 0 ? void 0 : optionsRecord.settingSources; // Check for explicit isolation mode (empty array) if (Array.isArray(raw) && raw.length === 0) { console.error('[ClaudeEngine] Isolation mode enabled: settingSources=[]'); return []; } // Parse provided sources if (Array.isArray(raw)) { const sources = []; for (const entry of raw) { if (typeof entry === 'string' && allowedSettingSources.has(entry)) { sources.push(entry); } } // If valid sources were provided, use them as-is (trust user config) if (sources.length > 0) { return sources; } } // Default: include 'project' to load CLAUDE.md return ['project']; })(); // Resolve system prompt from session config const resolvedSystemPrompt = (() => { if (typeof systemPromptConfig === 'string') { const trimmed = systemPromptConfig.trim(); return trimmed.length > 0 ? trimmed : undefined; } if (!systemPromptConfig || typeof systemPromptConfig !== 'object' || Array.isArray(systemPromptConfig)) { return undefined; } const record = systemPromptConfig; const type = record.type; if (type === 'custom' && typeof record.text === 'string') { const trimmed = record.text.trim(); return trimmed.length > 0 ? trimmed : undefined; } if (type === 'preset' && record.preset === 'claude_code') { // Trim append and ignore empty strings to avoid "append is empty but object is passed" edge case const rawAppend = typeof record.append === 'string' ? record.append.trim() : ''; const append = rawAppend.length > 0 ? rawAppend : undefined; return append ? { type: 'preset', preset: 'claude_code', append } : { type: 'preset', preset: 'claude_code' }; } return undefined; })(); // Create internal AbortController that mirrors the external signal // SDK expects abortController option, not raw AbortSignal const internalAbortController = new AbortController(); if (signal) { // Propagate external abort to internal controller if (signal.aborted) { internalAbortController.abort(); } else { signal.addEventListener('abort', () => { internalAbortController.abort(); }, { once: true }); } } const queryOptions = { cwd: repoPath, additionalDirectories: [repoPath], model: resolvedModel, // Permission settings are session-configurable (defaults preserve previous behavior) permissionMode: resolvedPermissionMode, allowDangerouslySkipPermissions: resolvedAllowDangerouslySkipPermissions, // Enable streaming: emit stream_event with content_block_delta for real-time UI updates // Without this, SDK only outputs aggregated assistant/result messages includePartialMessages: true, // Load CLAUDE.md / .claude/settings.json from the project root settingSources: resolvedSettingSources, // Custom system prompt if provided systemPrompt: resolvedSystemPrompt, // AbortController for cancellation support - SDK uses this to terminate underlying processes abortController: internalAbortController, // Pass merged env to support Claude Code Router (CCR) // This allows users to set ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN via: // 1. eval "$(ccr activate)" before launching Chrome // 2. Or setting env vars in shell profile env: claudeEnv, stderr: (data) => { const line = String(data).trimEnd(); if (!line) return; if (stderrBuffer.length > ClaudeEngine.MAX_STDERR_LINES) { stderrBuffer.shift(); } stderrBuffer.push(line); console.error(`[ClaudeEngine][stderr] ${line}`); }, }; // Apply additional SDK options from optionsConfig if (optionsRecord) { const isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string'); if (isStringArray(optionsRecord.allowedTools)) { queryOptions.allowedTools = optionsRecord.allowedTools; } if (isStringArray(optionsRecord.disallowedTools)) { queryOptions.disallowedTools = optionsRecord.disallowedTools; } const tools = optionsRecord.tools; if (isStringArray(tools)) { queryOptions.tools = tools; } else if (tools && typeof tools === 'object' && !Array.isArray(tools)) { const toolsRecord = tools; if (toolsRecord.type === 'preset' && toolsRecord.preset === 'claude_code') { queryOptions.tools = { type: 'preset', preset: 'claude_code' }; } } if (isStringArray(optionsRecord.betas)) { queryOptions.betas = optionsRecord.betas; } if (typeof optionsRecord.maxThinkingTokens === 'number' && Number.isFinite(optionsRecord.maxThinkingTokens)) { queryOptions.maxThinkingTokens = optionsRecord.maxThinkingTokens; } if (typeof optionsRecord.maxTurns === 'number' && Number.isFinite(optionsRecord.maxTurns)) { queryOptions.maxTurns = optionsRecord.maxTurns; } if (typeof optionsRecord.maxBudgetUsd === 'number' && Number.isFinite(optionsRecord.maxBudgetUsd)) { queryOptions.maxBudgetUsd = optionsRecord.maxBudgetUsd; } if (optionsRecord.mcpServers && typeof optionsRecord.mcpServers === 'object' && !Array.isArray(optionsRecord.mcpServers)) { queryOptions.mcpServers = optionsRecord.mcpServers; } if (optionsRecord.outputFormat && typeof optionsRecord.outputFormat === 'object' && !Array.isArray(optionsRecord.outputFormat)) { queryOptions.outputFormat = optionsRecord.outputFormat; } if (typeof optionsRecord.enableFileCheckpointing === 'boolean') { queryOptions.enableFileCheckpointing = optionsRecord.enableFileCheckpointing; } if (optionsRecord.sandbox && typeof optionsRecord.sandbox === 'object' && !Array.isArray(optionsRecord.sandbox)) { queryOptions.sandbox = optionsRecord.sandbox; } // Merge session-level env overrides with base claudeEnv // Session env takes precedence over process env (useful for per-session API keys, etc.) if (optionsRecord.env && typeof optionsRecord.env === 'object' && !Array.isArray(optionsRecord.env)) { const sessionEnv = optionsRecord.env; const mergedEnv = { ...claudeEnv }; for (const [key, value] of Object.entries(sessionEnv)) { if (typeof value === 'string') { mergedEnv[key] = value; } } // Ensure Node.js bin directory is still in PATH after merge // Session may have overwritten PATH, which would break child processes const nodeBinDir = node_path_1.default.dirname(process.execPath); const mergedPath = mergedEnv.PATH || mergedEnv.Path || ''; if (!mergedPath.includes(nodeBinDir)) { mergedEnv.PATH = [nodeBinDir, mergedPath].filter(Boolean).join(node_path_1.default.delimiter); } queryOptions.env = mergedEnv; } } // Inject the local Chrome MCP server based on project preference. // This only controls the built-in "chrome-mcp" entry; user-configured MCP servers remain untouched. const CHROME_MCP_SERVER_NAME = 'chrome-mcp'; if (enableChromeMcp) { const existingMcpServers = queryOptions.mcpServers && typeof queryOptions.mcpServers === 'object' && !Array.isArray(queryOptions.mcpServers) ? queryOptions.mcpServers : {}; queryOptions.mcpServers = { ...existingMcpServers, [CHROME_MCP_SERVER_NAME]: { type: 'http', url: (0, constant_1.getChromeMcpUrl)(), }, }; console.error(`[ClaudeEngine] Chrome MCP server enabled: ${(0, constant_1.getChromeMcpUrl)()}`); } else if (queryOptions.mcpServers && typeof queryOptions.mcpServers === 'object' && !Array.isArray(queryOptions.mcpServers)) { // If Chrome MCP is disabled, remove it from existing mcpServers if present const existing = queryOptions.mcpServers; if (CHROME_MCP_SERVER_NAME in existing) { const { [CHROME_MCP_SERVER_NAME]: _removed, ...rest } = existing; if (Object.keys(rest).length > 0) { queryOptions.mcpServers = rest; } else { delete queryOptions.mcpServers; } } console.error('[ClaudeEngine] Chrome MCP server disabled'); } // Add resume option if we have a valid Claude session ID if (resumeClaudeSessionId) { queryOptions.resume = resumeClaudeSessionId; console.error(`[ClaudeEngine] Resuming Claude session: ${resumeClaudeSessionId}`); } const response = query({ prompt: promptInstruction, options: queryOptions, }); // Process streaming response for await (const message of response) { // Check for cancellation before processing each message if (signal === null || signal === void 0 ? void 0 : signal.aborted) { console.error('[ClaudeEngine] Execution cancelled via abort signal'); throw new Error('ClaudeEngine: execution was cancelled'); } console.error('[ClaudeEngine] Message type:', message.type); if (message.type === 'stream_event') { const event = (_a = message.event) !== null && _a !== void 0 ? _a : {}; const eventType = this.pickFirstString(event.type); switch (eventType) { case 'message_start': { // Reset assistant state for new message assistantBuffer = ''; assistantMessageId = (0, node_crypto_1.randomUUID)(); assistantCreatedAt = new Date().toISOString(); lastAssistantEmitted = null; break; } case 'content_block_start': { const contentBlock = event.content_block; const blockIndex = typeof event.index === 'number' ? event.index : ++currentContentBlockIndex; currentContentBlockIndex = blockIndex; if (contentBlock && contentBlock.type === 'tool_use') { const toolName = this.pickFirstString(contentBlock.name) || 'tool'; const toolId = this.pickFirstString(contentBlock.id) || ''; // Store pending tool input for accumulation // Don't emit message here - wait for content_block_stop with complete input pendingToolInputs.set(blockIndex, { toolName, toolId, inputJsonParts: [], }); } else if (contentBlock && contentBlock.type === 'tool_result') { // Handle tool_result in content_block_start const metadata = this.buildToolResultMetadata(contentBlock); const content = this.extractToolResultContent(contentBlock); const isError = contentBlock.is_error === true; dispatchToolMessage(isError ? `Error: ${content || 'Tool execution failed'}` : content || 'Tool completed', metadata, 'tool_result', false); } break; } case 'content_block_stop': { const blockIndex = typeof event.index === 'number' ? event.index : currentContentBlockIndex; // Check if we have accumulated tool input for this block if (pendingToolInputs.has(blockIndex)) { const pending = pendingToolInputs.get(blockIndex); pendingToolInputs.delete(blockIndex); // Parse the accumulated JSON const fullJsonStr = pending.inputJsonParts.join(''); let input = {}; try { if (fullJsonStr) { input = JSON.parse(fullJsonStr); } } catch (e) { console.error(`[ClaudeEngine] Failed to parse tool input JSON: ${e}`); } console.error(`[ClaudeEngine] content_block_stop - toolName: ${pending.toolName}, input: ${JSON.stringify(input).slice(0, 500)}`); // Build metadata with full input const metadata = buildToolMetadata({ name: pending.toolName, id: pending.toolId, input, }); // Build informative content let content = `Using tool: ${pending.toolName}`; if (input.command) content = `Running: ${input.command}`; else if (input.file_path) content = `Operating on: ${input.file_path}`; else if (input.pattern) content = `Searching: ${input.pattern}`; else if (input.query) content = `Searching: ${input.query}`; // Emit final tool_use message with complete metadata dispatchToolMessage(content, metadata, 'tool_use', false); } // Check if this block was a tool_result const contentBlock = event.content_block; if (contentBlock && contentBlock.type === 'tool_result') { const metadata = this.buildToolResultMetadata(contentBlock); const content = this.extractToolResultContent(contentBlock); const isError = contentBlock.is_error === true; dispatchToolMessage(isError ? `Error: ${content || 'Tool execution failed'}` : content || 'Tool completed', metadata, 'tool_result', false); } break; } case 'content_block_delta': { const delta = event.delta; const blockIndex = typeof event.index === 'number' ? event.index : currentContentBlockIndex; // Check if this is a tool_use input_json_delta if (delta && typeof delta === 'object' && delta.type === 'input_json_delta') { const partialJson = delta.partial_json; if (partialJson && pendingToolInputs.has(blockIndex)) { pendingToolInputs.get(blockIndex).inputJsonParts.push(partialJson); } break; } // Handle text delta for assistant messages let textChunk = ''; if (typeof delta === 'string') { textChunk = delta; } else if (delta && typeof delta === 'object') { if (typeof delta.text === 'string') { textChunk = delta.text; } else if (typeof delta.delta === 'string') { textChunk = delta.delta; } else if (typeof delta.partial === 'string') { textChunk = delta.partial; } } if (textChunk) { assistantBuffer += textChunk; emitAssistant(false); } break; } case 'message_delta': { // message_delta usually contains metadata only (stop_reason, usage) // Don't emit final here to avoid duplicate finals break; } case 'message_stop': { // Emit final assistant message only on message_stop emitAssistant(true); break; } default: // Other stream events are ignored break; } } else if (message.type === 'assistant') { // Fallback for non-streaming assistant messages const content = this.extractMessageContent(message); if (content) { assistantBuffer = content; emitAssistant(true); } } else if (message.type === 'result') { // Final result - check for errors first const resultRecord = message; // Log full result for debugging console.error(`[ClaudeEngine] Result message: ${JSON.stringify(resultRecord, null, 2)}`); // Extract and emit usage statistics const usage = resultRecord.usage; const totalCostUsd = typeof resultRecord.total_cost_usd === 'number' ? resultRecord.total_cost_usd : 0; const durationMs = typeof resultRecord.duration_ms === 'number' ? resultRecord.duration_ms : 0; const numTurns = typeof resultRecord.num_turns === 'number' ? resultRecord.num_turns : 0; if (usage || totalCostUsd > 0) { ctx.emit({ type: 'usage', data: { sessionId, requestId, inputTokens: typeof (usage === null || usage === void 0 ? void 0 : usage.input_tokens) === 'number' ? usage.input_tokens : 0, outputTokens: typeof (usage === null || usage === void 0 ? void 0 : usage.output_tokens) === 'number' ? usage.output_tokens : 0, cacheReadInputTokens: typeof (usage === null || usage === void 0 ? void 0 : usage.cache_read_input_tokens) === 'number' ? usage.cache_read_input_tokens : undefined, cacheCreationInputTokens: typeof (usage === null || usage === void 0 ? void 0 : usage.cache_creation_input_tokens) === 'number' ? usage.cache_creation_input_tokens : undefined, totalCostUsd, durationMs, numTurns, }, }); } // Check if result contains errors (SDK puts error details here) // Note: is_error can be true even with empty errors array if (resultRecord.is_error) { const errors = resultRecord.errors; const resultText = resultRecord.result; const errorMsg = (errors === null || errors === void 0 ? void 0 : errors.length) ? errors.join('; ') : resultText || 'Unknown error from Claude Code'; console.error(`[ClaudeEngine] Result error: ${errorMsg}`); // Check if this is a resume failure const isResumeFailure = errorMsg.includes('No conversation found') || errorMsg.includes('Failed to resume session') || errorMsg.includes('session ID'); if (isResumeFailure && resumeClaudeSessionId) { // Clear the stored session ID so next request starts fresh if (ctx.persistClaudeSessionId && projectId) { try { // Pass empty string to clear the session await ctx.persistClaudeSessionId(''); console.error('[ClaudeEngine] Cleared invalid session ID'); } catch (_b) { // Ignore clear errors } } throw new Error(`Resume failed: ${errorMsg}. Session has been cleared - please retry.`); } throw new Error(errorMsg); } // Extract content from successful result const resultContent = this.extractMessageContent(message); if (resultContent && resultContent !== assistantBuffer.trim()) { assistantBuffer = resultContent; emitAssistant(true); } } else if (message.type === 'system') { // Handle system messages const record = message; const subtype = this.pickFirstString(record.subtype); if (subtype === 'init') { // system:init - contains session_id and management information const claudeSessionId = record.session_id ? String(record.session_id) : undefined; if (claudeSessionId) { console.error(`[ClaudeEngine] Session initialized: ${claudeSessionId}`); // Persist the session ID if callback is provided and projectId exists if (ctx.persistClaudeSessionId && projectId) { try { await ctx.persistClaudeSessionId(claudeSessionId); console.error(`[ClaudeEngine] Session ID persisted for project: ${projectId}`); } catch (persistError) { console.error('[ClaudeEngine] Failed to persist session ID:', persistError); } } } // Extract and persist management information if (ctx.persistManagementInfo) { try { const managementInfo = { tools: Array.isArray(record.tools) ? record.tools.filter((t) => typeof t === 'string') : undefined, agents: Array.isArray(record.agents) ? record.agents.filter((a) => typeof a === 'string') : undefined, // SDK returns plugins as { name, path }[] objects plugins: Array.isArray(record.plugins) ? record.plugins .filter((p) => p && typeof p.name === 'string') .map((p) => ({ name: String(p.name), path: p.path ? String(p.path) : undefined, })) : undefined, skills: Array.isArray(record.skills) ? record.skills.filter((s) => typeof s === 'string') : undefined, mcpServers: Array.isArray(record.mcp_servers) ? record.mcp_servers .filter((s) => s && typeof s.name === 'string') .map((s) => ({ name: String(s.name), status: String(s.status || 'unknown'), })) : undefined, slashCommands: Array.isArray(record.slash_commands) ? record.slash_commands.filter((c) => typeof c === 'string') : undefined, model: this.pickFirstString(record.model), permissionMode: this.pickFirstString(record.permissionMode), cwd: this.pickFirstString(record.cwd), outputStyle: this.pickFirstString(record.output_style), betas: Array.isArray(record.betas) ? record.betas.filter((b) => typeof b === 'string') : undefined, claudeCodeVersion: this.pickFirstString(record.claude_code_version), apiKeySource: this.pickFi