diff --git a/resources/database/drizzle/meta/0000_snapshot.json b/resources/database/drizzle/meta/0000_snapshot.json index 6bfe1204d7..140460cf09 100644 --- a/resources/database/drizzle/meta/0000_snapshot.json +++ b/resources/database/drizzle/meta/0000_snapshot.json @@ -328,4 +328,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/resources/database/drizzle/meta/_journal.json b/resources/database/drizzle/meta/_journal.json index 86f2a531c0..74624cded7 100644 --- a/resources/database/drizzle/meta/_journal.json +++ b/resources/database/drizzle/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/scripts/before-pack.js b/scripts/before-pack.js index 59c0a39171..9bebf24889 100644 --- a/scripts/before-pack.js +++ b/scripts/before-pack.js @@ -35,6 +35,9 @@ const allX64 = { '@napi-rs/system-ocr-win32-x64-msvc': '1.0.2' } +const claudeCodeVenderPath = '@anthropic-ai/claude-code/vendor' +const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32'] + const platformToArch = { mac: 'darwin', windows: 'win32', @@ -46,9 +49,6 @@ exports.default = async function (context) { const archType = arch === Arch.arm64 ? 'arm64' : 'x64' const platform = context.packager.platform.name - const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**') - const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*') - const downloadPackages = async (packages) => { console.log('downloading packages ......') const downloadPromises = [] @@ -67,25 +67,39 @@ exports.default = async function (context) { await Promise.all(downloadPromises) } - const changeFilters = async (packages, filtersToExclude, filtersToInclude) => { - await downloadPackages(packages) + const changeFilters = async (filtersToExclude, filtersToInclude) => { // remove filters for the target architecture (allow inclusion) - let filters = context.packager.config.files[0].filter filters = filters.filter((filter) => !filtersToInclude.includes(filter)) + // add filters for other architectures (exclude them) filters.push(...filtersToExclude) context.packager.config.files[0].filter = filters } - if (arch === Arch.arm64) { - await changeFilters(allArm64, x64Filters, arm64Filters) - return - } + await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64) - if (arch === Arch.x64) { - await changeFilters(allX64, arm64Filters, x64Filters) - return + const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**') + const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*') + const excludeClaudeCodeRipgrepFilters = claudeCodeVenders + .filter((f) => f !== `${archType}-${platformToArch[platform]}`) + .map((f) => '!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + f + '/**') + const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin'] + + const includeClaudeCodeFilters = [ + '!node_modules/' + claudeCodeVenderPath + '/' + `${archType}-${platformToArch[platform]}/**` + ] + + if (arch === Arch.arm64) { + await changeFilters( + [...x64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins], + [...arm64Filters, ...includeClaudeCodeFilters] + ) + } else { + await changeFilters( + [...arm64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins], + [...x64Filters, ...includeClaudeCodeFilters] + ) } } diff --git a/src/main/apiServer/routes/agents/handlers/messages.ts b/src/main/apiServer/routes/agents/handlers/messages.ts index 571778f337..72f286415a 100644 --- a/src/main/apiServer/routes/agents/handlers/messages.ts +++ b/src/main/apiServer/routes/agents/handlers/messages.ts @@ -36,10 +36,7 @@ export const createMessage = async (req: Request, res: Response): Promise logger.debug('Streaming message data:', messageData) // Step 1: Save user message first - const userMessage = await sessionMessageService.saveUserMessage( - sessionId, - messageData.content - ) + const userMessage = await sessionMessageService.saveUserMessage(sessionId, messageData.content) // Set SSE headers res.setHeader('Content-Type', 'text/event-stream') @@ -48,7 +45,6 @@ export const createMessage = async (req: Request, res: Response): Promise res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', 'Cache-Control') - const messageStream = sessionMessageService.createSessionMessage(session, messageData, userMessage.id) // Track stream lifecycle so we keep the SSE connection open until persistence finishes diff --git a/src/main/services/agents/database/MigrationService.ts b/src/main/services/agents/database/MigrationService.ts index 71d4b80c41..fce09bc68b 100644 --- a/src/main/services/agents/database/MigrationService.ts +++ b/src/main/services/agents/database/MigrationService.ts @@ -51,9 +51,7 @@ export class MigrationService { } // Get applied migrations - const appliedMigrations = hasMigrationsTable - ? await this.getAppliedMigrations() - : [] + const appliedMigrations = hasMigrationsTable ? await this.getAppliedMigrations() : [] const appliedVersions = new Set(appliedMigrations.map((m) => Number(m.version))) const latestAppliedVersion = appliedMigrations.reduce( @@ -90,9 +88,7 @@ export class MigrationService { private async migrationsTableExists(): Promise { try { - const table = await this.client.execute( - `SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'` - ) + const table = await this.client.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`) return table.rows.length > 0 } catch (error) { logger.error('Failed to check migrations table status:', { error }) @@ -162,5 +158,4 @@ export class MigrationService { throw error } } - } diff --git a/src/main/services/agents/database/schema/migrations.schema.ts b/src/main/services/agents/database/schema/migrations.schema.ts index b3485ca911..ab0ad17b90 100644 --- a/src/main/services/agents/database/schema/migrations.schema.ts +++ b/src/main/services/agents/database/schema/migrations.schema.ts @@ -11,4 +11,4 @@ export const migrations = sqliteTable('migrations', { }) export type Migration = typeof migrations.$inferSelect -export type NewMigration = typeof migrations.$inferInsert \ No newline at end of file +export type NewMigration = typeof migrations.$inferInsert diff --git a/src/main/services/agents/services/SessionMessageService.ts b/src/main/services/agents/services/SessionMessageService.ts index 1ab016b198..acd7cb1ab9 100644 --- a/src/main/services/agents/services/SessionMessageService.ts +++ b/src/main/services/agents/services/SessionMessageService.ts @@ -1,11 +1,12 @@ import { EventEmitter } from 'node:events' +import { PermissionMode } from '@anthropic-ai/claude-code' import { loggerService } from '@logger' import type { AgentSessionMessageEntity, CreateSessionMessageRequest, GetAgentSessionResponse, - ListOptions, + ListOptions } from '@types' import { ModelMessage, UIMessage, UIMessageChunk } from 'ai' import { convertToModelMessages, readUIMessageStream } from 'ai' @@ -17,7 +18,6 @@ import ClaudeCodeService from './claudecode' const logger = loggerService.withContext('SessionMessageService') - // Collapse a UIMessageChunk stream into a final UIMessage, then convert to ModelMessage[] export async function chunksToModelMessages( chunkStream: ReadableStream, @@ -68,7 +68,6 @@ interface PersistContext { session: GetAgentSessionResponse accumulator: ChunkAccumulator userMessageId: number - sessionStream: EventEmitter } // Chunk accumulator class to collect and reconstruct streaming data @@ -254,10 +253,7 @@ export class SessionMessageService extends BaseService { updated_at: now } - const [saved] = await this.database - .insert(sessionMessagesTable) - .values(insertData) - .returning() + const [saved] = await this.database.insert(sessionMessagesTable).values(insertData).returning() return this.deserializeSessionMessage(saved) as AgentSessionMessageEntity } @@ -299,8 +295,8 @@ export class SessionMessageService extends BaseService { // Create the streaming agent invocation (using invokeStream for streaming) const claudeStream = this.cc.invoke(req.content, session.accessible_paths[0], session_id, { - permissionMode: session.configuration?.permissionMode || 'default', - maxTurns: session.configuration?.maxTurns || 10 + permissionMode: (session.configuration?.permissionMode as PermissionMode) || 'default', + maxTurns: (session.configuration?.maxTurns as number) || 10 }) // Use chunk accumulator to manage streaming data @@ -345,8 +341,7 @@ export class SessionMessageService extends BaseService { void this.persistSessionMessageAsync({ session, accumulator, - userMessageId, - sessionStream + userMessageId }) } @@ -355,6 +350,10 @@ export class SessionMessageService extends BaseService { error: serializeError(underlyingError), persistScheduled }) + // Always emit a finish chunk at the end + sessionStream.emit('data', { + type: 'finish' + }) break } @@ -367,18 +366,15 @@ export class SessionMessageService extends BaseService { // Set the agent result in the accumulator accumulator.setAgentResult(event.agentResult) - // // Emit SSE completion FIRST before persistence - // sessionStream.emit('data', { - // type: 'complete', - // result: accumulator.buildStructuredContent() - // }) - // Then handle async persistence void this.persistSessionMessageAsync({ session, accumulator, - userMessageId, - sessionStream + userMessageId + }) + // Always emit a finish chunk at the end + sessionStream.emit('data', { + type: 'finish' }) break } @@ -399,11 +395,10 @@ export class SessionMessageService extends BaseService { }) } - private async persistSessionMessageAsync({ session, accumulator, userMessageId, sessionStream }: PersistContext) { + private async persistSessionMessageAsync({ session, accumulator, userMessageId }: PersistContext) { if (!session?.id) { const missingSessionError = new Error('Missing session_id for persisted message') - logger.error(missingSessionError.message, { error: missingSessionError }) - sessionStream.emit('data', { type: 'persist-error', error: serializeError(missingSessionError) }) + logger.error('error persisting session message', { error: missingSessionError }) return } @@ -435,13 +430,10 @@ export class SessionMessageService extends BaseService { updated_at: now } - const [row] = await this.database.insert(sessionMessagesTable).values(insertData).returning() - - const entity = this.deserializeSessionMessage(row) as AgentSessionMessageEntity - sessionStream.emit('data', { type: 'persisted', message: entity }) + await this.database.insert(sessionMessagesTable).values(insertData).returning() + logger.debug('Success Persisted session message') } catch (error) { logger.error('Failed to persist session message', { error }) - sessionStream.emit('data', { type: 'persist-error', error: serializeError(error) }) } } diff --git a/src/main/services/agents/services/SessionService.ts b/src/main/services/agents/services/SessionService.ts index 6b057f0f99..39f73f52fd 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -11,7 +11,6 @@ import { and, count, eq, type SQL } from 'drizzle-orm' import { BaseService } from '../BaseService' import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema' - export class SessionService extends BaseService { private static instance: SessionService | null = null diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 19bb97b6dd..02362a85a6 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -1,9 +1,8 @@ // src/main/services/agents/services/claudecode/index.ts -import { ChildProcess, spawn } from 'node:child_process' import { EventEmitter } from 'node:events' import { createRequire } from 'node:module' -import { Options, SDKMessage } from '@anthropic-ai/claude-code' +import { Options, query, SDKMessage } from '@anthropic-ai/claude-code' import { loggerService } from '@logger' import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' @@ -38,210 +37,129 @@ class ClaudeCodeService implements AgentServiceInterface { invoke(prompt: string, cwd: string, session_id?: string, base?: Options): AgentStream { const aiStream = new ClaudeCodeStream() - // Spawn process with same parameters as invoke - const args: string[] = [this.claudeExecutablePath, '--output-format', 'stream-json', '--verbose'] + // Build SDK options from parameters + const options: Options = { + cwd, + pathToClaudeCodeExecutable: this.claudeExecutablePath, + stderr: (chunk: string) => { + logger.info('claude stderr', { chunk }) + }, + ...base + } if (session_id) { - args.push('--resume', session_id) - } - if (base?.maxTurns) { - args.push('--max-turns', base.maxTurns.toString()) - } - if (base?.permissionMode) { - args.push('--permission-mode', base.permissionMode) + options.resume = session_id } - args.push('--print', prompt) - - logger.info('Spawning Claude Code streaming process', { args, cwd }) - - const p = spawn(process.execPath, args, { - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - shell: false, - detached: false + logger.info('Starting Claude Code SDK query', { + prompt, + options: { cwd, maxTurns: options.maxTurns, permissionMode: options.permissionMode } }) - logger.info('Streaming process created', { pid: p.pid }) - - // Close stdin immediately - if (p.stdin) { - p.stdin.end() - logger.debug('Closed stdin for streaming process') - } - - this.setupStreamingHandlers(p, aiStream) + // Start async processing + this.processSDKQuery(prompt, options, aiStream) return aiStream } /** - * Set up process event handlers for streaming output + * Process SDK query and emit stream events */ - private setupStreamingHandlers(process: ChildProcess, stream: ClaudeCodeStream): void { - let stdoutData = '' - let stderrData = '' - const jsonOutput: any[] = [] + private async processSDKQuery(prompt: string, options: Options, stream: ClaudeCodeStream): Promise { + const jsonOutput: SDKMessage[] = [] let hasCompleted = false - let stdoutBuffer = '' - const startTime = Date.now() - const emitChunks = (sdkMessage: SDKMessage) => { - jsonOutput.push(sdkMessage) - const chunks = transformSDKMessageToUIChunk(sdkMessage) - for (const chunk of chunks) { - stream.emit('data', { - type: 'chunk', - chunk, - rawAgentMessage: sdkMessage // Store Claude Code specific SDKMessage as generic agent message - }) + try { + // Process streaming responses using SDK query + for await (const message of query({ + prompt, + options + })) { + if (hasCompleted) break + + jsonOutput.push(message) + logger.silly('claude response', { message }) + if (message.type === 'assistant' || message.type === 'user') { + logger.silly('message content', { + message: JSON.stringify({ role: message.message.role, content: message.message.content }) + }) + } + + // Transform SDKMessage to UIMessageChunks + const chunks = transformSDKMessageToUIChunk(message) + for (const chunk of chunks) { + stream.emit('data', { + type: 'chunk', + chunk, + rawAgentMessage: message + }) + } } - } - // Handle stdout with streaming events - if (process.stdout) { - process.stdout.setEncoding('utf8') - process.stdout.on('data', (data: string) => { - stdoutData += data - stdoutBuffer += data - logger.debug('Streaming stdout chunk:', { length: data.length }) - - let newlineIndex = stdoutBuffer.indexOf('\n') - while (newlineIndex !== -1) { - const line = stdoutBuffer.slice(0, newlineIndex) - stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) - const trimmed = line.trim() - if (trimmed) { - try { - const parsed = JSON.parse(trimmed) as SDKMessage - emitChunks(parsed) - logger.debug('Parsed JSON line', { parsed }) - } catch (error) { - logger.debug('Non-JSON line', { line: trimmed }) - } - } - newlineIndex = stdoutBuffer.indexOf('\n') - } - }) - - process.stdout.on('end', () => { - const trimmed = stdoutBuffer.trim() - if (trimmed) { - try { - const parsed = JSON.parse(trimmed) as SDKMessage - emitChunks(parsed) - logger.debug('Parsed JSON line on stream end', { parsed }) - } catch (error) { - logger.debug('Non-JSON remainder on stdout end', { line: trimmed }) - } - } - logger.debug('Streaming stdout ended') - }) - } - - // Handle stderr - if (process.stderr) { - process.stderr.setEncoding('utf8') - process.stderr.on('data', (data: string) => { - stderrData += data - const message = data.trim() - if (!message) return - logger.warn('Streaming stderr chunk:', { data: message }) - stream.emit('data', { - type: 'error', - error: new Error(message) - }) - }) - - process.stderr.on('end', () => { - logger.debug('Streaming stderr ended') - }) - } - - // Handle process completion - const completeProcess = (code: number | null, signal: NodeJS.Signals | null, error?: Error) => { - if (hasCompleted) return + // Successfully completed hasCompleted = true - const duration = Date.now() - startTime - const success = !error && code === 0 - logger.info('Streaming process completed', { - code, - signal, - success, + logger.debug('SDK query completed successfully', { duration, - stdoutLength: stdoutData.length, - stderrLength: stderrData.length, - jsonItems: jsonOutput.length, - error: error?.message + messageCount: jsonOutput.length }) const result: ClaudeCodeResult = { - success, - stdout: stdoutData, - stderr: stderrData, + success: true, + stdout: '', + stderr: '', jsonOutput, - exitCode: code || undefined, - error + exitCode: 0 } - // Emit completion event with agent-specific result + // Emit completion event stream.emit('data', { type: 'complete', agentResult: { ...result, - rawSDKMessages: jsonOutput, // Claude Code specific: all collected SDK messages - agentType: 'claude-code' // Identify the agent type + rawSDKMessages: jsonOutput, + agentType: 'claude-code' + } + }) + } catch (error) { + if (hasCompleted) return + hasCompleted = true + + const duration = Date.now() - startTime + logger.error('SDK query error:', { + error: error instanceof Error ? error.message : String(error), + duration, + messageCount: jsonOutput.length + }) + + const result: ClaudeCodeResult = { + success: false, + stdout: '', + stderr: error instanceof Error ? error.message : String(error), + jsonOutput, + error: error instanceof Error ? error : new Error(String(error)), + exitCode: 1 + } + + // Emit error event + stream.emit('data', { + type: 'error', + error: error instanceof Error ? error : new Error(String(error)) + }) + + // Emit completion with error result + stream.emit('data', { + type: 'complete', + agentResult: { + ...result, + rawSDKMessages: jsonOutput, + agentType: 'claude-code' } }) } - - // Handle process exit - process.on('exit', (code, signal) => { - completeProcess(code, signal) - }) - - // Handle process errors - process.on('error', (error) => { - const duration = Date.now() - startTime - logger.error('Streaming process error:', { - error: error.message, - duration, - stdoutLength: stdoutData.length, - stderrLength: stderrData.length - }) - - completeProcess(null, null, error) - }) - - // Handle close event as a fallback - process.on('close', (code, signal) => { - logger.debug('Streaming process closed', { code, signal }) - completeProcess(code, signal) - }) - - // Set timeout to prevent hanging - const timeout = setTimeout(() => { - if (!hasCompleted) { - logger.error('Streaming process timeout after 600 seconds', { - pid: process.pid, - stdoutLength: stdoutData.length, - stderrLength: stderrData.length, - jsonItems: jsonOutput.length - }) - process.kill('SIGTERM') - completeProcess(null, null, new Error('Process timeout after 600 seconds')) - } - }, 600 * 1000) - - // Clear timeout when process ends - process.on('exit', () => clearTimeout(timeout)) - process.on('error', () => clearTimeout(timeout)) } - } export default ClaudeCodeService diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts index fc8f55132b..e3ea8ceef0 100644 --- a/src/main/services/agents/services/claudecode/transform.ts +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -327,11 +327,6 @@ function handleResultMessage(message: Extract): } } }) - - // Always emit a finish chunk at the end - chunks.push({ - type: 'finish' - }) return chunks } diff --git a/src/main/services/agents/services/index.ts b/src/main/services/agents/services/index.ts index 0ee284a502..e6e545a442 100644 --- a/src/main/services/agents/services/index.ts +++ b/src/main/services/agents/services/index.ts @@ -16,10 +16,11 @@ export { sessionMessageService } from './SessionMessageService' export { sessionService } from './SessionService' // Type definitions for service requests and responses -export type { AgentEntity, AgentSessionEntity,CreateAgentRequest, UpdateAgentRequest } from '@types' +export type { AgentEntity, AgentSessionEntity, CreateAgentRequest, UpdateAgentRequest } from '@types' export type { AgentSessionMessageEntity, CreateSessionRequest, GetAgentSessionResponse, ListOptions as SessionListOptions, - UpdateSessionRequest} from '@types' + UpdateSessionRequest +} from '@types' diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index f9b187606c..5b543285bf 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2486,7 +2486,7 @@ const migrateConfig = { return state } }, - '156': (state: RootState) => { + '157': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.anthropic) { @@ -2497,7 +2497,7 @@ const migrateConfig = { }) return state } catch (error) { - logger.error('migrate 156 error', error as Error) + logger.error('migrate 157 error', error as Error) return state } } diff --git a/src/renderer/src/ui/context-menu.tsx b/src/renderer/src/ui/context-menu.tsx index 1a8464d197..28864847af 100644 --- a/src/renderer/src/ui/context-menu.tsx +++ b/src/renderer/src/ui/context-menu.tsx @@ -42,7 +42,7 @@ function ContextMenuSubTrigger({ data-slot="context-menu-sub-trigger" data-inset={inset} className={cn( - "flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", className )} {...props}> @@ -57,7 +57,7 @@ function ContextMenuSubContent({ className, ...props }: React.ComponentProps @@ -162,7 +162,7 @@ function ContextMenuLabel({ ) @@ -182,7 +182,7 @@ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span return ( )