| |
| |
| |
| |
|
|
| import path from 'path'; |
| import * as secureFs from '../lib/secure-fs.js'; |
| import type { EventEmitter } from '../lib/events.js'; |
| import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types'; |
| import { stripProviderPrefix } from '@automaker/types'; |
| import { |
| readImageAsBase64, |
| buildPromptWithImages, |
| isAbortError, |
| loadContextFiles, |
| createLogger, |
| classifyError, |
| } from '@automaker/utils'; |
| import { ProviderFactory } from '../providers/provider-factory.js'; |
| import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; |
| import type { SettingsService } from './settings-service.js'; |
| import { |
| getAutoLoadClaudeMdSetting, |
| getUseClaudeCodeSystemPromptSetting, |
| filterClaudeMdFromContext, |
| getMCPServersFromSettings, |
| getPromptCustomization, |
| getSkillsConfiguration, |
| getSubagentsConfiguration, |
| getCustomSubagents, |
| getProviderByModelId, |
| getDefaultMaxTurnsSetting, |
| } from '../lib/settings-helpers.js'; |
|
|
| interface Message { |
| id: string; |
| role: 'user' | 'assistant'; |
| content: string; |
| images?: Array<{ |
| data: string; |
| mimeType: string; |
| filename: string; |
| }>; |
| timestamp: string; |
| isError?: boolean; |
| } |
|
|
| interface QueuedPrompt { |
| id: string; |
| message: string; |
| imagePaths?: string[]; |
| model?: string; |
| thinkingLevel?: ThinkingLevel; |
| addedAt: string; |
| } |
|
|
| interface Session { |
| messages: Message[]; |
| isRunning: boolean; |
| abortController: AbortController | null; |
| workingDirectory: string; |
| model?: string; |
| thinkingLevel?: ThinkingLevel; |
| reasoningEffort?: ReasoningEffort; |
| sdkSessionId?: string; |
| promptQueue: QueuedPrompt[]; |
| } |
|
|
| interface SessionMetadata { |
| id: string; |
| name: string; |
| projectPath?: string; |
| workingDirectory: string; |
| createdAt: string; |
| updatedAt: string; |
| archived?: boolean; |
| tags?: string[]; |
| model?: string; |
| sdkSessionId?: string; |
| } |
|
|
| export class AgentService { |
| private sessions = new Map<string, Session>(); |
| private stateDir: string; |
| private metadataFile: string; |
| private events: EventEmitter; |
| private settingsService: SettingsService | null = null; |
| private logger = createLogger('AgentService'); |
|
|
| constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) { |
| this.stateDir = path.join(dataDir, 'agent-sessions'); |
| this.metadataFile = path.join(dataDir, 'sessions-metadata.json'); |
| this.events = events; |
| this.settingsService = settingsService ?? null; |
| } |
|
|
| async initialize(): Promise<void> { |
| await secureFs.mkdir(this.stateDir, { recursive: true }); |
| } |
|
|
| |
| |
| |
| |
| private isStaleSessionError(rawErrorText: string): boolean { |
| const errorLower = rawErrorText.toLowerCase(); |
| return ( |
| errorLower.includes('session not found') || |
| errorLower.includes('session expired') || |
| errorLower.includes('invalid session') || |
| errorLower.includes('no such session') |
| ); |
| } |
|
|
| |
| |
| |
| async startConversation({ |
| sessionId, |
| workingDirectory, |
| }: { |
| sessionId: string; |
| workingDirectory?: string; |
| }) { |
| |
| |
| |
| let session = await this.ensureSession(sessionId, workingDirectory); |
| if (!session) { |
| |
| const effectiveWorkingDirectory = workingDirectory || process.cwd(); |
| const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); |
| validateWorkingDirectory(resolvedWorkingDirectory); |
|
|
| session = { |
| messages: [], |
| isRunning: false, |
| abortController: null, |
| workingDirectory: resolvedWorkingDirectory, |
| promptQueue: [], |
| }; |
| this.sessions.set(sessionId, session); |
| } |
|
|
| return { |
| success: true, |
| messages: session.messages, |
| sessionId, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private async ensureSession( |
| sessionId: string, |
| workingDirectory?: string |
| ): Promise<Session | null> { |
| const existing = this.sessions.get(sessionId); |
| if (existing) { |
| return existing; |
| } |
|
|
| |
| |
| let metadata: Record<string, SessionMetadata>; |
| let messages: Message[]; |
| try { |
| [metadata, messages] = await Promise.all([this.loadMetadata(), this.loadSession(sessionId)]); |
| } catch (error) { |
| |
| |
| |
| this.logger.error( |
| `Failed to load session ${sessionId} from disk (I/O error — NOT a missing session):`, |
| error |
| ); |
| return null; |
| } |
|
|
| const sessionMetadata = metadata[sessionId]; |
|
|
| |
| |
| if (!sessionMetadata && messages.length === 0) { |
| this.logger.warn( |
| `Session "${sessionId}" not found: no metadata and no persisted messages. ` + |
| `This can happen when a session ID references a deleted/expired session, ` + |
| `or when the server restarted and the session was never persisted to disk. ` + |
| `Available session IDs in metadata: [${Object.keys(metadata).slice(0, 10).join(', ')}${Object.keys(metadata).length > 10 ? '...' : ''}]` |
| ); |
| return null; |
| } |
|
|
| const effectiveWorkingDirectory = |
| workingDirectory || sessionMetadata?.workingDirectory || process.cwd(); |
| const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); |
|
|
| |
| try { |
| validateWorkingDirectory(resolvedWorkingDirectory); |
| } catch (validationError) { |
| this.logger.warn( |
| `Session "${sessionId}": working directory "${resolvedWorkingDirectory}" is not allowed — ` + |
| `returning null so callers treat it as a missing session. Error: ${(validationError as Error).message}` |
| ); |
| return null; |
| } |
|
|
| |
| const promptQueue = await this.loadQueueState(sessionId); |
|
|
| const session: Session = { |
| messages, |
| isRunning: false, |
| abortController: null, |
| workingDirectory: resolvedWorkingDirectory, |
| sdkSessionId: sessionMetadata?.sdkSessionId, |
| promptQueue, |
| }; |
|
|
| this.sessions.set(sessionId, session); |
| this.logger.info( |
| `Auto-initialized session ${sessionId} from disk ` + |
| `(${messages.length} messages, sdkSessionId: ${sessionMetadata?.sdkSessionId ? 'present' : 'none'})` |
| ); |
| return session; |
| } |
|
|
| |
| |
| |
| async sendMessage({ |
| sessionId, |
| message, |
| workingDirectory, |
| imagePaths, |
| model, |
| thinkingLevel, |
| reasoningEffort, |
| }: { |
| sessionId: string; |
| message: string; |
| workingDirectory?: string; |
| imagePaths?: string[]; |
| model?: string; |
| thinkingLevel?: ThinkingLevel; |
| reasoningEffort?: ReasoningEffort; |
| }) { |
| const session = await this.ensureSession(sessionId, workingDirectory); |
| if (!session) { |
| this.logger.error( |
| `Session not found: ${sessionId}. ` + |
| `The session may have been deleted, never created, or lost after a server restart. ` + |
| `In-memory sessions: ${this.sessions.size}, requested ID: ${sessionId}` |
| ); |
| throw new Error( |
| `Session ${sessionId} not found. ` + |
| `The session may have been deleted or expired. ` + |
| `Please create a new session and try again.` |
| ); |
| } |
|
|
| if (session.isRunning) { |
| this.logger.error('ERROR: Agent already running for session:', sessionId); |
| throw new Error('Agent is already processing a message'); |
| } |
|
|
| |
| if (model) { |
| session.model = model; |
| await this.updateSession(sessionId, { model }); |
| } |
| if (thinkingLevel !== undefined) { |
| session.thinkingLevel = thinkingLevel; |
| } |
| if (reasoningEffort !== undefined) { |
| session.reasoningEffort = reasoningEffort; |
| } |
|
|
| |
| const effectiveModel = model || session.model; |
| if (imagePaths && imagePaths.length > 0 && effectiveModel) { |
| const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel); |
| if (!supportsVision) { |
| throw new Error( |
| `This model (${effectiveModel}) does not support image input. ` + |
| `Please switch to a model that supports vision, or remove the images and try again.` |
| ); |
| } |
| } |
|
|
| |
| const images: Message['images'] = []; |
| if (imagePaths && imagePaths.length > 0) { |
| for (const imagePath of imagePaths) { |
| try { |
| const imageData = await readImageAsBase64(imagePath); |
| images.push({ |
| data: imageData.base64, |
| mimeType: imageData.mimeType, |
| filename: imageData.filename, |
| }); |
| } catch (error) { |
| this.logger.error(`Failed to load image ${imagePath}:`, error); |
| } |
| } |
| } |
|
|
| |
| const userMessage: Message = { |
| id: this.generateId(), |
| role: 'user', |
| content: message, |
| images: images.length > 0 ? images : undefined, |
| timestamp: new Date().toISOString(), |
| }; |
|
|
| session.messages.push(userMessage); |
| session.isRunning = true; |
| session.abortController = new AbortController(); |
|
|
| |
| this.emitAgentEvent(sessionId, { |
| type: 'started', |
| }); |
|
|
| |
| this.emitAgentEvent(sessionId, { |
| type: 'message', |
| message: userMessage, |
| }); |
|
|
| await this.saveSession(sessionId, session.messages); |
|
|
| try { |
| |
| const effectiveWorkDir = workingDirectory || session.workingDirectory; |
|
|
| |
| const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( |
| effectiveWorkDir, |
| this.settingsService, |
| '[AgentService]' |
| ); |
|
|
| |
| |
| let useClaudeCodeSystemPrompt = true; |
| try { |
| useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( |
| effectiveWorkDir, |
| this.settingsService, |
| '[AgentService]' |
| ); |
| } catch (err) { |
| this.logger.error( |
| '[AgentService] getUseClaudeCodeSystemPromptSetting failed, defaulting to true', |
| err |
| ); |
| } |
|
|
| |
| const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); |
|
|
| |
| const skillsConfig = this.settingsService |
| ? await getSkillsConfiguration(this.settingsService) |
| : { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false }; |
|
|
| |
| const subagentsConfig = this.settingsService |
| ? await getSubagentsConfiguration(this.settingsService) |
| : { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false }; |
|
|
| |
| const customSubagents = |
| this.settingsService && subagentsConfig.enabled |
| ? await getCustomSubagents(this.settingsService, effectiveWorkDir) |
| : undefined; |
|
|
| |
| const credentials = await this.settingsService?.getCredentials(); |
|
|
| |
| |
| let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; |
| let providerResolvedModel: string | undefined; |
| const requestedModel = model || session.model; |
| if (requestedModel && this.settingsService) { |
| const providerResult = await getProviderByModelId( |
| requestedModel, |
| this.settingsService, |
| '[AgentService]' |
| ); |
| if (providerResult.provider) { |
| claudeCompatibleProvider = providerResult.provider; |
| providerResolvedModel = providerResult.resolvedModel; |
| this.logger.info( |
| `[AgentService] Using provider "${providerResult.provider.name}" for model "${requestedModel}"` + |
| (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') |
| ); |
| } |
| } |
|
|
| let combinedSystemPrompt: string | undefined; |
| |
| |
| const contextResult = await loadContextFiles({ |
| projectPath: effectiveWorkDir, |
| fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'], |
| taskContext: { |
| title: message.substring(0, 200), |
| description: message, |
| }, |
| }); |
|
|
| |
| |
| const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); |
|
|
| |
| const baseSystemPrompt = await this.getSystemPrompt(); |
| combinedSystemPrompt = contextFilesPrompt |
| ? `${contextFilesPrompt}\n\n${baseSystemPrompt}` |
| : baseSystemPrompt; |
|
|
| |
| |
| const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel; |
| const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort; |
|
|
| |
| |
| |
| const modelForSdk = providerResolvedModel || model; |
| const sessionModelForSdk = providerResolvedModel ? undefined : session.model; |
|
|
| |
| const userMaxTurns = await getDefaultMaxTurnsSetting(this.settingsService, '[AgentService]'); |
|
|
| const sdkOptions = createChatOptions({ |
| cwd: effectiveWorkDir, |
| model: modelForSdk, |
| sessionModel: sessionModelForSdk, |
| systemPrompt: combinedSystemPrompt, |
| abortController: session.abortController!, |
| autoLoadClaudeMd, |
| useClaudeCodeSystemPrompt, |
| thinkingLevel: effectiveThinkingLevel, |
| maxTurns: userMaxTurns, |
| mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, |
| }); |
|
|
| |
| const effectiveModel = sdkOptions.model!; |
| const maxTurns = sdkOptions.maxTurns; |
| let allowedTools = sdkOptions.allowedTools as string[] | undefined; |
|
|
| |
| const sdkSettingSources = (sdkOptions.settingSources ?? []).filter( |
| (source): source is 'user' | 'project' => source === 'user' || source === 'project' |
| ); |
| const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : []; |
| const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])]; |
|
|
| |
| |
| const needsSkillTool = skillsConfig.shouldIncludeInTools; |
| const needsTaskTool = |
| subagentsConfig.shouldIncludeInTools && |
| customSubagents && |
| Object.keys(customSubagents).length > 0; |
|
|
| |
| const baseTools = [ |
| 'Read', |
| 'Write', |
| 'Edit', |
| 'MultiEdit', |
| 'Glob', |
| 'Grep', |
| 'LS', |
| 'Bash', |
| 'WebSearch', |
| 'WebFetch', |
| 'TodoWrite', |
| ]; |
|
|
| if (allowedTools) { |
| allowedTools = [...allowedTools]; |
| |
| if (needsSkillTool && !allowedTools.includes('Skill')) { |
| allowedTools.push('Skill'); |
| } |
| |
| if (needsTaskTool && !allowedTools.includes('Task')) { |
| allowedTools.push('Task'); |
| } |
| } else if (needsSkillTool || needsTaskTool) { |
| |
| |
| allowedTools = [...baseTools]; |
| if (needsSkillTool) { |
| allowedTools.push('Skill'); |
| } |
| if (needsTaskTool) { |
| allowedTools.push('Task'); |
| } |
| } |
|
|
| |
| |
| const modelForProvider = claudeCompatibleProvider |
| ? (requestedModel ?? effectiveModel) |
| : effectiveModel; |
| const provider = ProviderFactory.getProviderForModel(modelForProvider); |
|
|
| |
| |
| |
| const bareModel: string = claudeCompatibleProvider |
| ? (requestedModel ?? effectiveModel) |
| : stripProviderPrefix(effectiveModel); |
|
|
| |
| const conversationHistory = session.messages |
| .slice(0, -1) |
| .map((msg) => ({ |
| role: msg.role, |
| content: msg.content, |
| })) |
| .filter((msg) => msg.content.trim().length > 0); |
|
|
| const options: ExecuteOptions = { |
| prompt: '', |
| model: bareModel, |
| originalModel: effectiveModel, |
| cwd: effectiveWorkDir, |
| systemPrompt: sdkOptions.systemPrompt, |
| maxTurns: maxTurns, |
| allowedTools: allowedTools, |
| abortController: session.abortController!, |
| conversationHistory: |
| conversationHistory && conversationHistory.length > 0 ? conversationHistory : undefined, |
| settingSources: settingSources.length > 0 ? settingSources : undefined, |
| sdkSessionId: session.sdkSessionId, |
| mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, |
| agents: customSubagents, |
| thinkingLevel: effectiveThinkingLevel, |
| reasoningEffort: effectiveReasoningEffort, |
| credentials, |
| claudeCompatibleProvider, |
| }; |
|
|
| |
| const { content: promptContent } = await buildPromptWithImages( |
| message, |
| imagePaths, |
| undefined, |
| true |
| ); |
|
|
| |
| options.prompt = promptContent; |
|
|
| |
| const stream = provider.executeQuery(options); |
|
|
| let currentAssistantMessage: Message | null = null; |
| let responseText = ''; |
| const toolUses: Array<{ name: string; input: unknown }> = []; |
| const toolNamesById = new Map<string, string>(); |
|
|
| for await (const msg of stream) { |
| |
| |
| |
| |
| |
| |
| if (msg.session_id && msg.session_id !== session.sdkSessionId) { |
| session.sdkSessionId = msg.session_id; |
| |
| await this.updateSession(sessionId, { sdkSessionId: msg.session_id }); |
| } |
|
|
| if (msg.type === 'assistant') { |
| if (msg.message?.content) { |
| for (const block of msg.message.content) { |
| if (block.type === 'text') { |
| responseText += block.text; |
|
|
| if (!currentAssistantMessage) { |
| currentAssistantMessage = { |
| id: this.generateId(), |
| role: 'assistant', |
| content: responseText, |
| timestamp: new Date().toISOString(), |
| }; |
| session.messages.push(currentAssistantMessage); |
| } else { |
| currentAssistantMessage.content = responseText; |
| } |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'stream', |
| messageId: currentAssistantMessage.id, |
| content: responseText, |
| isComplete: false, |
| }); |
| } else if (block.type === 'tool_use') { |
| const toolUse = { |
| name: block.name || 'unknown', |
| input: block.input, |
| }; |
| toolUses.push(toolUse); |
| if (block.tool_use_id) { |
| toolNamesById.set(block.tool_use_id, toolUse.name); |
| } |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'tool_use', |
| tool: toolUse, |
| }); |
| } else if (block.type === 'tool_result') { |
| const toolUseId = block.tool_use_id; |
| const toolName = toolUseId ? toolNamesById.get(toolUseId) : undefined; |
|
|
| |
| const rawContent: unknown = block.content; |
| let contentString: string; |
| if (typeof rawContent === 'string') { |
| contentString = rawContent; |
| } else if (Array.isArray(rawContent)) { |
| |
| contentString = rawContent |
| .map((part: { text?: string; type?: string }) => { |
| if (typeof part === 'string') return part; |
| if (part.text) return part.text; |
| |
| if (part.type) return `[${part.type}]`; |
| return JSON.stringify(part); |
| }) |
| .join('\n'); |
| } else if (rawContent !== undefined && rawContent !== null) { |
| contentString = JSON.stringify(rawContent); |
| } else { |
| contentString = ''; |
| } |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'tool_result', |
| tool: { |
| name: toolName || 'unknown', |
| input: { |
| toolUseId, |
| content: contentString, |
| }, |
| }, |
| }); |
| } |
| } |
| } |
| } else if (msg.type === 'result') { |
| if (msg.subtype === 'success' && msg.result) { |
| if (currentAssistantMessage) { |
| currentAssistantMessage.content = msg.result; |
| responseText = msg.result; |
| } |
| } |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'complete', |
| messageId: currentAssistantMessage?.id, |
| content: responseText, |
| toolUses, |
| }); |
| } else if (msg.type === 'error') { |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const rawMsgError = |
| (typeof msg.error === 'string' && msg.error.trim()) || |
| 'Unexpected error from provider during agent execution.'; |
| let rawErrorText = rawMsgError.replace(/\x1b\[[0-9;]*m/g, '').trim() || rawMsgError; |
| |
| rawErrorText = rawErrorText.replace(/^Error:\s*/i, '').trim() || rawErrorText; |
|
|
| const errorInfo = classifyError(new Error(rawErrorText)); |
|
|
| |
| |
| |
| |
| if (session.sdkSessionId && this.isStaleSessionError(rawErrorText)) { |
| this.logger.info( |
| `Clearing stale sdkSessionId for session ${sessionId} after provider session error` |
| ); |
| session.sdkSessionId = undefined; |
| await this.clearSdkSessionId(sessionId); |
| } |
|
|
| |
| |
| const enhancedText = errorInfo.isRateLimit |
| ? `${rawErrorText}\n\nTip: It looks like you hit a rate limit. Try waiting a bit or reducing concurrent Agent Runner / Auto Mode tasks.` |
| : rawErrorText; |
|
|
| this.logger.error('Provider error during agent execution:', { |
| type: errorInfo.type, |
| message: errorInfo.message, |
| }); |
|
|
| |
| session.isRunning = false; |
| session.abortController = null; |
|
|
| const errorMessage: Message = { |
| id: this.generateId(), |
| role: 'assistant', |
| content: `Error: ${enhancedText}`, |
| timestamp: new Date().toISOString(), |
| isError: true, |
| }; |
|
|
| session.messages.push(errorMessage); |
| await this.saveSession(sessionId, session.messages); |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'error', |
| error: enhancedText, |
| message: errorMessage, |
| }); |
|
|
| |
| return { |
| success: false, |
| }; |
| } |
| } |
|
|
| await this.saveSession(sessionId, session.messages); |
|
|
| session.isRunning = false; |
| session.abortController = null; |
|
|
| |
| setImmediate(() => this.processNextInQueue(sessionId)); |
|
|
| return { |
| success: true, |
| message: currentAssistantMessage, |
| }; |
| } catch (error) { |
| if (isAbortError(error)) { |
| session.isRunning = false; |
| session.abortController = null; |
| return { success: false, aborted: true }; |
| } |
|
|
| this.logger.error('Error:', error); |
|
|
| |
| |
| let rawThrownMsg = ((error as Error).message || '').replace(/\x1b\[[0-9;]*m/g, '').trim(); |
| rawThrownMsg = rawThrownMsg.replace(/^Error:\s*/i, '').trim() || rawThrownMsg; |
| const thrownErrorMsg = rawThrownMsg.toLowerCase(); |
|
|
| |
| |
| if (session.sdkSessionId && this.isStaleSessionError(rawThrownMsg)) { |
| this.logger.info( |
| `Clearing stale sdkSessionId for session ${sessionId} after thrown session error` |
| ); |
| session.sdkSessionId = undefined; |
| await this.clearSdkSessionId(sessionId); |
| } |
|
|
| session.isRunning = false; |
| session.abortController = null; |
|
|
| const cleanErrorMsg = rawThrownMsg || (error as Error).message; |
| const errorMessage: Message = { |
| id: this.generateId(), |
| role: 'assistant', |
| content: `Error: ${cleanErrorMsg}`, |
| timestamp: new Date().toISOString(), |
| isError: true, |
| }; |
|
|
| session.messages.push(errorMessage); |
| await this.saveSession(sessionId, session.messages); |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'error', |
| error: cleanErrorMsg, |
| message: errorMessage, |
| }); |
|
|
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| async getHistory(sessionId: string) { |
| const session = await this.ensureSession(sessionId); |
| if (!session) { |
| return { success: false, error: 'Session not found' }; |
| } |
|
|
| return { |
| success: true, |
| messages: session.messages, |
| isRunning: session.isRunning, |
| }; |
| } |
|
|
| |
| |
| |
| async stopExecution(sessionId: string) { |
| const session = await this.ensureSession(sessionId); |
| if (!session) { |
| return { success: false, error: 'Session not found' }; |
| } |
|
|
| if (session.abortController) { |
| session.abortController.abort(); |
| session.isRunning = false; |
| session.abortController = null; |
| } |
|
|
| return { success: true }; |
| } |
|
|
| |
| |
| |
| async clearSession(sessionId: string) { |
| const session = this.sessions.get(sessionId); |
| if (session) { |
| session.messages = []; |
| session.isRunning = false; |
| session.sdkSessionId = undefined; |
| await this.saveSession(sessionId, []); |
| } |
|
|
| |
| |
| |
| |
| await this.clearSdkSessionId(sessionId); |
|
|
| return { success: true }; |
| } |
|
|
| |
|
|
| async loadSession(sessionId: string): Promise<Message[]> { |
| const sessionFile = path.join(this.stateDir, `${sessionId}.json`); |
|
|
| try { |
| const data = (await secureFs.readFile(sessionFile, 'utf-8')) as string; |
| return JSON.parse(data); |
| } catch { |
| return []; |
| } |
| } |
|
|
| async saveSession(sessionId: string, messages: Message[]): Promise<void> { |
| const sessionFile = path.join(this.stateDir, `${sessionId}.json`); |
|
|
| try { |
| await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8'); |
| await this.updateSessionTimestamp(sessionId); |
| } catch (error) { |
| this.logger.error('Failed to save session:', error); |
| } |
| } |
|
|
| async loadMetadata(): Promise<Record<string, SessionMetadata>> { |
| try { |
| const data = (await secureFs.readFile(this.metadataFile, 'utf-8')) as string; |
| return JSON.parse(data); |
| } catch { |
| return {}; |
| } |
| } |
|
|
| async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> { |
| await secureFs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), 'utf-8'); |
| } |
|
|
| async updateSessionTimestamp(sessionId: string): Promise<void> { |
| const metadata = await this.loadMetadata(); |
| if (metadata[sessionId]) { |
| metadata[sessionId].updatedAt = new Date().toISOString(); |
| await this.saveMetadata(metadata); |
| } |
| } |
|
|
| async listSessions(includeArchived = false): Promise<SessionMetadata[]> { |
| const metadata = await this.loadMetadata(); |
| let sessions = Object.values(metadata); |
|
|
| if (!includeArchived) { |
| sessions = sessions.filter((s) => !s.archived); |
| } |
|
|
| return sessions.sort( |
| (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() |
| ); |
| } |
|
|
| async createSession( |
| name: string, |
| projectPath?: string, |
| workingDirectory?: string, |
| model?: string |
| ): Promise<SessionMetadata> { |
| const sessionId = this.generateId(); |
| const metadata = await this.loadMetadata(); |
|
|
| |
| const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd(); |
| const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); |
|
|
| |
| validateWorkingDirectory(resolvedWorkingDirectory); |
|
|
| |
| if (projectPath) { |
| validateWorkingDirectory(projectPath); |
| } |
|
|
| const session: SessionMetadata = { |
| id: sessionId, |
| name, |
| projectPath, |
| workingDirectory: resolvedWorkingDirectory, |
| createdAt: new Date().toISOString(), |
| updatedAt: new Date().toISOString(), |
| model, |
| }; |
|
|
| metadata[sessionId] = session; |
| await this.saveMetadata(metadata); |
|
|
| return session; |
| } |
|
|
| async setSessionModel(sessionId: string, model: string): Promise<boolean> { |
| const session = this.sessions.get(sessionId); |
| if (session) { |
| session.model = model; |
| await this.updateSession(sessionId, { model }); |
| return true; |
| } |
| return false; |
| } |
|
|
| async updateSession( |
| sessionId: string, |
| updates: Partial<SessionMetadata> |
| ): Promise<SessionMetadata | null> { |
| const metadata = await this.loadMetadata(); |
| if (!metadata[sessionId]) return null; |
|
|
| metadata[sessionId] = { |
| ...metadata[sessionId], |
| ...updates, |
| updatedAt: new Date().toISOString(), |
| }; |
|
|
| await this.saveMetadata(metadata); |
| return metadata[sessionId]; |
| } |
|
|
| async archiveSession(sessionId: string): Promise<boolean> { |
| const result = await this.updateSession(sessionId, { archived: true }); |
| return result !== null; |
| } |
|
|
| async unarchiveSession(sessionId: string): Promise<boolean> { |
| const result = await this.updateSession(sessionId, { archived: false }); |
| return result !== null; |
| } |
|
|
| async deleteSession(sessionId: string): Promise<boolean> { |
| const metadata = await this.loadMetadata(); |
| if (!metadata[sessionId]) return false; |
|
|
| delete metadata[sessionId]; |
| await this.saveMetadata(metadata); |
|
|
| |
| try { |
| const sessionFile = path.join(this.stateDir, `${sessionId}.json`); |
| await secureFs.unlink(sessionFile); |
| } catch { |
| |
| } |
|
|
| |
| this.sessions.delete(sessionId); |
|
|
| return true; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async clearSdkSessionId(sessionId: string): Promise<void> { |
| const metadata = await this.loadMetadata(); |
| if (metadata[sessionId] && metadata[sessionId].sdkSessionId) { |
| delete metadata[sessionId].sdkSessionId; |
| metadata[sessionId].updatedAt = new Date().toISOString(); |
| await this.saveMetadata(metadata); |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| async addToQueue( |
| sessionId: string, |
| prompt: { |
| message: string; |
| imagePaths?: string[]; |
| model?: string; |
| thinkingLevel?: ThinkingLevel; |
| } |
| ): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> { |
| const session = await this.ensureSession(sessionId); |
| if (!session) { |
| return { success: false, error: 'Session not found' }; |
| } |
|
|
| const queuedPrompt: QueuedPrompt = { |
| id: this.generateId(), |
| message: prompt.message, |
| imagePaths: prompt.imagePaths, |
| model: prompt.model, |
| thinkingLevel: prompt.thinkingLevel, |
| addedAt: new Date().toISOString(), |
| }; |
|
|
| session.promptQueue.push(queuedPrompt); |
| await this.saveQueueState(sessionId, session.promptQueue); |
|
|
| |
| this.emitAgentEvent(sessionId, { |
| type: 'queue_updated', |
| queue: session.promptQueue, |
| }); |
|
|
| return { success: true, queuedPrompt }; |
| } |
|
|
| |
| |
| |
| async getQueue( |
| sessionId: string |
| ): Promise<{ success: boolean; queue?: QueuedPrompt[]; error?: string }> { |
| const session = await this.ensureSession(sessionId); |
| if (!session) { |
| return { success: false, error: 'Session not found' }; |
| } |
| return { success: true, queue: session.promptQueue }; |
| } |
|
|
| |
| |
| |
| async removeFromQueue( |
| sessionId: string, |
| promptId: string |
| ): Promise<{ success: boolean; error?: string }> { |
| const session = await this.ensureSession(sessionId); |
| if (!session) { |
| return { success: false, error: 'Session not found' }; |
| } |
|
|
| const index = session.promptQueue.findIndex((p) => p.id === promptId); |
| if (index === -1) { |
| return { success: false, error: 'Prompt not found in queue' }; |
| } |
|
|
| session.promptQueue.splice(index, 1); |
| await this.saveQueueState(sessionId, session.promptQueue); |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'queue_updated', |
| queue: session.promptQueue, |
| }); |
|
|
| return { success: true }; |
| } |
|
|
| |
| |
| |
| async clearQueue(sessionId: string): Promise<{ success: boolean; error?: string }> { |
| const session = await this.ensureSession(sessionId); |
| if (!session) { |
| return { success: false, error: 'Session not found' }; |
| } |
|
|
| session.promptQueue = []; |
| await this.saveQueueState(sessionId, []); |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'queue_updated', |
| queue: [], |
| }); |
|
|
| return { success: true }; |
| } |
|
|
| |
| |
| |
| private async saveQueueState(sessionId: string, queue: QueuedPrompt[]): Promise<void> { |
| const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`); |
| try { |
| await secureFs.writeFile(queueFile, JSON.stringify(queue, null, 2), 'utf-8'); |
| } catch (error) { |
| this.logger.error('Failed to save queue state:', error); |
| } |
| } |
|
|
| |
| |
| |
| private async loadQueueState(sessionId: string): Promise<QueuedPrompt[]> { |
| const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`); |
| try { |
| const data = (await secureFs.readFile(queueFile, 'utf-8')) as string; |
| return JSON.parse(data); |
| } catch { |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| private async processNextInQueue(sessionId: string): Promise<void> { |
| const session = this.sessions.get(sessionId); |
| if (!session || session.promptQueue.length === 0) { |
| return; |
| } |
|
|
| |
| if (session.isRunning) { |
| return; |
| } |
|
|
| const nextPrompt = session.promptQueue.shift(); |
| if (!nextPrompt) return; |
|
|
| await this.saveQueueState(sessionId, session.promptQueue); |
|
|
| this.emitAgentEvent(sessionId, { |
| type: 'queue_updated', |
| queue: session.promptQueue, |
| }); |
|
|
| try { |
| await this.sendMessage({ |
| sessionId, |
| message: nextPrompt.message, |
| imagePaths: nextPrompt.imagePaths, |
| model: nextPrompt.model, |
| thinkingLevel: nextPrompt.thinkingLevel, |
| }); |
| } catch (error) { |
| this.logger.error('Failed to process queued prompt:', error); |
| this.emitAgentEvent(sessionId, { |
| type: 'queue_error', |
| error: (error as Error).message, |
| promptId: nextPrompt.id, |
| }); |
| } |
| } |
|
|
| |
| |
| |
| private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void { |
| this.events.emit('agent:stream', { sessionId, ...data }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| emitSessionError(sessionId: string, error: string): void { |
| this.events.emit('agent:stream', { sessionId, type: 'error', error }); |
| } |
|
|
| private async getSystemPrompt(): Promise<string> { |
| |
| const prompts = await getPromptCustomization(this.settingsService, '[AgentService]'); |
| return prompts.agent.systemPrompt; |
| } |
|
|
| private generateId(): string { |
| return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; |
| } |
| } |
|
|