mcp-chrome-bridge
Version:
Chrome Native-Messaging host (Node)
917 lines • 69.4 kB
JavaScript
"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